/** * 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('