Files
AriWeb-Suite/hal/ai/shell.mjs
T

69 lines
9.0 KiB
JavaScript
Raw Normal View History

/**
* 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)을 요구한다(인증 키 변경과 같은 등급). 운영자당 동시 세션 2개, 유휴 10분·최대 30분 뒤 종료.
* - 입력(코드·비밀번호)은 저장·감사하지 않는다. 감사에는 시작·종료 사실(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 } from './providers.mjs';
/** 허용 명령: tool → command 이름 → CLI 인자. 여기 없는 것은 실행하지 않는다. */
export const SHELL_COMMANDS = {
claude: { login: ['login'], logout: ['logout'], status: ['auth', 'status'], version: ['--version'] },
codex: { login: ['login'], logout: ['logout'], status: ['login', 'status'], version: ['--version'] },
};
/** 사전 안내: 셸을 열기 전에 운영자가 알아야 할 것 */
export const SHELL_GUIDE = {
claude: { login: ['Claude Code 로그인은 브라우저 인증입니다. 셸에 나온 URL 을 복사해 브라우저에서 열고, Anthropic 계정으로 로그인한 뒤 표시된 코드를 이 셸에 붙여 넣고 Enter 를 누르세요.', '로그인 세션은 HAL 실행 계정(서버 HOME)에 저장되며 HAL 은 그 파일을 읽지 않습니다.', '끝나면 AI 연동 화면에서 "다시 탐색"으로 로그인 상태를 확인하세요.'], logout: ['로그아웃하면 이 CLI 를 쓰는 연결은 "로그인 필요" 상태가 됩니다.'], status: ['현재 로그인 상태만 출력합니다.'], version: ['버전만 출력합니다.'] },
codex: { login: ['Codex 로그인은 ChatGPT 계정 인증입니다. 셸에 나온 URL 을 브라우저에서 열고 로그인하세요(기기 코드 방식이면 코드를 입력).', '서버가 브라우저를 열 수 없으므로 URL 을 직접 복사해 여세요.', '끝나면 "다시 탐색"으로 로그인 상태를 확인하세요.'], 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 = 2;
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 pub = (s) => ({ id: s.id, tool: s.tool, command: s.command, bin: s.bin, 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 }) {
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(', ')})`);
const mine = [...sessions.values()].filter((s) => s.operator === operator && s.status === 'running'); if (mine.length >= PER_OPERATOR) throw new HttpError(409, 'too_many_sessions', `열린 텍스트 셸이 ${PER_OPERATOR}개입니다 — 먼저 닫으세요`);
const env = { ...cliEnv(), TERM: 'xterm-256color', COLUMNS: '100', LINES: '30', 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'], { env, stdio: ['pipe', 'pipe', 'pipe'], detached: true }); pty = true; } else child = spawn(bin, args, { 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, bin, 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); },
stopAll() { for (const s of sessions.values()) if (s.status === 'running') mgr.kill(s.id, 'HAL 종료'); },
};
return mgr;
}