HAL 0.3.9: 로그인 터미널·텍스트 셸·모델 선택·슬래시 명령·점검 수정 — claude 실제 로그인 명령 auth login(대화형 세션·신뢰 설정도 허용, 작업 폴더 HOME), 텍스트 셸 범용화(순수 단말 에뮬레이터 term.mjs 로 TUI 렌더, 원시 키 전달·키 단추·붙여넣기·단말 크기), 유령 세션 한도 오류 수정(닫기=종료, 살아 있는 프로세스만 계산, 한도 3, 모두 종료·세션 목록), 모델 선택(확인된 목록·PATCH·/model), 슬래시 명령 20개(/help /status /order /deploy …, 원문 기록·확장 지시), 패널 로그인·재인증 단추, 개발 지시 v0.3.9(Oz)·docs/HAL-AI.md §8·VERSION_HISTORY 0.3.9 — test/hal-ai-039.test.mjs RED 0/6 → GREEN 6/6, 전체 28파일 195건 통과

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5.1
2026-10-29 02:22:21 +00:00
parent f861c7ee5a
commit fe4800f94a
20 changed files with 506 additions and 49 deletions
+39
View File
@@ -0,0 +1,39 @@
/**
* hal/ai/commands.mjs — 컨텍스트(지시) 입력 창의 슬래시 명령(0.3.9, 개발 지시 v0.3.9 §5).
*
* 두 부류
* - local : HAL 이 바로 처리하고 모델을 부르지 않는다(/help·/tools·/model·/reset).
* - prompt: 운영자 글을 모델에게 보낼 **지시문으로 확장**한다(/status·/servers·/order·/deploy …). 화면과 기록에는 운영자가 친 원문(/status)이 남고,
* 모델은 확장된 지시를 받는다. 확장 지시는 "어떤 도구로 무엇을 확인·제안하라"를 말할 뿐 승인 규칙을 바꾸지 않는다(쓰기·고위험은 여전히 제안 → 운영자 승인).
* 인자: 명령 뒤 공백으로 구분한 나머지 글. requiresArgs 인 명령에 인자가 없으면 400 command_args_required.
*/
const shq = (s) => String(s || '').trim();
const tail = '증거(도구 결과·시각)를 붙이고, 확인하지 못한 것은 확인하지 못했다고 말하라.';
export const COMMANDS = [
{ name: 'help', usage: '/help', description: '쓸 수 있는 명령 목록을 보인다(모델 호출 없음)', kind: 'local' },
{ name: 'status', usage: '/status', description: '운영 현황 요약(건강·서버·작업·사건·드리프트)', kind: 'prompt', expand: () => `운영 현황을 요약하라. hal_overview 와 hal_health 도구로 현재 상태를 읽고 ① HAL 건강·버전 ② 서버(offline·pending·유지보수·디스크 경고) ③ 진행·대기·확인 필요 작업 ④ 열린 사건 ⑤ 목표·실제 버전 차이(드리프트) ⑥ 긴급 복구·DR 순으로 짧게 정리하라. ${tail}` },
{ name: 'health', usage: '/health', description: 'HAL 자체 건강만', kind: 'prompt', expand: () => `hal_health 도구로 HAL 자체 건강(ok·version·db·jobs·bootstrap)을 읽고 뜻을 설명하라. ${tail}` },
{ name: 'servers', usage: '/servers [역할]', description: '서버 목록과 상태(역할: web·mail·backup·control)', kind: 'prompt', expand: (a) => `hal_servers_list 도구${a ? `(role=${shq(a)})` : ''}로 서버 목록을 읽고 서버별 상태·Agent·용량·모듈 드리프트를 표로 정리하라. 주의가 필요한 서버를 먼저 말하라. ${tail}` },
{ name: 'server', usage: '/server <서버id>', description: '서버 하나 상세·최근 작업', kind: 'prompt', requiresArgs: true, expand: (a) => `hal_server_get 도구(serverId=${shq(a)})로 서버 상세와 최근 작업을 읽고 상태·지표·모듈·최근 작업 결과를 설명하라. ${tail}` },
{ name: 'jobs', usage: '/jobs [상태]', description: '작업 목록(상태: running,queued,needs_user,failed …)', kind: 'prompt', expand: (a) => `hal_jobs_list 도구${a ? `(status=${shq(a)})` : '(기본: 진행·대기·확인 필요)'}로 작업을 읽고 종류·상태·현재 단계·오류를 정리하라. needs_user·failed·stale 작업은 원인과 다음 조치를 제안하라. ${tail}` },
{ name: 'job', usage: '/job <작업id>', description: '작업 하나 단계·기록·결과', kind: 'prompt', requiresArgs: true, expand: (a) => `hal_job_get 도구(jobId=${shq(a)})로 작업의 단계·기록·결과·자식 작업을 읽고 무엇이 일어났는지와 다음 조치를 설명하라. ${tail}` },
{ name: 'releases', usage: '/releases', description: '릴리스 등록부·실행 버전·배포 순서', kind: 'prompt', expand: () => `hal_releases_list 도구로 릴리스(status·mainCommit·artifact)와 실행 중 버전·모듈 배치 상태를 읽고, 승인되지 않은 릴리스·드리프트·HAL 먼저 규칙 위반 가능성을 정리하라. ${tail}` },
{ name: 'incidents', usage: '/incidents', description: '열린 사건과 알림', kind: 'prompt', expand: () => `hal_incidents_list 도구(openOnly=true)로 열린 사건을 읽고 심각도·고객 영향·경과를 정리하라. ${tail}` },
{ name: 'audit', usage: '/audit [행동 접두어]', description: '감사 기록(예: /audit deploy.)', kind: 'prompt', expand: (a) => `hal_audit_list 도구${a ? `(action=${shq(a)})` : ''}로 최근 감사 기록을 읽고 누가·언제·무엇을 했는지 시간순으로 정리하라. ${tail}` },
{ name: 'dr', usage: '/dr', description: '재해복구 준비도·스냅샷', kind: 'prompt', expand: () => `hal_dr_status 도구로 DR 준비도·스냅샷·복원·훈련을 읽고 RTO/RPO 충족 여부와 빠진 것을 말하라. ${tail}` },
{ name: 'emergency', usage: '/emergency', description: '긴급 복구 세션', kind: 'prompt', expand: () => `hal_emergency_list 도구로 긴급 복구 세션을 읽고 단계·승인·자격 상태를 정리하라. ${tail}` },
{ name: 'order', usage: '/order <지시>', description: '작업 지시(계획 → 읽기 도구로 확인 → 필요한 변경은 제안·승인 요청 → 보고)', kind: 'prompt', requiresArgs: true, expand: (a) => `작업 지시: ${shq(a)}\n절차: 1) 목표와 영향 범위를 정리하고 계획을 세운다. 2) 읽기 도구로 현재 상태를 확인한다. 3) 필요한 쓰기·고위험 도구는 직접 실행되지 않으니 제안(승인 요청)으로 올리고 운영자 승인을 기다린다 — 승인된 것처럼 말하지 않는다. 4) 결과를 증거와 함께 보고하고, 확인하지 못한 것은 남긴다.` },
{ name: 'deploy', usage: '/deploy <모듈> <버전> [서버id,서버id]', description: '모듈 배포 제안(고위험 → 승인·3단계 확인)', kind: 'prompt', requiresArgs: true, expand: (a) => { const [m, v, s] = shq(a).split(/\s+/); return `모듈 배포를 준비하라: module=${m || '?'} version=${v || '?'}${s ? ` serverIds=${s}` : ''}. 먼저 hal_releases_list 로 릴리스 승인 여부·HAL 먼저 규칙·대상 서버를 확인하고, 문제가 없으면 hal_module_deploy 도구로 제안(승인 요청)을 올려라${s ? '' : '(serverIds 는 hal_servers_list 에서 역할에 맞는 서버를 골라 제안에 넣어라)'}. 승인 전에는 실행되지 않는다. ${tail}`; } },
{ name: 'rollback', usage: '/rollback <모듈> [버전] [서버id,…]', description: '모듈 되돌리기 제안(고위험)', kind: 'prompt', requiresArgs: true, expand: (a) => { const [m, v, s] = shq(a).split(/\s+/); return `모듈 되돌리기를 준비하라: module=${m || '?'}${v ? ` toVersion=${v}` : ''}${s ? ` serverIds=${s}` : ''}. hal_releases_list 로 현재·이전 버전을 확인하고 hal_module_rollback 도구로 제안(승인 요청)을 올려라. ${tail}`; } },
{ name: 'isolate', usage: '/isolate <서버id> <사유>', description: '서버 격리 제안(고위험)', kind: 'prompt', requiresArgs: true, expand: (a) => { const [id, ...rest] = shq(a).split(/\s+/); return `서버 격리를 준비하라: serverId=${id || '?'} 사유=${rest.join(' ') || '(사유 필요)'}. hal_server_get 으로 배치된 서비스·상태를 확인하고 영향 범위를 설명한 뒤 hal_server_isolate 도구로 제안(승인 요청)을 올려라. ${tail}`; } },
{ name: 'incident', usage: '/incident <key> <제목>', description: '사건 등록 제안(쓰기 → 승인)', kind: 'prompt', requiresArgs: true, expand: (a) => { const [key, ...rest] = shq(a).split(/\s+/); return `사건 등록을 준비하라: key=${key || '?'} 제목=${rest.join(' ') || '(제목 필요)'}. 관련 상태를 읽기 도구로 확인해 심각도·고객 영향을 정하고 hal_incident_open 도구로 제안(승인 요청)을 올려라. ${tail}`; } },
{ name: 'model', usage: '/model <모델 이름>', description: '이 탭(개별 방)의 모델을 바꾼다(확인된 목록에서)', kind: 'local', requiresArgs: true },
{ name: 'tools', usage: '/tools', description: '이 연결에 켜진 HAL 도구 목록', kind: 'local' },
{ name: 'reset', usage: '/reset', description: '이 방의 대화를 비운다(승인 대기 제안 취소)', kind: 'local' },
];
const BY = new Map(COMMANDS.map((c) => [c.name, c]));
/** '/이름 인자…' 해석. 슬래시로 시작하지 않으면 null. */
export function parseCommand(text) { const m = /^\/([a-zA-Z][a-zA-Z0-9_-]*)(?:\s+([\s\S]*))?$/.exec(String(text || '').trim()); if (!m) return null; return { name: m[1].toLowerCase(), args: (m[2] || '').trim(), command: BY.get(m[1].toLowerCase()) || null }; }
export const publicCommands = () => COMMANDS.map((c) => ({ name: c.name, usage: c.usage, description: c.description, kind: c.kind, requiresArgs: !!c.requiresArgs }));
/** /help 본문 */
export function helpText() { return ['쓸 수 있는 명령(지시 입력 창에 / 로 시작):', ...COMMANDS.map((c) => `${c.usage} — ${c.description}${c.kind === 'local' ? '' : ' (모델에게 지시로 전달)'}`), '통합 방에서는 @이름 으로 특정 모델만 부를 수 있습니다.'].join('\n'); }
+20 -2
View File
@@ -20,6 +20,7 @@ import * as C from './connections.mjs';
import * as R from './rooms.mjs';
import { toolByName, toolDefs, validateArgs, runTool, toolOutputText, SKILL_DOC, RISK_LABEL } from './tools.mjs';
import { toolFormat } from './providers.mjs';
import { parseCommand, helpText } from './commands.mjs';
import * as P from './providers.mjs';
const MAX_ITER = 8; // 한 턴에서 도구 왕복 최대 횟수(무한 반복 방지)
@@ -54,7 +55,7 @@ export function createEngine(d) {
const out = [];
for (const m of rows) {
if (m.status === 'failed') continue;
if (m.role === 'user') { const t = textOf(m.content); if (t) out.push({ role: 'user', content: [{ type: 'text', text: room.kind === 'group' ? `[${m.author || '운영자'}] ${t}` : t }] }); continue; }
if (m.role === 'user') { const cmd = m.content.find((b) => b.type === 'command'); const t = cmd && cmd.expanded ? cmd.expanded : textOf(m.content); if (t) out.push({ role: 'user', content: [{ type: 'text', text: room.kind === 'group' ? `[${m.author || '운영자'}] ${t}` : t }] }); continue; } // [0.3.9] 슬래시 명령은 확장된 지시를 모델에게
if (m.role === 'system') { const t = textOf(m.content); if (t) out.push({ role: 'user', content: [{ type: 'text', text: `[HAL] ${t}` }] }); continue; }
if (m.role === 'tool') { if (m.connectionId === conn.id) out.push({ role: 'user', content: m.content.filter((b) => b.type === 'tool_result') }); continue; }
if (m.role === 'assistant') {
@@ -138,13 +139,30 @@ export function createEngine(d) {
send(roomId, { text, operator, context = null }) {
const t = String(text || '').trim(); if (!t) throw new HttpError(400, 'empty_message', '지시 내용이 비어 있습니다'); if (t.length > MAX_TEXT) throw new HttpError(400, 'message_too_long', `지시는 ${MAX_TEXT}자까지입니다`);
const room = R.getRoom(d, roomId); if (!room) throw new HttpError(404, 'room_not_found', '방이 없습니다');
// [0.3.9] 슬래시 명령: local 은 바로 처리(모델 호출 없음), prompt 는 확장 지시로 바꿔 보낸다
let command = null; if (t.startsWith('/')) {
const pc = parseCommand(t); if (!pc || !pc.command) throw new HttpError(400, 'unknown_command', `모르는 명령입니다: ${t.split(/\s+/)[0].slice(0, 30)} — /help 로 명령 목록을 보세요`);
if (pc.command.requiresArgs && !pc.args) throw new HttpError(400, 'command_args_required', `인자가 필요합니다: ${pc.command.usage}`);
if (pc.command.kind === 'local') return engine.localCommand(room, pc, operator);
command = { type: 'command', name: pc.name, args: pc.args, expanded: pc.command.expand(pc.args) };
}
let responders;
if (room.kind === 'direct') { const conn = C.get(d, room.connection_id); if (!conn) throw new HttpError(404, 'connection_not_found', '연결이 없습니다'); if (conn.status !== 'active') throw new HttpError(409, 'connection_inactive', `연결 "${conn.name}" 이(가) ${conn.status} 상태라 보낼 수 없습니다`, { status: conn.status, lastError: conn.lastError }); responders = [conn]; }
else { const participants = C.active(d); if (!participants.length) throw new HttpError(409, 'no_active_connections', '활성 AI 연결이 없습니다 — AI 연동 화면에서 연결을 추가하세요'); const { addressed, unknown } = R.parseAddressing(t, participants); if (unknown.length) throw new HttpError(400, 'unknown_addressee', `모르는 이름입니다: ${unknown.map((n) => '@' + n).join(', ')}`, { unknown, participants: participants.map((p) => p.name) }); responders = addressed.length ? participants.filter((p) => addressed.includes(p.id)) : participants; }
const message = R.appendMessage(d, { roomId: room.id, role: 'user', author: operator ? operator.login : null, content: [{ type: 'text', text: t }], addressed: responders.map((c) => c.id), relayDepth: 0 });
const message = R.appendMessage(d, { roomId: room.id, role: 'user', author: operator ? operator.login : null, content: command ? [{ type: 'text', text: t }, command] : [{ type: 'text', text: t }], addressed: responders.map((c) => c.id), relayDepth: 0 });
const turns = responders.map((conn) => startTurn(room, conn, { relayDepth: 0, operator, context }));
return { message, responders: responders.map((c) => c.id), turns };
},
/** [0.3.9] HAL 이 바로 처리하는 명령(/help·/tools·/model·/reset). 모델을 부르지 않는다. */
localCommand(room, pc, operator) {
const note = (text) => ({ message: R.appendMessage(d, { roomId: room.id, role: 'system', author: 'HAL', content: [{ type: 'text', text }], relayDepth: 0 }), responders: [], turns: [], command: pc.name });
if (pc.name === 'help') return note(helpText());
if (pc.name === 'reset') { R.resetRoom(d, room.id); return { message: null, responders: [], turns: [], command: 'reset' }; }
const conn = room.kind === 'direct' ? C.get(d, room.connection_id) : null;
if (pc.name === 'tools') { const list = conn ? [conn] : C.active(d); return note(list.length ? list.map((c) => `${c.name}: 도구 정책 ${c.tools.preset}(${c.tools.enabled.length}개)${c.toolsVia === 'mcp' ? ' · MCP' : ''}\n ${c.tools.enabled.join(', ') || '(없음)'}`).join('\n') : '활성 연결이 없습니다'); }
if (pc.name === 'model') { if (!conn) throw new HttpError(400, 'command_needs_direct_room', '/model 은 개별 방(모델 탭)에서만 쓸 수 있습니다'); const before = conn.model; const c2 = C.update(d, conn.id, { model: pc.args }, operator ? operator.id : null); return note(`모델을 ${before} → ${c2.model} 로 바꿨습니다(연결 ${c2.name}).`); }
throw new HttpError(400, 'unknown_command', '모르는 명령입니다 — /help');
},
listActions({ roomId = null, status = null, limit = 200 } = {}) { const w = []; const p = []; if (roomId) { w.push('room_id=?'); p.push(roomId); } if (status) { w.push('status=?'); p.push(status); } return d.store.all(`SELECT * FROM ai_actions ${w.length ? 'WHERE ' + w.join(' AND ') : ''} ORDER BY proposed_at DESC LIMIT ?`, ...p, Math.min(1000, limit)).map(publicAction); },
getAction(id) { return publicAction(d.store.get('SELECT * FROM ai_actions WHERE id=?', id)); },
/** 승인 뒤 실행(라우트가 재인증·3단계 확인을 먼저 적용한다). 딱 한 번만 실행되며 결과를 도구 결과로 넣고 모델을 이어서 부른다. */
+4 -4
View File
@@ -47,7 +47,7 @@ function providerFail(status, j) { const e = j && j.error ? (typeof j.error ===
/** 사용 가능한 모델 목록(실제 공급자 응답). local-cli 는 실행 파일 확인으로 대신한다. */
export async function listModels(d, conn, secret) {
if (conn.provider === 'local-cli') { const info = await cliInfo(conn); if (info.loggedIn === false) throw new ProviderError('cli_not_logged_in', `${info.label}(${info.bin})에 로그인되어 있지 않습니다 — 서버(HAL 실행 계정)에서 '${info.loginCommand}' 을 실행하세요. HAL 이 대신 로그인할 수 없습니다`, { status: 502 }); return { models: info.models.length ? info.models : [conn.model].filter(Boolean), cliVersion: info.version, loggedIn: info.loggedIn, cli: info }; }
if (conn.provider === 'local-cli') { const info = await cliInfo(conn); if (info.loggedIn === false) throw new ProviderError('cli_not_logged_in', `${info.label}(${info.bin})에 로그인되어 있지 않습니다 — AI 연동 화면의 텍스트 셸에서 '${info.loginCommand}' 을 실행하세요(HAL 이 대신 로그인할 수 없습니다)`, { status: 502 }); return { models: info.models.length ? info.models : [conn.model].filter(Boolean), cliVersion: info.version, loggedIn: info.loggedIn, cli: info }; }
const r = await fetchOf(d)(`${base(conn)}/models`, { method: 'GET', headers: { ...authHeaders(conn, secret), 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(20000) });
const j = await readJson(r); if (!r.ok) throw providerFail(r.status, j);
const models = providerKind(conn.provider) === 'gemini' ? ((j && j.models) || []).map((m) => String(m.name || '').replace(/^models\//, '')).filter(Boolean) : ((j && j.data) || []).map((m) => m.id).filter(Boolean);
@@ -134,10 +134,10 @@ async function chatOpenai(d, conn, secret, { system, messages, tools, maxTokens
* 로그인 상태: claude 는 `claude auth status`(JSON loggedIn), codex 는 `codex login status`("Logged in …"). 로그인 안 됨은 HAL 이 대신할 수 없다 — 서버에서 그 계정으로 `claude login` / `codex login`.
*/
export const CLI_TOOLS = {
claude: { label: 'Claude Code CLI', defaultModel: 'claude-opus-5-5', loginCommand: 'claude login', envBin: 'CLAUDE_BIN' },
claude: { label: 'Claude Code CLI', defaultModel: 'claude-opus-5-5', loginCommand: 'claude auth login', envBin: 'CLAUDE_BIN' }, // [0.3.9] `claude login` 은 대화형 세션 — 실제 로그인은 `claude auth login`
codex: { label: 'OpenAI Codex CLI', defaultModel: 'gpt-5.5', loginCommand: 'codex login', envBin: 'CODEX_BIN' },
};
const cliHome = () => process.env.HAL_CLI_HOME || homedir();
export const cliHome = () => process.env.HAL_CLI_HOME || homedir();
/** HAL 프로세스 환경을 CLI 에 넘기지 않는다(ARIWEB_VAULT_KEY 등). 로그인 세션은 CLI 가 HOME 아래 자기 설정에서 스스로 읽는다. PATH 에는 탐색 위치를 더해 CLI 가 자기 보조 실행 파일(node 등)을 찾게 한다. */
export const cliEnv = () => { const e = {}; for (const k of ['LANG', 'LC_ALL', 'TERM', 'TMPDIR']) if (process.env[k]) e[k] = process.env[k]; e.HOME = cliHome(); e.PATH = [...new Set([...(process.env.PATH || '').split(':'), ...cliSearchDirs()])].filter(Boolean).join(':'); return e; };
const isExec = (p) => { try { accessSync(p, constants.X_OK); return statSync(p).isFile(); } catch (_) { return false; } };
@@ -169,7 +169,7 @@ export async function discoverClis() {
for (const tool of Object.keys(CLI_TOOLS)) {
const bin = findCliBin(tool); if (!bin) continue; const t = CLI_TOOLS[tool];
const v = await runCli(bin, ['--version'], 15000); const login = await cliLoginStatus(tool, bin); let realpath = bin; try { realpath = realpathSync(bin); } catch (_) {}
clis.push({ tool, label: t.label, bin, realpath, version: v.err ? null : v.stdout.trim().split('\n')[0], versionError: v.err ? (v.err.code || v.err.message) : null, loggedIn: login.loggedIn, loginMethod: login.method, defaultModel: cliDefaultModel(tool), models: tool === 'codex' ? codexModels() : [], loginHint: login.loggedIn === false ? `서버(HAL 실행 계정)에서 '${t.loginCommand}' 을 실행해 로그인하세요 — HAL 이 대신 로그인할 수 없습니다` : null });
clis.push({ tool, label: t.label, bin, realpath, version: v.err ? null : v.stdout.trim().split('\n')[0], versionError: v.err ? (v.err.code || v.err.message) : null, loggedIn: login.loggedIn, loginMethod: login.method, defaultModel: cliDefaultModel(tool), models: tool === 'codex' ? codexModels() : [], loginHint: login.loggedIn === false ? `AI 연동 화면의 텍스트 셸에서 '${t.loginCommand}' 을 실행해 로그인하세요(HAL 이 대신 로그인할 수 없습니다 — 셸에서 운영자가 직접 인증)` : null });
}
return { clis, searched: cliSearchDirs(), home: cliHome() };
}
+1 -1
View File
@@ -32,7 +32,7 @@ export function getRoom(d, id) { return d.store.get('SELECT * FROM ai_rooms WHER
export function listRooms(d, connections) {
ensureGroupRoom(d); const g = getRoom(d, GROUP_ROOM);
const out = [{ id: g.id, kind: 'group', title: '통합', fixed: true, connectionId: null, updatedAt: g.updated_at }];
for (const c of connections) { const r = ensureDirectRoom(d, c); out.push({ id: r.id, kind: 'direct', title: r.title || c.tabLabel || c.name, fixed: false, connectionId: c.id, connection: { id: c.id, name: c.name, status: c.status, provider: c.provider, model: c.model, authKind: c.authKind }, updatedAt: r.updated_at }); }
for (const c of connections) { const r = ensureDirectRoom(d, c); out.push({ id: r.id, kind: 'direct', title: r.title || c.tabLabel || c.name, fixed: false, connectionId: c.id, connection: { id: c.id, name: c.name, status: c.status, provider: c.provider, model: c.model, models: c.models || [], authKind: c.authKind, toolsVia: c.toolsVia, lastError: c.lastError || null }, updatedAt: r.updated_at }); }
return out;
}
export function renameRoom(d, id, title) {
+4 -1
View File
@@ -16,6 +16,7 @@ import * as C from './connections.mjs';
import * as R from './rooms.mjs';
import { publicTools, SKILL_DOC, TOOL_PRESETS } from './tools.mjs';
import { SHELL_COMMANDS, SHELL_GUIDE } from './shell.mjs';
import { publicCommands, helpText } from './commands.mjs';
export function registerAiRoutes(app, d, { requireRecent, threeStep, wrap }) {
const ai = d.ai; const op = (ctx) => ({ id: ctx.operator.id, login: ctx.operator.login, displayName: ctx.operator.displayName });
@@ -55,7 +56,9 @@ if(window.opener&&!window.opener.closed){try{window.opener.postMessage(msg,locat
app.get('/ops/api/ai/mcp-config', () => ({ ok: true, ...mcpConfig(d) })); // [0.3.2] 외부 MCP 클라이언트용 설정 스니펫(비밀 없음)
// ---------------- [0.3.2] 텍스트 셸(CLI 로그인): 허용 명령만, 시작은 재인증 필요, 입력은 저장하지 않음
app.get('/ops/api/ai/shell', (ctx) => ({ ok: true, sessions: d.shell.list(), commands: SHELL_COMMANDS, guide: SHELL_GUIDE }));
app.post('/ops/api/ai/shell', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); return { ok: true, ...d.shell.start({ tool: String(b.tool || ''), command: String(b.command || ''), operator: ctx.operator.id }) }; }));
app.post('/ops/api/ai/shell', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); return { ok: true, ...d.shell.start({ tool: String(b.tool || ''), command: String(b.command || ''), operator: ctx.operator.id, cols: b.cols, rows: b.rows }) }; }));
app.post('/ops/api/ai/shell/kill-all', wrap((ctx) => ({ ok: true, sessions: d.shell.killAll(ctx.operator.id) }))); // [0.3.9] 한도 오류 복구
app.get('/ops/api/ai/commands', () => ({ ok: true, commands: publicCommands(), help: helpText() })); // [0.3.9] 슬래시 명령 목록
app.get('/ops/api/ai/shell/:id', wrap((ctx) => ({ ok: true, ...d.shell.get(ctx.params.id, { after: ctx.query.after }) })));
app.post('/ops/api/ai/shell/:id/input', wrap(async (ctx) => { const b = await ctx.body(); return { ok: true, session: d.shell.input(ctx.params.id, b.data) }; }));
app.delete('/ops/api/ai/shell/:id', wrap((ctx) => ({ ok: true, session: d.shell.kill(ctx.params.id) })));
+30 -13
View File
@@ -5,7 +5,8 @@
* - 실행할 수 있는 것은 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분 뒤 종료.
* - 세션 시작은 라우트가 재인증(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';
@@ -13,19 +14,30 @@ 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';
import { CLI_TOOLS, findCliBin, cliEnv, cliSearchDirs, cliHome } from './providers.mjs';
import { mkdirSync } from 'node:fs';
/** 허용 명령: 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'] },
// [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 Code 로그인은 브라우저 인증입니다. 셸에 나온 URL 을 복사해 브라우저에서 열고, Anthropic 계정으로 로그인한 뒤 표시된 코드를 이 셸에 붙여 넣고 Enter 를 누르세요.', '로그인 세션은 HAL 실행 계정(서버 HOME)에 저장되며 HAL 은 그 파일을 읽지 않습니다.', '끝나면 AI 연동 화면에서 "다시 탐색"으로 로그인 상태를 확인하세요.'], logout: ['로그아웃하면 이 CLI 를 쓰는 연결은 "로그인 필요" 상태가 됩니다.'], status: ['현재 로그인 상태만 출력합니다.'], version: ['버전만 출력합니다.'] },
codex: { login: ['Codex 로그인은 ChatGPT 계정 인증입니다. 셸에 나온 URL 을 브라우저에서 열고 로그인하세요(기기 코드 방식이면 코드를 입력).', '서버가 브라우저를 열 수 없으므로 URL 을 직접 복사해 여세요.', '끝나면 "다시 탐색"으로 로그인 상태를 확인하세요.'], logout: ['로그아웃하면 Codex 연결은 "로그인 필요" 상태가 됩니다.'], status: ['현재 로그인 상태만 출력합니다.'], version: ['버전만 출력합니다.'] },
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 = 2;
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; } }
@@ -34,19 +46,22 @@ const scriptBin = () => ['/usr/bin/script', '/bin/script', '/usr/local/bin/scrip
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 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 }) {
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(', ')})`);
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' };
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'], { env, stdio: ['pipe', 'pipe', 'pipe'], detached: true }); pty = true; } else child = spawn(bin, args, { env, stdio: ['pipe', 'pipe', 'pipe'], detached: true }); }
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, 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 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); });
@@ -62,6 +77,8 @@ export function createShellManager(d) {
/** 입력(키 입력 그대로). 저장·감사하지 않는다. 호출당 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;