HAL 0.3.2: AI 연동 편의성 + 도구 기본 설정·승인 요청 — 공급자 프리셋(Claude·ChatGPT·Gemini·OpenRouter + 기타 OpenAI 호환, Gemini 자체 API 어댑터), 사전 준비 안내, OAuth 저장 직후 로그인 창 자동(완료 페이지 postMessage·닫기, Gemini Google 프리셋, OpenRouter PKCE 키 발급), CLI 로그인용 텍스트 셸(허용 명령만 PTY, 재인증, 입력 미저장, 미로그인 시 자동 열기), 연결별 도구 정책(기본 전부)·로컬 CLI 에 HAL MCP 설정 자동 주입·MCP 설정 스니펫, MCP 쓰기·고위험 호출은 승인 요청(제안)으로 + hal_action_status/hal_actions_pending, 개발 지시 v0.3.2(Oz)·docs/HAL-AI.md §7·VERSION_HISTORY 0.3.2 — test/hal-ai-032.test.mjs RED 0/6 → GREEN 6/6, 전체 27파일 189건 통과
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* 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 });
|
||||
writeFileSync(join(bin, 'claude'), `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "2.1.312 (Claude Code)"; exit 0; fi\nif [ "$1" = "auth" ]; then echo '{"loggedIn": false}'; exit 0; fi\nif [ "$1" = "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('<https://x>'));
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.2'); eq(h.d.version, '0.3.2');
|
||||
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();
|
||||
@@ -302,7 +302,7 @@ await test('화면·버전·문서: 공통 틀에 AI 패널(통합 탭 고정)·
|
||||
r = await invoke(h.app, 'GET', '/ops/ui/ai.js'); eq(r.status, 200); r = await invoke(h.app, 'GET', '/ops/ui/ai-render.mjs'); eq(r.status, 200);
|
||||
const AR = await import('../hal/ui/public/ai-render.mjs'); const tabs = AR.tabsHtml([{ id: 'room_group', kind: 'group', title: '통합', fixed: true }, { id: 'r2', kind: 'direct', title: '<네오>', connection: { status: 'active' } }], 'r2'); ok(tabs.indexOf('통합') < tabs.indexOf('<네오>') && !tabs.includes('<네오>'), '통합이 먼저·이스케이프');
|
||||
ok(AR.messageHtml({ role: 'assistant', author: '네오', content: [{ type: 'text', text: '<b>x</b>' }], createdAt: new Date().toISOString() }).includes('<b>'));
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.1'); eq(h.d.version, '0.3.1');
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.2'); eq(h.d.version, '0.3.2');
|
||||
r = await op.as('GET', '/ops/api/ai/skill'); eq(r.status, 200); ok(r.json.skill.includes('pending') && r.json.skill.includes('needs_user') && r.json.skill.includes('reauth_required'), '상태·로그 의미 설명');
|
||||
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.0.md'), 'utf8').includes('Oz'), '개발 지시 v0.3.0'); ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.1.md'), 'utf8').includes('Oz'), '개발 지시 v0.3.1'); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.1'));
|
||||
ok(readFileSync(join(ROOT, 'docs', 'HAL-AI.md'), 'utf8').includes('hal_servers_list'), 'AI 도구 문서');
|
||||
@@ -315,8 +315,8 @@ await test('MCP(stdio) 서버: initialize·tools/list·읽기 도구 call 은
|
||||
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); };
|
||||
send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'test' } } }); const init = await waitFor(1); ok(init.result.serverInfo.name.includes('HAL') && init.result.capabilities.tools);
|
||||
send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); const tl = await waitFor(2); ok(tl.result.tools.some((t) => t.name === 'hal_servers_list' && t.inputSchema)); ok(!tl.result.tools.some((t) => /shell|exec/.test(t.name)));
|
||||
send({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'hal_health', arguments: {} } }); const hc = await waitFor(3); ok(!hc.result.isError); ok(JSON.parse(hc.result.content[0].text).version === '0.3.1');
|
||||
send({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'hal_incident_open', arguments: { key: 'k', title: 't', severity: 'warning' } } }); const wc = await waitFor(4); ok(wc.result.isError === true && /승인/.test(wc.result.content[0].text), JSON.stringify(wc));
|
||||
send({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'hal_health', arguments: {} } }); const hc = await waitFor(3); ok(!hc.result.isError); ok(JSON.parse(hc.result.content[0].text).version === '0.3.2');
|
||||
send({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'hal_incident_open', arguments: { key: 'k', title: 't', severity: 'warning' } } }); const wc = await waitFor(4); ok(!wc.result.isError && /승인/.test(wc.result.content[0].text) && JSON.parse(wc.result.content[0].text).approvalRequested === true, JSON.stringify(wc)); // [0.3.2] 오류가 아니라 승인 요청(제안)으로 올라간다
|
||||
send({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'nope', arguments: {} } }); const nc = await waitFor(5); ok(nc.error || nc.result.isError);
|
||||
child.kill(); await sleep(50);
|
||||
});
|
||||
|
||||
@@ -19,10 +19,10 @@ const require = createRequire(import.meta.url);
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const WM = join(ROOT, 'modules', 'webmaker');
|
||||
|
||||
await test('도입 모듈에 직접 편집 순수 모듈·런타임·화면 파일이 있고 Webmaker 1.8.0 · Suite 0.3.1(HAL AI 채팅룸·CLI 자동 탐색, 도입 모듈 변경 없음) 이다', () => {
|
||||
await test('도입 모듈에 직접 편집 순수 모듈·런타임·화면 파일이 있고 Webmaker 1.8.0 · Suite 0.3.2(HAL AI 연동 편의성·도구 기본 설정, 도입 모듈 변경 없음) 이다', () => {
|
||||
for (const f of ['lib/visual-editor.js', 'public/visual-editor-core.js', 'public/wm-ve-canvas.js', 'public/visual-editor-ui.js']) ok(existsSync(join(WM, f)), f);
|
||||
eq(JSON.parse(readFileSync(join(WM, 'package.json'), 'utf8')).version, '1.8.0');
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.1');
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.2');
|
||||
const imported = JSON.parse(readFileSync(join(ROOT, 'IMPORTED_SOURCES.json'), 'utf8')).sources.find((s) => s.name === 'webmaker');
|
||||
const pin = (readFileSync(join(ROOT, 'scripts', 'import-sources.mjs'), 'utf8').match(/name: 'webmaker'[^\n]*pin: '([0-9a-f]{40})'/) || [])[1]; ok(pin, '도입 스크립트의 webmaker pin');
|
||||
if (imported.upstream && imported.upstream.workingTreeDirty) eq(imported.commit, '3b10859884ef2fd0f2af11934ab6c16370fd64b8', '개발 중 기록(--allow-dirty)은 0.1.1 기준 커밋 위의 작업 트리');
|
||||
|
||||
Reference in New Issue
Block a user