Files

86 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}