/** * hal/ai/shell.mjs — CLI 로그인용 텍스트 셸(0.3.2, 개발 지시 v0.3.2 §1). 운영자가 HAL 화면에서 서버의 Claude Code·Codex CLI 로그인(또는 로그아웃·상태·버전)을 직접 수행한다. * * 경계(임의 셸이 아니다) * - 실행할 수 있는 것은 SHELL_COMMANDS 의 허용 명령뿐이다(tool × command). 명령 문자열을 받지 않고 (tool, command 이름)만 받아 HAL 이 인자 배열을 만든다. * - 실행 파일은 hal/ai/providers.mjs 의 자동 탐색(findCliBin) 결과 또는 연결에 적힌 절대 경로. * - PTY: util-linux `script -qfc '<명령>' /dev/null` 로 가짜 단말을 붙인다(CLI 가 단말을 요구하는 로그인 흐름 때문). script 가 없으면 파이프로 실행하고 pty=false 로 알린다. * - 세션 시작은 라우트가 재인증(requireRecent)을 요구한다(인증 키 변경과 같은 등급). 운영자당 살아 있는 세션 3개(끝난 것은 세지 않음 — 0.3.9), 유휴 10분·최대 30분 뒤 종료. * - [0.3.9] 작업 폴더는 HAL 실행 계정 HOME(이미 신뢰된 폴더), 단말 크기(cols·rows)는 요청값(기본 100×30), 입력은 바이트 그대로(화살표·Esc·Ctrl 포함) 전달. * - 입력(코드·비밀번호)은 저장·감사하지 않는다. 감사에는 시작·종료 사실(tool·command·exitCode)만 남긴다. 출력은 메모리(세션당 256KB)에만 두고 세션이 끝나면 10분 뒤 버린다. */ import { spawn } from 'node:child_process'; import { HttpError } from '../../lib/http.mjs'; import { audit } from '../../lib/audit.mjs'; import { nowIso } from '../../lib/db.mjs'; import { newId } from '../../lib/crypto.mjs'; import { CLI_TOOLS, findCliBin, cliEnv, cliSearchDirs, cliHome } from './providers.mjs'; import { mkdirSync } from 'node:fs'; /** 허용 명령: tool → command 이름 → CLI 인자. 여기 없는 것은 실행하지 않는다. */ export const SHELL_COMMANDS = { // [0.3.9] claude 의 로그인은 `claude auth login`(`claude login` 은 대화형 세션에 "login" 을 입력하는 것). 대화형(interactive, 인자 없음)은 신뢰(trust) 선택·첫 설정 같은 TUI 흐름용. claude: { login: ['auth', 'login'], login_console: ['auth', 'login', '--console'], logout: ['auth', 'logout'], status: ['auth', 'status'], version: ['--version'], interactive: [] }, codex: { login: ['login'], logout: ['logout'], status: ['login', 'status'], version: ['--version'], interactive: [] }, }; /** 사전 안내: 셸을 열기 전에 운영자가 알아야 할 것 */ export const SHELL_GUIDE = { claude: { login: ['`claude auth login` 은 화살표(↑↓)·Enter 로 로그인 방식을 고르는 TUI 입니다. 단말 화면을 클릭해 키를 직접 누르거나 아래 키 단추를 쓰세요.', '브라우저 인증 URL 이 나오면 복사해 열고, Anthropic 계정으로 로그인한 뒤 표시된 코드를 이 셸에 붙여 넣고 Enter 를 누르세요.', '로그인 세션은 HAL 실행 계정(서버 HOME)에 저장되며 HAL 은 그 파일을 읽지 않습니다. 끝나면 "다시 탐색"으로 상태를 확인하세요.'], login_console: ['Anthropic Console(API 과금) 계정으로 로그인합니다. 흐름은 login 과 같습니다.'], interactive: ['`claude` 대화형 세션을 작업 폴더(HAL 실행 계정 HOME)에서 엽니다. 처음이면 "이 폴더의 파일을 신뢰합니까?(trust)" 질문이 나오니 화살표·Enter 로 답하세요. 로그인이 안 되어 있으면 세션 안에서 /login 을 쓸 수도 있습니다.', '끝내려면 /exit 또는 Ctrl+C 두 번. 이 세션은 운영자 본인의 CLI 이며 HAL 도구와는 무관합니다.'], logout: ['로그아웃하면 이 CLI 를 쓰는 연결은 "로그인 필요" 상태가 됩니다.'], status: ['현재 로그인 상태만 출력합니다.'], version: ['버전만 출력합니다.'], }, codex: { login: ['Codex 로그인은 ChatGPT 계정 인증입니다. 셸에 나온 URL 을 브라우저에서 열고 로그인하세요(기기 코드 방식이면 코드를 입력).', '서버가 브라우저를 열 수 없으므로 URL 을 직접 복사해 여세요. 끝나면 "다시 탐색"으로 상태를 확인하세요.'], interactive: ['`codex` 대화형 세션을 작업 폴더(HOME)에서 엽니다. 신뢰·권한 질문이 나오면 화살표·Enter 로 답하세요. 끝내려면 /quit 또는 Ctrl+C.'], logout: ['로그아웃하면 Codex 연결은 "로그인 필요" 상태가 됩니다.'], status: ['현재 로그인 상태만 출력합니다.'], version: ['버전만 출력합니다.'], }, }; const MAX_OUT = 256 * 1024; const IDLE_MS = 10 * 60 * 1000; const MAX_MS = 30 * 60 * 1000; const KEEP_MS = 10 * 60 * 1000; const PER_OPERATOR = 3; // [0.3.9] 한도 3, 살아 있는 세션만 센다 const q = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`; import { accessSync, constants } from 'node:fs'; function existsExec(p) { try { accessSync(p, constants.X_OK); return true; } catch (_) { return false; } } /** util-linux script(PTY 래퍼) 위치. 없으면 null → 파이프 실행 */ const scriptBin = () => ['/usr/bin/script', '/bin/script', '/usr/local/bin/script'].find(existsExec) || null; export function createShellManager(d) { const sessions = new Map(); const alive = (s) => s.status === 'running' && s.child && s.child.exitCode === null && s.child.signalCode === null; // [0.3.9] 실제로 살아 있는지(프로세스 기준) const pub = (s) => ({ id: s.id, tool: s.tool, command: s.command, args: s.args, bin: s.bin, cwd: s.cwd, cols: s.cols, rows: s.rows, pty: s.pty, status: s.status, exitCode: s.exitCode, signal: s.signal, startedAt: s.startedAt, endedAt: s.endedAt, operator: s.operator, outputBytes: s.total, idleMs: Date.now() - s.lastIo }); const finish = (s, code, signal) => { if (s.status !== 'running') return; s.status = signal === 'SIGTERM' || signal === 'SIGKILL' || s.killed ? 'killed' : 'exited'; s.exitCode = code; s.signal = signal || null; s.endedAt = nowIso(); clearTimeout(s.maxTimer); clearInterval(s.idleTimer); audit(d.store, { actorType: 'operator', actorId: s.operator, action: 'ai.shell.end', subjectType: 'ai_shell', subjectId: s.id, outcome: code === 0 ? 'ok' : 'failed', detail: { tool: s.tool, command: s.command, exitCode: code, signal: s.signal, status: s.status } }); setTimeout(() => sessions.delete(s.id), KEEP_MS).unref(); }; const mgr = { /** 세션 시작. (tool, command) 는 허용표에 있어야 한다. */ start({ tool, command, operator, cols = 100, rows = 30 }) { if (!CLI_TOOLS[tool] || !SHELL_COMMANDS[tool]) throw new HttpError(400, 'bad_tool', `지원하지 않는 CLI: ${tool}`); const args = SHELL_COMMANDS[tool][command]; if (!args) throw new HttpError(400, 'bad_command', `허용되지 않은 명령입니다: ${String(command).slice(0, 40)} (허용: ${Object.keys(SHELL_COMMANDS[tool]).join('·')})`); const bin = findCliBin(tool); if (!bin) throw new HttpError(424, 'cli_unavailable', `${CLI_TOOLS[tool].label} 실행 파일을 찾지 못했습니다(탐색 위치: ${cliSearchDirs().slice(0, 6).join(', ')})`); for (const x of sessions.values()) if (x.status === 'running' && !alive(x)) finish(x, x.child ? x.child.exitCode : -1, x.child ? x.child.signalCode : null); // 끝났는데 running 으로 남은 것 정리 const mine = [...sessions.values()].filter((s) => s.operator === operator && alive(s)); if (mine.length >= PER_OPERATOR) throw new HttpError(409, 'too_many_sessions', `열린 텍스트 셸이 ${PER_OPERATOR}개입니다 — 먼저 닫으세요(목록에서 종료)`, { sessions: mine.map(pub) }); const C = Math.max(40, Math.min(250, Number(cols) || 100)); const Rw = Math.max(10, Math.min(80, Number(rows) || 30)); const cwd = cliHome(); try { mkdirSync(cwd, { recursive: true }); } catch (_) {} const env = { ...cliEnv(), TERM: 'xterm-256color', COLUMNS: String(C), LINES: String(Rw), NO_COLOR: '1' }; const sc = scriptBin(); let child; let pty = false; try { if (sc) { child = spawn(sc, ['-qfc', [bin, ...args].map(q).join(' '), '/dev/null'], { cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true }); pty = true; } else child = spawn(bin, args, { cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true }); } catch (e) { throw new HttpError(424, 'cli_unavailable', `CLI 를 실행할 수 없습니다: ${e.message}`); } const s = { id: newId('sh_'), tool, command, args, bin, cwd, cols: C, rows: Rw, pty, status: 'running', exitCode: null, signal: null, startedAt: nowIso(), endedAt: null, operator, child, out: '', base: 0, total: 0, lastIo: Date.now(), killed: false }; const push = (c) => { const t = c.toString('utf8'); s.out += t; s.total += t.length; s.lastIo = Date.now(); if (s.out.length > MAX_OUT) { const cut = s.out.length - MAX_OUT; s.out = s.out.slice(cut); s.base += cut; } }; child.stdout.on('data', push); child.stderr.on('data', push); child.on('error', (e) => { push(`\r\n[HAL] 실행 오류: ${e.code || e.message}\r\n`); finish(s, -1, null); }); child.on('close', (code, signal) => finish(s, code, signal)); s.maxTimer = setTimeout(() => mgr.kill(s.id, '최대 시간(30분) 초과'), MAX_MS); s.maxTimer.unref && s.maxTimer.unref(); s.idleTimer = setInterval(() => { if (Date.now() - s.lastIo > IDLE_MS) mgr.kill(s.id, '유휴 10분 초과'); }, 15000); s.idleTimer.unref && s.idleTimer.unref(); sessions.set(s.id, s); audit(d.store, { actorType: 'operator', actorId: operator, action: 'ai.shell.start', subjectType: 'ai_shell', subjectId: s.id, detail: { tool, command, bin, pty } }); return { session: pub(s), guide: (SHELL_GUIDE[tool] && SHELL_GUIDE[tool][command]) || [] }; }, get(id, { after = 0 } = {}) { const s = sessions.get(id); if (!s) throw new HttpError(404, 'shell_not_found', '텍스트 셸 세션이 없습니다'); const from = Math.max(0, Number(after) || 0); const start = Math.max(0, from - s.base); return { session: pub(s), output: s.out.slice(start), next: s.base + s.out.length, truncated: from < s.base }; }, list() { return [...sessions.values()].map(pub); }, /** 입력(키 입력 그대로). 저장·감사하지 않는다. 호출당 4KB. */ input(id, data) { const s = sessions.get(id); if (!s) throw new HttpError(404, 'shell_not_found', '텍스트 셸 세션이 없습니다'); if (s.status !== 'running') throw new HttpError(409, 'shell_not_running', '이미 끝난 세션입니다'); const t = String(data ?? ''); if (!t) return pub(s); if (t.length > 4096) throw new HttpError(400, 'input_too_long', '입력이 너무 깁니다'); s.lastIo = Date.now(); s.child.stdin.write(t); return pub(s); }, kill(id, reason = '운영자 종료') { const s = sessions.get(id); if (!s) throw new HttpError(404, 'shell_not_found', '텍스트 셸 세션이 없습니다'); if (s.status !== 'running') return pub(s); s.killed = true; try { process.kill(-s.child.pid, 'SIGTERM'); } catch (_) { try { s.child.kill('SIGTERM'); } catch (_) {} } setTimeout(() => { if (s.status === 'running') { try { process.kill(-s.child.pid, 'SIGKILL'); } catch (_) { try { s.child.kill('SIGKILL'); } catch (_) {} } } }, 2000).unref(); s.out += `\r\n[HAL] 세션 종료 요청: ${reason}\r\n`; return pub(s); }, /** [0.3.9] 운영자의 열린 세션 모두 종료(한도 오류 복구용) */ killAll(operator, reason = '운영자 모두 종료') { const out = []; for (const s of sessions.values()) if (s.status === 'running' && (!operator || s.operator === operator)) out.push(mgr.kill(s.id, reason)); return out; }, stopAll() { for (const s of sessions.values()) if (s.status === 'running') mgr.kill(s.id, 'HAL 종료'); }, }; return mgr; }