Files

199 lines
30 KiB
JavaScript
Raw Permalink Normal View History

/**
* test/hal-ai-032.test.mjs — Ariweb Suite 0.3.2(개발 지시 `Ariweb-Suitle_Dev-Order_v0.3.2.md`, Oz 2026-10-29): AI 연동 편의성 + 도구 기본 설정·인증/승인 요청.
*
* 계약(요약)
* 1) 공급자 프리셋: Claude(Anthropic)·ChatGPT(OpenAI)·Gemini(Google)·OpenRouter 를 기본 선택지로, 그 밖의 API 서비스는 "기타(OpenAI 호환, 기본 주소 필수)". 프리셋마다 사전 준비 안내(키 발급 주소 등).
* Gemini 는 자체 API(generateContent·functionDeclarations), OpenRouter 는 OpenAI 호환(기본 주소 고정).
* 2) OAuth 자동 진행: OAuth 연결을 만들면 응답이 다음 단계(시작 주소)를 알려 주고, 콜백은 완료 페이지(/ops/ai/oauth/done)로 가서 팝업이면 스스로 닫히며 부모 창에 알린다. Gemini 는 Google 끝점 프리셋, OpenRouter 는 PKCE 로 키를 받는 프리셋.
* 3) CLI 로그인용 텍스트 셸: 허용된 CLI 명령(login·logout·상태·버전)만 PTY(`script`)로 실행하고 화면에서 출력·입력을 주고받는다. 시작은 재인증 필요. 임의 명령 금지.
* 4) 도구 기본 설정: 연결마다 도구 정책(기본: 전부 켜짐)이 미리 들어 있고 바꿀 수 있다. 로컬 CLI 연결에는 HAL MCP 설정을 자동으로 붙여 도구를 바로 쓰게 한다. MCP 설정 스니펫 API.
* 5) 인증/승인 요청: MCP 경로의 쓰기·고위험 도구 호출은 "승인 요청"(제안)으로 올라가고 `hal_action_status`·`hal_actions_pending` 으로 상태를 본다. 운영자가 콘솔에서 승인(재인증·3단계)하면 실행된다.
*/
import { test, eq, ok, tmpDir, done, sleep } from '../lib/test.mjs';
import { invoke } from '../lib/http.mjs';
import { createHalDeps } from '../hal/deps.mjs';
import { buildApp } from '../hal/app.mjs';
import * as ops from '../hal/ops.mjs';
import { mkdirSync, readFileSync, writeFileSync, chmodSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { randomBytes } from 'node:crypto';
import { spawn } from 'node:child_process';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const rnd = (p = 'fx') => `${p}-${randomBytes(12).toString('hex')}`;
function fakeProvider() {
const calls = []; const script = [];
const text = (t) => ({ id: 'msg_' + calls.length, type: 'message', role: 'assistant', model: 'claude-fake', content: [{ type: 'text', text: t }], stop_reason: 'end_turn', usage: { input_tokens: 11, output_tokens: 7 } });
const fetchImpl = async (url, init = {}) => {
const u = String(url); const headers = Object.fromEntries(Object.entries(init.headers || {}).map(([k, v]) => [k.toLowerCase(), v])); let body = null; try { body = init.body ? JSON.parse(init.body) : null; } catch (_) { body = init.body; }
const rec = { url: u, method: init.method || 'GET', headers, body }; calls.push(rec);
const respond = (j, status = 200) => ({ ok: status < 400, status, json: async () => j, text: async () => JSON.stringify(j) });
if (/\/v1beta\/models$/.test(u.split('?')[0]) && rec.method === 'GET') return respond({ models: [{ name: 'models/gemini-fake-pro' }, { name: 'models/gemini-fake-flash' }] });
if (u.endsWith('/v1/models') && rec.method === 'GET') return respond({ data: [{ id: 'claude-fake' }, { id: 'gpt-fake' }, { id: 'openrouter/auto' }] });
if (u.endsWith('/auth/keys')) return respond({ key: rnd('sk-or-v1') });
if (u.endsWith('/oauth/token') || u === 'https://oauth2.googleapis.com/token') return respond({ access_token: rnd('ya29'), refresh_token: rnd('rt'), expires_in: 3600, token_type: 'Bearer' });
const fn = script.shift(); const j = fn ? fn(rec) : text(`응답 ${calls.length}`);
return respond(j, j && j.__status ? j.__status : 200);
};
return { fetchImpl, calls, script, text };
}
function makeHal(fp = fakeProvider()) {
const dataDir = join(tmpDir(), 'hal'); mkdirSync(dataDir, { recursive: true });
const config = { dataDir, file: join(dataDir, 'config.json'), port: 0, host: '127.0.0.1', publicUrl: 'https://hal.example.test', portalUrl: 'http://127.0.0.1:1', opsAllowCidrs: ['127.0.0.1/32'], metricsRetentionDays: 90, offlineAfterMs: 90000, transferTtlMs: 3600000, emergencyTtlMinutes: 30, agentPollMs: 20, agentWaitTimeoutMs: 2000, fanoutTimeoutMs: 4000, values: {}, set(p) { Object.assign(this, p); } };
const d = createHalDeps({ config, dbFile: ':memory:', fetchImpl: fp.fetchImpl });
return { d, app: buildApp(d), dataDir, fp };
}
async function loginOp(h) {
const pw = rnd('pw'); ops.createAccount(h.d, { login: 'ozops', password: pw, displayName: '오즈' });
const r = await invoke(h.app, 'POST', '/ops/api/login', { body: { login: 'ozops', password: pw } }); ok(r.json.ok, JSON.stringify(r.json));
return { pw, cookie: r.cookies.aw_ops, as: (method, path, body, extra = {}) => invoke(h.app, method, path, { body, cookies: { aw_ops: r.cookies.aw_ops }, headers: { 'x-csrf': r.json.csrf, ...(extra.headers || {}) } }) };
}
const stale = (h) => h.d.store.run('UPDATE sessions SET reauth_at=?', new Date(Date.now() - 11 * 60000).toISOString());
const texts = (msgs) => msgs.map((m) => ({ role: m.role, by: m.connectionId || null, text: (m.content || []).filter((b) => b.type === 'text').map((b) => b.text).join('') }));
const roomOf = async (op, id) => (await op.as('GET', '/ops/api/ai/rooms')).json.rooms.find((x) => x.connectionId === id);
await test('공급자 프리셋: Claude·ChatGPT·Gemini·OpenRouter 기본 선택 + 기타(OpenAI 호환) — 사전 준비 안내, OpenRouter 기본 주소, 기타는 주소 필수, Gemini 는 자체 API 로 모델 목록·도구 왕복', async () => {
const h = makeHal(); const op = await loginOp(h);
let r = await op.as('GET', '/ops/api/ai/connections'); const P = Object.fromEntries(r.json.providers.map((p) => [p.id, p]));
for (const id of ['anthropic', 'openai', 'gemini', 'openrouter', 'other', 'local-cli']) ok(P[id], '공급자 ' + id);
ok(/Claude/.test(P.anthropic.label) && /ChatGPT/.test(P.openai.label) && /Gemini/.test(P.gemini.label) && /OpenRouter/.test(P.openrouter.label) && /기타/.test(P.other.label));
ok(P.anthropic.guide && /console\.anthropic\.com/.test(P.anthropic.guide.keyUrl) && P.anthropic.guide.steps.length >= 1, '사전 준비 안내(키 발급 주소·절차)'); ok(P.gemini.guide.oauth && /Google/.test(P.gemini.guide.oauth), 'OAuth 사전 안내'); ok(P['local-cli'].guide.steps.some((s) => /login/.test(s)), 'CLI 사전 안내');
ok(P.openrouter.defaultBase === 'https://openrouter.ai/api/v1' && P.other.defaultBase === null && P.gemini.defaultBase.includes('generativelanguage.googleapis.com'));
// 기타: 기본 주소 없으면 거부
r = await op.as('POST', '/ops/api/ai/connections', { name: '기타', provider: 'other', authKind: 'api_key', apiKey: rnd('k'), model: 'm' }); eq(r.status, 400); eq(r.json.error, 'base_url_required');
r = await op.as('POST', '/ops/api/ai/connections', { name: '기타', provider: 'other', authKind: 'api_key', apiKey: rnd('k'), model: 'm', baseUrl: 'https://llm.example.test/v1' }); eq(r.status, 200, JSON.stringify(r.json));
// OpenRouter: 기본 주소 고정, OpenAI 호환 호출(Bearer)
const ork = rnd('sk-or'); r = await op.as('POST', '/ops/api/ai/connections', { name: '오픈라우터', provider: 'openrouter', authKind: 'api_key', apiKey: ork, model: 'openrouter/auto' }); eq(r.status, 200, JSON.stringify(r.json)); const orc = r.json.connection; eq(orc.baseUrl, null); eq(orc.providerKind, 'openai');
r = await op.as('POST', `/ops/api/ai/connections/${orc.id}/test`); eq(r.status, 200, JSON.stringify(r.json)); const mc = h.fp.calls.filter((x) => x.method === 'GET').pop(); eq(mc.url, 'https://openrouter.ai/api/v1/models'); eq(mc.headers.authorization, 'Bearer ' + ork);
// Gemini: x-goog-api-key, 모델 목록은 models/ 접두어 제거, generateContent 도구 왕복
const gk = rnd('AIza'); r = await op.as('POST', '/ops/api/ai/connections', { name: '제미', provider: 'gemini', authKind: 'api_key', apiKey: gk, model: 'gemini-fake-pro' }); eq(r.status, 200, JSON.stringify(r.json)); const g = r.json.connection; eq(g.providerKind, 'gemini');
r = await op.as('POST', `/ops/api/ai/connections/${g.id}/test`); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.models, ['gemini-fake-pro', 'gemini-fake-flash']); const gm = h.fp.calls.filter((x) => x.method === 'GET').pop(); ok(gm.url.startsWith('https://generativelanguage.googleapis.com/v1beta/models') && gm.headers['x-goog-api-key'] === gk && !gm.headers.authorization, JSON.stringify(gm.headers));
const room = await roomOf(op, g.id);
h.fp.script.push((req) => { ok(req.url.includes('/models/gemini-fake-pro:generateContent'), req.url); ok(req.body.tools && req.body.tools[0].functionDeclarations.some((f) => f.name === 'hal_health'), 'functionDeclarations'); ok(req.body.systemInstruction && JSON.stringify(req.body.systemInstruction).includes('제미'), 'systemInstruction'); return { candidates: [{ content: { role: 'model', parts: [{ text: '확인합니다' }, { functionCall: { name: 'hal_health', args: {} } }] }, finishReason: 'STOP' }], usageMetadata: { promptTokenCount: 21, candidatesTokenCount: 4 } }; });
h.fp.script.push((req) => { const last = req.body.contents[req.body.contents.length - 1]; const fr = last.parts.find((p) => p.functionResponse); ok(fr && fr.functionResponse.name === 'hal_health' && fr.functionResponse.response.version === h.d.version, JSON.stringify(last).slice(0, 300)); return { candidates: [{ content: { role: 'model', parts: [{ text: `HAL ${fr.functionResponse.response.version}` }] } }], usageMetadata: { promptTokenCount: 30, candidatesTokenCount: 3 } }; });
r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: 'HAL 버전?' }); eq(r.status, 200); await h.d.ai.drain();
r = await op.as('GET', `/ops/api/ai/rooms/${room.id}/messages`); const a = r.json.messages.filter((m) => m.role === 'assistant'); eq(texts([a[a.length - 1]])[0].text, `HAL ${h.d.version}`); eq(a[0].usage.inputTokens, 21);
ok(!JSON.stringify(r.json).includes(gk), '키 미노출');
});
await test('OAuth 자동 진행: OAuth 연결 생성 응답이 다음 단계를 알려 주고, 완료 페이지는 팝업을 닫고 부모 창에 알린다; Gemini 는 Google 끝점 프리셋, OpenRouter 는 PKCE 로 키를 받는다', async () => {
const h = makeHal(); const op = await loginOp(h);
// Gemini OAuth: client id/secret 만 주면 Google 끝점·scope 프리셋
let r = await op.as('POST', '/ops/api/ai/connections', { name: '제미OAuth', provider: 'gemini', authKind: 'oauth', model: 'gemini-fake-pro', oauth: { clientId: 'google-client-id', clientSecret: rnd('gcs') } }); eq(r.status, 200, JSON.stringify(r.json));
ok(r.json.next && r.json.next.action === 'oauth' && r.json.next.startPath === `/ops/api/ai/connections/${r.json.connection.id}/oauth/start`, '다음 단계 안내'); const gc = r.json.connection; eq(gc.oauth.authorizeUrl, 'https://accounts.google.com/o/oauth2/v2/auth'); eq(gc.oauth.tokenUrl, 'https://oauth2.googleapis.com/token'); ok(gc.oauth.scopes.some((s) => /generative-language/.test(s)));
r = await op.as('POST', `/ops/api/ai/connections/${gc.id}/oauth/start`); eq(r.status, 200); const u = new URL(r.json.authorizeUrl); eq(u.host, 'accounts.google.com'); eq(u.searchParams.get('access_type'), 'offline'); ok(u.searchParams.get('code_challenge'));
r = await op.as('GET', `/ops/ai/oauth/callback?code=gc1&state=${u.searchParams.get('state')}`); eq(r.status, 302, r.text); ok(String(r.headers.Location).startsWith('/ops/ai/oauth/done?ok=1&connection='), r.headers.Location);
const tc = h.fp.calls.find((x) => x.url === 'https://oauth2.googleapis.com/token'); ok(tc, 'Google 토큰 교환'); ok(JSON.parse(h.d.vault.get(`ai:conn:${gc.id}:oauth`)).accessToken.startsWith('ya29'));
// 완료 페이지: 팝업이면 opener 에 postMessage 하고 닫는다, 아니면 /ops/ai 로
r = await op.as('GET', `/ops/ai/oauth/done?ok=1&connection=${gc.id}`); eq(r.status, 200); ok(r.text.includes('window.opener') && r.text.includes('postMessage') && r.text.includes('window.close') && r.text.includes('/ops/ai?oauth=ok'), '팝업 닫기·알림·대체 이동');
ok(!r.text.includes('<script src='), '외부 스크립트 없음'); ok(!JSON.stringify(r.text).includes('ya29'), '토큰 미노출');
// 실패 콜백도 done 페이지로(ok=0, 이유), 토큰 교환 없음
r = await op.as('GET', '/ops/ai/oauth/callback?error=access_denied&state=x'); eq(r.status, 302); ok(String(r.headers.Location).startsWith('/ops/ai/oauth/done?ok=0'), r.headers.Location);
// OpenRouter PKCE: 설정 없이 바로 시작 → openrouter.ai/auth → 콜백 code → /api/v1/auth/keys 교환 → 키를 Vault 에
r = await op.as('POST', '/ops/api/ai/connections', { name: '오픈라우터OAuth', provider: 'openrouter', authKind: 'oauth', model: 'openrouter/auto' }); eq(r.status, 200, JSON.stringify(r.json)); const oc = r.json.connection; eq(oc.status, 'reauth_required'); eq(oc.oauth.mode, 'openrouter_pkce');
r = await op.as('POST', `/ops/api/ai/connections/${oc.id}/oauth/start`); eq(r.status, 200, JSON.stringify(r.json)); const ou = new URL(r.json.authorizeUrl); eq(ou.origin + ou.pathname, 'https://openrouter.ai/auth'); ok(ou.searchParams.get('callback_url').includes('/ops/ai/oauth/callback') && ou.searchParams.get('callback_url').includes('state='), ou.toString()); eq(ou.searchParams.get('code_challenge_method'), 'S256');
const st = new URL(ou.searchParams.get('callback_url')).searchParams.get('state');
r = await op.as('GET', `/ops/ai/oauth/callback?state=${st}&code=orcode`); eq(r.status, 302, r.text); const kc = h.fp.calls.find((x) => x.url === 'https://openrouter.ai/api/v1/auth/keys'); ok(kc && kc.method === 'POST' && kc.body.code === 'orcode' && kc.body.code_verifier && kc.body.code_challenge_method === 'S256', JSON.stringify(kc && kc.body));
const tok = JSON.parse(h.d.vault.get(`ai:conn:${oc.id}:oauth`)); ok(tok.accessToken.startsWith('sk-or-v1'));
r = await op.as('GET', '/ops/api/ai/connections'); const oc2 = r.json.connections.find((c) => c.id === oc.id); eq(oc2.status, 'active'); ok(!JSON.stringify(r.json).includes(tok.accessToken));
r = await op.as('POST', `/ops/api/ai/connections/${oc.id}/test`); eq(r.status, 200, JSON.stringify(r.json)); const mc = h.fp.calls.filter((x) => x.method === 'GET').pop(); eq(mc.headers.authorization, 'Bearer ' + tok.accessToken);
});
await test('텍스트 셸(CLI 로그인): 허용 명령만 PTY 로 실행해 출력·입력을 주고받고, 시작은 재인증 필요, 임의 명령·모르는 CLI 는 거부, 종료 코드·강제 종료', async () => {
const h = makeHal(); const op = await loginOp(h);
const home = join(h.dataDir, 'home'); const bin = join(home, '.local', 'bin'); mkdirSync(bin, { recursive: true });
// [0.3.9] 실제 명령은 `claude auth login`(`claude login` 은 대화형 세션) — 가짜 CLI 도 그 형식을 따른다
writeFileSync(join(bin, 'claude'), `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "2.1.312 (Claude Code)"; exit 0; fi\nif [ "$1" = "auth" ] && [ "$2" = "status" ]; then echo '{"loggedIn": false}'; exit 0; fi\nif [ "$1" = "auth" ] && [ "$2" = "login" ]; then echo "Open https://login.example.test/device and paste the code:"; read code; echo "Logged in with code $code"; exit 0; fi\necho "unknown"; exit 2\n`); chmodSync(join(bin, 'claude'), 0o755);
const prev = { h: process.env.HAL_CLI_HOME, d: process.env.HAL_CLI_DIRS }; process.env.HAL_CLI_HOME = home; process.env.HAL_CLI_DIRS = bin;
try {
stale(h); let r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: 'login' }); eq(r.status, 403); eq(r.json.error, 'reauth_required'); await op.as('POST', '/ops/api/reauth', { password: op.pw });
r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: 'rm -rf /' }); eq(r.status, 400); eq(r.json.error, 'bad_command');
r = await op.as('POST', '/ops/api/ai/shell', { tool: 'bash', command: 'login' }); eq(r.status, 400);
r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: 'login' }); eq(r.status, 200, JSON.stringify(r.json)); const s = r.json.session; ok(s.id && s.status === 'running' && s.pty === true && s.command === 'login' && s.tool === 'claude', JSON.stringify(s)); ok(Array.isArray(r.json.guide) && r.json.guide.length, '사전 안내');
let out = ''; for (let i = 0; i < 60 && !/paste the code/.test(out); i++) { await sleep(50); r = await op.as('GET', `/ops/api/ai/shell/${s.id}?after=0`); out = r.json.output; } ok(/paste the code/.test(out), out);
r = await op.as('POST', `/ops/api/ai/shell/${s.id}/input`, { data: 'ABC-123\r' }); eq(r.status, 200);
let st = null; for (let i = 0; i < 80; i++) { await sleep(50); r = await op.as('GET', `/ops/api/ai/shell/${s.id}?after=0`); if (r.json.session.status === 'exited') { st = r.json; break; } } ok(st, '종료'); eq(st.session.exitCode, 0); ok(/Logged in with code ABC-123/.test(st.output), st.output);
ok(!/ABC-123\r/.test(JSON.stringify(h.d.store.all('SELECT * FROM audit_log'))), '입력 내용은 감사에 남기지 않음'); ok(h.d.store.all('SELECT * FROM audit_log WHERE action=?', 'ai.shell.start').length === 1);
// 목록·강제 종료
r = await op.as('POST', '/ops/api/ai/shell', { tool: 'claude', command: 'login' }); const s2 = r.json.session; r = await op.as('GET', '/ops/api/ai/shell'); ok(r.json.sessions.some((x) => x.id === s2.id));
r = await op.as('DELETE', `/ops/api/ai/shell/${s2.id}`); eq(r.status, 200); for (let i = 0; i < 40; i++) { await sleep(50); r = await op.as('GET', `/ops/api/ai/shell/${s2.id}`); if (r.json.session.status !== 'running') break; } ok(r.json.session.status !== 'running', r.json.session.status);
r = await op.as('GET', '/ops/api/ai/shell/no-such'); eq(r.status, 404);
} finally { if (prev.h === undefined) delete process.env.HAL_CLI_HOME; else process.env.HAL_CLI_HOME = prev.h; if (prev.d === undefined) delete process.env.HAL_CLI_DIRS; else process.env.HAL_CLI_DIRS = prev.d; }
});
await test('도구 기본 설정: 연결마다 도구 정책이 미리 들어 있고(전부 켜짐) 바꿀 수 있다; 로컬 CLI 에는 HAL MCP 설정이 자동으로 붙는다(claude --mcp-config, codex -c mcp_servers); MCP 설정 스니펫 API', async () => {
const h = makeHal(); const op = await loginOp(h);
let r = await op.as('POST', '/ops/api/ai/connections', { name: '네오', provider: 'anthropic', authKind: 'api_key', apiKey: rnd('sk'), model: 'claude-fake' }); const c = r.json.connection;
ok(c.tools && c.tools.preset === 'all' && c.tools.enabled.includes('hal_health') && c.tools.enabled.includes('hal_module_deploy') && c.tools.enabled.length >= 30, JSON.stringify(c.tools));
r = await op.as('GET', '/ops/api/ai/tools'); ok(r.json.presets && r.json.presets.some((p) => p.id === 'read_only') && r.json.presets.some((p) => p.id === 'all'), '프리셋 목록');
r = await op.as('PATCH', `/ops/api/ai/connections/${c.id}`, { tools: { preset: 'read_only' } }); eq(r.status, 200); ok(r.json.connection.tools.enabled.includes('hal_health') && !r.json.connection.tools.enabled.includes('hal_module_deploy'));
r = await op.as('PATCH', `/ops/api/ai/connections/${c.id}`, { tools: { preset: 'custom', enabled: ['hal_health', 'nope'] } }); eq(r.status, 400); eq(r.json.error, 'unknown_tool');
r = await op.as('PATCH', `/ops/api/ai/connections/${c.id}`, { tools: { preset: 'custom', enabled: ['hal_health', 'hal_incident_open'] } }); eq(r.status, 200);
const room = await roomOf(op, c.id); h.fp.script.push((req) => { eq(req.body.tools.map((t) => t.name).sort(), ['hal_health', 'hal_incident_open']); return h.fp.text('ok'); });
await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: 'x' }); await h.d.ai.drain(); eq(h.fp.calls.filter((x) => x.url.endsWith('/v1/messages')).length, 1);
// MCP 설정 스니펫
r = await op.as('GET', '/ops/api/ai/mcp-config'); eq(r.status, 200); ok(r.json.claude.mcpServers.hal.args.some((a) => a.endsWith('hal/index.mjs')) && r.json.claude.mcpServers.hal.args.includes('mcp') && r.json.claude.mcpServers.hal.env.ARIWEB_HAL_DATA === h.dataDir, JSON.stringify(r.json.claude)); ok(/mcp_servers\.hal/.test(r.json.codexToml) && /ARIWEB_HAL_DATA/.test(r.json.codexToml), r.json.codexToml); ok(r.json.claudeCommand && /--mcp-config/.test(r.json.claudeCommand));
// 로컬 CLI(claude): 가짜 CLI 가 --mcp-config 파일 내용을 복사해 둔다
const home = join(h.dataDir, 'home'); const bin = join(home, '.local', 'bin'); mkdirSync(bin, { recursive: true });
writeFileSync(join(bin, 'claude'), `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "2.1.312"; exit 0; fi\nif [ "$1" = "auth" ]; then echo '{"loggedIn": true}'; exit 0; fi\necho "$@" > "${h.dataDir}/claude-args.txt"\nwhile [ $# -gt 0 ]; do if [ "$1" = "--mcp-config" ]; then cp "$2" "${h.dataDir}/claude-mcp.json"; fi; shift; done\necho '{"type":"result","result":"CLI 응답","usage":{"input_tokens":1,"output_tokens":1}}'\n`); chmodSync(join(bin, 'claude'), 0o755);
writeFileSync(join(bin, 'codex'), `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "codex-cli 0.162.1"; exit 0; fi\nif [ "$1" = "login" ]; then echo "Logged in using ChatGPT"; exit 0; fi\necho "$@" > "${h.dataDir}/codex-args.txt"\necho '{"type":"item.completed","item":{"type":"agent_message","text":"OK"}}'\n`); chmodSync(join(bin, 'codex'), 0o755);
const prev = { h: process.env.HAL_CLI_HOME, d: process.env.HAL_CLI_DIRS }; process.env.HAL_CLI_HOME = home; process.env.HAL_CLI_DIRS = bin;
try {
r = await op.as('POST', '/ops/api/ai/connections', { name: '클로드', provider: 'local-cli', authKind: 'login', cli: { tool: 'claude' } }); const cl = r.json.connection; ok(cl.toolsSupported === true && cl.toolsVia === 'mcp', JSON.stringify({ ts: cl.toolsSupported, via: cl.toolsVia }));
r = await op.as('POST', `/ops/api/ai/connections/${cl.id}/test`); eq(r.status, 200, JSON.stringify(r.json));
const clRoom = await roomOf(op, cl.id); await op.as('POST', `/ops/api/ai/rooms/${clRoom.id}/messages`, { text: '안녕' }); await h.d.ai.drain();
const args = readFileSync(join(h.dataDir, 'claude-args.txt'), 'utf8'); ok(/--mcp-config \S+/.test(args) && /--strict-mcp-config/.test(args) && /--allowedTools mcp__hal/.test(args), args);
const mcp = JSON.parse(readFileSync(join(h.dataDir, 'claude-mcp.json'), 'utf8')); ok(mcp.mcpServers.hal.args.includes('mcp') && mcp.mcpServers.hal.env.ARIWEB_HAL_DATA === h.dataDir, JSON.stringify(mcp));
ok(!existsSync(args.match(/--mcp-config (\S+)/)[1]), '임시 MCP 설정 파일은 실행 뒤 지운다');
r = await op.as('PATCH', `/ops/api/ai/connections/${cl.id}`, { tools: { preset: 'none' } }); eq(r.status, 200);
await op.as('POST', `/ops/api/ai/rooms/${clRoom.id}/messages`, { text: '도구 없이' }); await h.d.ai.drain(); ok(!/--mcp-config/.test(readFileSync(join(h.dataDir, 'claude-args.txt'), 'utf8')), '도구 끄면 MCP 설정도 붙이지 않음');
r = await op.as('POST', '/ops/api/ai/connections', { name: '코덱스', provider: 'local-cli', authKind: 'login', cli: { tool: 'codex' }, model: 'gpt-fake' }); const cx = r.json.connection; await op.as('POST', `/ops/api/ai/connections/${cx.id}/test`);
const cxRoom = await roomOf(op, cx.id); await op.as('POST', `/ops/api/ai/rooms/${cxRoom.id}/messages`, { text: '안녕' }); await h.d.ai.drain();
const cargs = readFileSync(join(h.dataDir, 'codex-args.txt'), 'utf8'); ok(/-c mcp_servers\.hal\.command=/.test(cargs) && /mcp_servers\.hal\.args=/.test(cargs) && /ARIWEB_HAL_DATA/.test(cargs), cargs);
} finally { if (prev.h === undefined) delete process.env.HAL_CLI_HOME; else process.env.HAL_CLI_HOME = prev.h; if (prev.d === undefined) delete process.env.HAL_CLI_DIRS; else process.env.HAL_CLI_DIRS = prev.d; }
});
await test('인증/승인 요청: MCP 경로의 쓰기·고위험 도구 호출은 승인 요청(제안)으로 올라가고, 상태 도구로 확인하며, 운영자가 콘솔에서 승인(재인증·3단계)하면 실행된다', async () => {
const dataDir = join(tmpDir(), 'hal-mcp'); mkdirSync(dataDir, { recursive: true });
// 같은 데이터로 HAL 앱(운영자 콘솔)과 MCP 프로세스를 함께 연다(파일 DB, WAL)
const config = { dataDir, file: join(dataDir, 'config.json'), port: 0, host: '127.0.0.1', publicUrl: 'https://hal.example.test', portalUrl: 'http://127.0.0.1:1', opsAllowCidrs: ['127.0.0.1/32'], metricsRetentionDays: 90, offlineAfterMs: 90000, values: {}, set(p) { Object.assign(this, p); } };
const d = createHalDeps({ config, dbFile: join(dataDir, 'hal.sqlite'), fetchImpl: async () => { throw new Error('no net'); } }); const app = buildApp(d); const h = { d, app, dataDir }; const op = await loginOp(h);
const child = spawn(process.execPath, ['--no-warnings', join(ROOT, 'hal', 'index.mjs'), 'mcp'], { env: { ...process.env, ARIWEB_HAL_DATA: dataDir, NODE_ENV: 'test' }, stdio: ['pipe', 'pipe', 'pipe'] });
const out = []; let buf = ''; child.stdout.on('data', (c) => { buf += c.toString(); let i; while ((i = buf.indexOf('\n')) >= 0) { const line = buf.slice(0, i); buf = buf.slice(i + 1); if (line.trim()) out.push(JSON.parse(line)); } });
const send = (o) => child.stdin.write(JSON.stringify(o) + '\n'); const waitFor = async (id) => { for (let i = 0; i < 200; i++) { const m = out.find((x) => x.id === id); if (m) return m; await sleep(25); } throw new Error('MCP 응답 없음 ' + id); };
try {
send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }); await waitFor(1);
send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); const tl = await waitFor(2); ok(tl.result.tools.some((t) => t.name === 'hal_action_status') && tl.result.tools.some((t) => t.name === 'hal_actions_pending'), '상태 도구');
// 쓰기 도구 → 승인 요청(오류가 아님), actionId 포함
send({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'hal_incident_open', arguments: { key: 'disk', title: '디스크 경고', severity: 'warning' } } }); const wc = await waitFor(3); ok(!wc.result.isError, JSON.stringify(wc)); const j = JSON.parse(wc.result.content[0].text); ok(j.approvalRequested === true && j.actionId && j.status === 'proposed' && /승인/.test(j.message), wc.result.content[0].text);
eq(h.d.store.all('SELECT * FROM incidents').length, 0, '승인 전 미실행');
send({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'hal_action_status', arguments: { actionId: j.actionId } } }); const sc = await waitFor(4); eq(JSON.parse(sc.result.content[0].text).action.status, 'proposed');
send({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'hal_actions_pending', arguments: {} } }); const pc = await waitFor(5); ok(JSON.parse(pc.result.content[0].text).actions.some((a) => a.id === j.actionId));
// 운영자 콘솔: 통합 방 안내 + 제안 목록에 보이고(출처 mcp), 승인(재인증 필요) → 실행
let r = await op.as('GET', '/ops/api/ai/actions?status=proposed'); const a = r.json.actions.find((x) => x.id === j.actionId); ok(a && a.source === 'mcp' && a.roomId === 'room_group', JSON.stringify(a));
r = await op.as('GET', '/ops/api/ai/rooms/room_group/messages'); ok(r.json.messages.some((m) => m.role === 'system' && (m.content || []).some((b) => b.type === 'proposal' && b.actionId === j.actionId)), '통합 방 제안 카드');
stale(h); r = await op.as('POST', `/ops/api/ai/actions/${j.actionId}/approve`, {}); eq(r.status, 403); eq(r.json.error, 'reauth_required'); await op.as('POST', '/ops/api/reauth', { password: op.pw });
r = await op.as('POST', `/ops/api/ai/actions/${j.actionId}/approve`, {}); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.action.status, 'executed'); eq(h.d.store.all('SELECT * FROM incidents').length, 1);
send({ jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'hal_action_status', arguments: { actionId: j.actionId } } }); const sc2 = await waitFor(6); const st = JSON.parse(sc2.result.content[0].text).action; eq(st.status, 'executed'); ok(st.result && st.result.id, JSON.stringify(st));
r = await op.as('GET', '/ops/api/ai/rooms/room_group/messages'); ok(r.json.messages.some((m) => m.role === 'system' && (m.content || []).some((b) => b.type === 'tool_result' && b.actionId === j.actionId && b.ok)), '실행 결과 안내');
// 고위험 도구 요청 → 승인에 3단계 확인 필요
const srv = (await import('../hal/core.mjs')).createServer(h.d, { name: '웹1', role: 'web' });
send({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'hal_server_isolate', arguments: { serverId: srv.id, reason: '점검' } } }); const hc = await waitFor(7); const hj = JSON.parse(hc.result.content[0].text); ok(hj.approvalRequested && /3단계|재인증/.test(hj.message), hj.message);
r = await op.as('POST', `/ops/api/ai/actions/${hj.actionId}/approve`, {}); eq(r.status, 400); eq(r.json.error, 'confirm_impact_required');
r = await op.as('POST', `/ops/api/ai/actions/${hj.actionId}/reject`, { reason: '아님' }); eq(r.json.action.status, 'rejected');
send({ jsonrpc: '2.0', id: 8, method: 'tools/call', params: { name: 'hal_action_status', arguments: { actionId: hj.actionId } } }); eq(JSON.parse((await waitFor(8)).result.content[0].text).action.status, 'rejected');
} finally { child.kill(); await sleep(50); }
});
await test('화면·버전·문서(0.3.2): AI 연동 화면에 프리셋·사전 안내·텍스트 셸·MCP 설정·도구 정책, 패키지 버전 0.3.2, 개발 지시 v0.3.2', async () => {
const h = makeHal(); const op = await loginOp(h);
const r = await op.as('GET', '/ops/ai'); eq(r.status, 200);
for (const s of ['OpenRouter', 'Gemini', 'ChatGPT', '기타', '사전 준비', '텍스트 셸', 'MCP', '도구 정책']) ok(r.text.includes(s), '화면 문구 ' + s);
const AR = await import('../hal/ui/public/ai-render.mjs'); ok(typeof AR.shellHtml === 'function' && typeof AR.guideHtml === 'function' && typeof AR.toolPolicyHtml === 'function');
ok(AR.shellHtml({ id: 's1', status: 'running', tool: 'claude', command: 'login' }, 'Open <https://x> paste').includes('&lt;https://x&gt;'));
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.1'); eq(h.d.version, '0.4.1');
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.2.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.2'));
});
done();