324 lines
46 KiB
JavaScript
324 lines
46 KiB
JavaScript
/**
|
|
* test/hal-ai.test.mjs — Ariweb Suite 0.3.0(개발 지시 `Ariweb-Suitle_Dev-Order_v0.3.0.md`, Oz 2026-10-29): HAL 운영자 콘솔 AI 채팅룸 회귀 테스트.
|
|
*
|
|
* 계약(요약)
|
|
* 연결: AI 모델은 로그인(로컬 CLI 세션)·OAuth·API 키로 연결한다. 비밀값은 Vault 에만 있고 어떤 응답·목록·감사·대화에도 나오지 않는다. 연결마다 이름·역할·특성(고유 정보)을 둔다.
|
|
* 키 등록·교체·연결 삭제는 "인증 키 변경"이므로 기존 고위험 규칙(최근 10분 재인증)을 따른다.
|
|
* 방: 통합 방(room_group)은 항상 첫 탭이며 이름을 바꿀 수 없다. 연결마다 개별 방이 있고 탭 이름은 모델 이름이 기본, 수정 가능.
|
|
* 통합: 내 지시는 모든 활성 모델에게 간다. 이름을 지정(@이름 / 이름: / 이름,)하면 그 모델만 반응한다. 모델이 다른 모델을 @이름 으로 부르면 그 모델이 자동으로 1회 답하고,
|
|
* 그 답에 대한 두 번째 반응은 자동으로 일어나지 않는다(사용자 지시 필요).
|
|
* 도구: 읽기 도구는 바로 실행된다. 쓰기·고위험 도구는 "제안"으로 멈추고 운영자가 승인해야 실행된다 — 승인은 기존 HAL 규칙(재인증·3단계 확인)을 그대로 거친다. 거부하면 실행되지 않는다.
|
|
*
|
|
* 외부 모델 공급자는 부르지 않는다: deps.fetch 를 가짜로 바꿔 Anthropic·OpenAI 호환 응답을 각본대로 돌려준다. 비밀 fixture 는 실행마다 무작위 생성(정적 literal 없음).
|
|
*/
|
|
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 core from '../hal/core.mjs';
|
|
import * as ops from '../hal/ops.mjs';
|
|
import { mkdirSync, readFileSync, writeFileSync, chmodSync } 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')}`;
|
|
/** 공급자 가짜 응답 각본. push 한 함수가 (요청 url, 파싱된 body, headers) 를 받아 응답 JSON 을 돌려준다. 각본이 비면 기본 텍스트 응답. */
|
|
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 (u.endsWith('/v1/models') && rec.method === 'GET') { if (headers['x-api-key'] === undefined && !headers.authorization) return respond({ error: { message: 'no key' } }, 401); return respond({ data: [{ id: 'claude-fake', display_name: 'Claude Fake' }, { id: 'gpt-fake' }] }); }
|
|
if (u.endsWith('/oauth/token')) return respond({ access_token: rnd('at'), 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());
|
|
/** 연결 하나 등록(API 키, 비밀은 무작위) */
|
|
async function addConn(op, { name, key = rnd('sk'), provider = 'anthropic', model = 'claude-fake', identity = {}, baseUrl } = {}) {
|
|
const r = await op.as('POST', '/ops/api/ai/connections', { name, provider, authKind: 'api_key', apiKey: key, model, identity, baseUrl }); eq(r.status, 200, JSON.stringify(r.json)); return { ...r.json.connection, _key: key };
|
|
}
|
|
const texts = (msgs) => msgs.map((m) => ({ role: m.role, by: m.connectionId || null, depth: m.relayDepth, text: (m.content || []).filter((b) => b.type === 'text').map((b) => b.text).join('') }));
|
|
|
|
await test('AI 연결: API 키 등록은 재인증 필요, 목록·수정·모델 확인·비활성화·삭제, 비밀값은 Vault 에만 있고 어디에도 나오지 않는다', async () => {
|
|
const h = makeHal(); const op = await loginOp(h); const key = rnd('sk');
|
|
// 10분 지난 세션: 키 등록은 고위험(인증 키 변경) → 403 reauth_required
|
|
stale(h); let r = await op.as('POST', '/ops/api/ai/connections', { name: '네오', provider: 'anthropic', authKind: 'api_key', apiKey: key, model: 'claude-fake' }); eq(r.status, 403); eq(r.json.error, 'reauth_required');
|
|
r = await op.as('POST', '/ops/api/reauth', { password: op.pw }); eq(r.status, 200);
|
|
r = await op.as('POST', '/ops/api/ai/connections', { name: '네오', provider: 'anthropic', authKind: 'api_key', apiKey: key, model: 'claude-fake', identity: { role: '릴리스 검토', traits: '꼼꼼함', instructions: '증거 없이 완료라 하지 않는다' } }); eq(r.status, 200, JSON.stringify(r.json));
|
|
const c = r.json.connection; ok(c.id && c.name === '네오' && c.provider === 'anthropic' && c.authKind === 'api_key' && c.model === 'claude-fake'); eq(c.identity.role, '릴리스 검토');
|
|
ok(!JSON.stringify(r.json).includes(key), '등록 응답에 키 없음'); ok(c.secret && c.secret.hasKey === true && c.secret.hint && !c.secret.hint.includes(key.slice(4, 20)), '힌트만');
|
|
ok(h.d.vault.has(`ai:conn:${c.id}:api_key`) && h.d.vault.get(`ai:conn:${c.id}:api_key`) === key, 'Vault 에 저장');
|
|
ok(!JSON.stringify(h.d.store.all('SELECT * FROM ai_connections')).includes(key), 'DB 행에 키 없음');
|
|
ok(!JSON.stringify(h.d.store.all('SELECT * FROM audit_log')).includes(key), '감사 기록에 키 없음');
|
|
// 목록·수정(이름·모델·고유 정보·탭 이름)
|
|
r = await op.as('GET', '/ops/api/ai/connections'); eq(r.status, 200); eq(r.json.connections.length, 1); ok(!JSON.stringify(r.json).includes(key));
|
|
r = await op.as('PATCH', `/ops/api/ai/connections/${c.id}`, { identity: { role: '릴리스 검토·배포 감시' }, tabLabel: '네오(검토)' }); eq(r.status, 200); eq(r.json.connection.identity.role, '릴리스 검토·배포 감시'); eq(r.json.connection.tabLabel, '네오(검토)');
|
|
// 모델 확인: 실제 공급자 모델 목록(가짜)으로 갱신, 서버가 키를 헤더로 보냄(브라우저에는 안 감)
|
|
r = await op.as('POST', `/ops/api/ai/connections/${c.id}/test`); eq(r.status, 200, JSON.stringify(r.json)); ok(r.json.models.includes('claude-fake')); ok(r.json.connection.lastOkAt);
|
|
const mc = h.fp.calls.find((x) => x.url.endsWith('/v1/models')); ok(mc && mc.headers['x-api-key'] === key, '서버→공급자 호출에만 키');
|
|
// 사용할 수 없는 모델 선택은 조용히 바꾸지 않고 거부
|
|
r = await op.as('PATCH', `/ops/api/ai/connections/${c.id}`, { model: 'no-such-model' }); eq(r.status, 400); eq(r.json.error, 'model_unavailable');
|
|
// 키 교체도 재인증 필요
|
|
stale(h); r = await op.as('POST', `/ops/api/ai/connections/${c.id}/secret`, { apiKey: rnd('sk') }); eq(r.json.error, 'reauth_required');
|
|
r = await op.as('POST', '/ops/api/reauth', { password: op.pw }); const key2 = rnd('sk'); r = await op.as('POST', `/ops/api/ai/connections/${c.id}/secret`, { apiKey: key2 }); eq(r.status, 200); eq(h.d.vault.get(`ai:conn:${c.id}:api_key`), key2); ok(!JSON.stringify(r.json).includes(key2));
|
|
// 비활성화 → 통합 방 응답자에서 빠짐(아래 방 테스트) / 삭제는 재인증 + Vault 비밀 제거
|
|
r = await op.as('PATCH', `/ops/api/ai/connections/${c.id}`, { status: 'disabled' }); eq(r.json.connection.status, 'disabled');
|
|
stale(h); r = await op.as('DELETE', `/ops/api/ai/connections/${c.id}`); eq(r.json.error, 'reauth_required');
|
|
await op.as('POST', '/ops/api/reauth', { password: op.pw }); r = await op.as('DELETE', `/ops/api/ai/connections/${c.id}`); eq(r.status, 200); ok(!h.d.vault.has(`ai:conn:${c.id}:api_key`), '삭제 뒤 Vault 비밀 없음');
|
|
r = await op.as('GET', '/ops/api/ai/connections'); eq(r.json.connections.length, 0);
|
|
// 잘못된 입력
|
|
r = await op.as('POST', '/ops/api/ai/connections', { name: '', provider: 'anthropic', authKind: 'api_key', apiKey: 'x' }); eq(r.status, 400);
|
|
r = await op.as('POST', '/ops/api/ai/connections', { name: 'x', provider: 'unknown', authKind: 'api_key', apiKey: rnd() }); eq(r.status, 400); eq(r.json.error, 'bad_provider');
|
|
});
|
|
|
|
await test('AI 연결: OAuth(PKCE) — 시작은 state·code_challenge 를 만들고, 콜백은 state·운영자·만료를 검증해 토큰을 Vault 에만 저장한다', async () => {
|
|
const h = makeHal(); const op = await loginOp(h);
|
|
let r = await op.as('POST', '/ops/api/ai/connections', { name: '오라클', provider: 'openai', authKind: 'oauth', baseUrl: 'https://llm.example.test/v1', model: 'gpt-fake', oauth: { authorizeUrl: 'https://auth.example.test/authorize', tokenUrl: 'https://auth.example.test/oauth/token', clientId: 'client-public-id', scopes: ['models.read', 'chat'], clientSecret: rnd('cs') } });
|
|
eq(r.status, 200, JSON.stringify(r.json)); const c = r.json.connection; eq(c.status, 'reauth_required', '토큰 없으면 재인증(로그인) 필요 상태'); ok(!JSON.stringify(r.json).includes('cs-'), 'client secret 미노출');
|
|
r = await op.as('POST', `/ops/api/ai/connections/${c.id}/oauth/start`); eq(r.status, 200, JSON.stringify(r.json)); const u = new URL(r.json.authorizeUrl);
|
|
eq(u.origin + u.pathname, 'https://auth.example.test/authorize'); eq(u.searchParams.get('client_id'), 'client-public-id'); eq(u.searchParams.get('code_challenge_method'), 'S256'); ok(u.searchParams.get('code_challenge') && u.searchParams.get('state')); ok(u.searchParams.get('redirect_uri').endsWith('/ops/ai/oauth/callback')); eq(u.searchParams.get('scope'), 'models.read chat');
|
|
const state = u.searchParams.get('state');
|
|
// 잘못된 state → 거부, 토큰 교환 호출 없음
|
|
r = await op.as('GET', `/ops/ai/oauth/callback?code=abc&state=${state}x`); eq(r.status, 400); ok(!h.fp.calls.some((x) => x.url.endsWith('/oauth/token')));
|
|
// 다른 운영자 세션의 콜백 → 거부(토큰이 다른 계정에 연결되지 않음)
|
|
const pw2 = rnd('pw'); ops.createAccount(h.d, { login: 'other', password: pw2, displayName: '다른' }); const l2 = await invoke(h.app, 'POST', '/ops/api/login', { body: { login: 'other', password: pw2 } });
|
|
r = await invoke(h.app, 'GET', `/ops/ai/oauth/callback?code=abc&state=${state}`, { cookies: { aw_ops: l2.cookies.aw_ops } }); eq(r.status, 400); ok(!h.fp.calls.some((x) => x.url.endsWith('/oauth/token')));
|
|
// 올바른 콜백 → 토큰 교환(code_verifier 포함, client_secret 은 서버→토큰 끝점에만) → Vault 저장 → 연결 active → /ops/ai 로 이동
|
|
r = await op.as('GET', `/ops/ai/oauth/callback?code=abc&state=${state}`); eq(r.status, 302, r.text); ok(String(r.headers.Location).startsWith('/ops/ai'));
|
|
const tc = h.fp.calls.find((x) => x.url.endsWith('/oauth/token')); ok(tc, '토큰 교환 호출'); const form = Object.fromEntries(new URLSearchParams(tc.body)); eq(form.grant_type, 'authorization_code'); eq(form.code, 'abc'); ok(form.code_verifier && form.code_verifier.length >= 43); ok(form.client_secret && form.client_secret.startsWith('cs-'));
|
|
ok(h.d.vault.has(`ai:conn:${c.id}:oauth`), 'OAuth 토큰은 Vault'); const tok = JSON.parse(h.d.vault.get(`ai:conn:${c.id}:oauth`)); ok(tok.accessToken.startsWith('at-') && tok.refreshToken.startsWith('rt-') && tok.expiresAt);
|
|
r = await op.as('GET', '/ops/api/ai/connections'); eq(r.json.connections[0].status, 'active'); ok(!JSON.stringify(r.json).includes(tok.accessToken) && !JSON.stringify(r.json).includes('rt-'), '토큰 미노출');
|
|
// 같은 state 재사용 불가(1회용)
|
|
r = await op.as('GET', `/ops/ai/oauth/callback?code=abc&state=${state}`); eq(r.status, 400);
|
|
// 모델 확인은 Bearer 로
|
|
r = await op.as('POST', `/ops/api/ai/connections/${c.id}/test`); eq(r.status, 200, JSON.stringify(r.json)); const mc = h.fp.calls.filter((x) => x.url.endsWith('/v1/models')).pop(); eq(mc.headers.authorization, 'Bearer ' + tok.accessToken); ok(mc.url.startsWith('https://llm.example.test/v1/'));
|
|
ok(!JSON.stringify(h.d.store.all('SELECT * FROM audit_log')).includes(tok.accessToken), '감사에 토큰 없음');
|
|
});
|
|
|
|
await test('채팅 방: 통합 방은 항상 첫 탭(이름 변경 불가), 연결마다 개별 방(탭 이름 = 모델 이름 기본, 수정 가능), 비활성 연결은 탭 표시만', async () => {
|
|
const h = makeHal(); const op = await loginOp(h);
|
|
let r = await op.as('GET', '/ops/api/ai/rooms'); eq(r.status, 200); eq(r.json.rooms.length, 1); eq(r.json.rooms[0].id, 'room_group'); eq(r.json.rooms[0].kind, 'group'); eq(r.json.rooms[0].fixed, true);
|
|
const a = await addConn(op, { name: '네오' }); const b = await addConn(op, { name: '헤라' });
|
|
r = await op.as('GET', '/ops/api/ai/rooms'); eq(r.json.rooms.map((x) => x.kind), ['group', 'direct', 'direct']); eq(r.json.rooms[1].title, '네오'); eq(r.json.rooms[1].connectionId, a.id); eq(r.json.rooms[2].title, '헤라');
|
|
r = await op.as('PATCH', `/ops/api/ai/rooms/${r.json.rooms[1].id}`, { title: '네오 — 검토' }); eq(r.status, 200); eq(r.json.room.title, '네오 — 검토');
|
|
r = await op.as('PATCH', '/ops/api/ai/rooms/room_group', { title: 'x' }); eq(r.status, 400); eq(r.json.error, 'room_fixed');
|
|
r = await op.as('GET', '/ops/api/ai/rooms'); eq(r.json.rooms[0].id, 'room_group'); eq(r.json.rooms[1].title, '네오 — 검토');
|
|
await op.as('PATCH', `/ops/api/ai/connections/${b.id}`, { status: 'disabled' }); r = await op.as('GET', '/ops/api/ai/rooms'); eq(r.json.rooms[2].connection.status, 'disabled');
|
|
});
|
|
|
|
await test('통합 방: 지시는 활성 모델 모두에게, @이름/이름: 지정은 그 모델만, 각 모델은 자기 고유 정보(이름·역할·특성)와 다른 참여자 발언을 받는다', async () => {
|
|
const h = makeHal(); const op = await loginOp(h);
|
|
const neo = await addConn(op, { name: '네오', identity: { role: '릴리스 검토', traits: '꼼꼼함' } }); const hera = await addConn(op, { name: '헤라', identity: { role: '배포 운영', traits: '신중함' } }); const idle = await addConn(op, { name: '쉼' });
|
|
await op.as('PATCH', `/ops/api/ai/connections/${idle.id}`, { status: 'disabled' });
|
|
h.fp.script.push((req) => h.fp.text(`[${req.body.system.includes('네오') ? '네오' : '헤라'}] 확인했습니다`)); h.fp.script.push((req) => h.fp.text(`[${req.body.system.includes('네오') ? '네오' : '헤라'}] 확인했습니다`));
|
|
let r = await op.as('POST', '/ops/api/ai/rooms/room_group/messages', { text: '현재 HAL 상태를 요약해 주세요' }); eq(r.status, 200, JSON.stringify(r.json));
|
|
eq(r.json.responders.sort(), [neo.id, hera.id].sort(), '활성 모델 모두(비활성 제외)'); await h.d.ai.drain();
|
|
r = await op.as('GET', '/ops/api/ai/rooms/room_group/messages'); const m = texts(r.json.messages); eq(m[0].role, 'user'); eq(m.filter((x) => x.role === 'assistant').length, 2); eq(r.json.pending, 0);
|
|
// 각 모델은 자기 고유 정보를 system 으로 받고, 도구 목록도 받는다
|
|
const reqs = h.fp.calls.filter((x) => x.url.endsWith('/v1/messages')); eq(reqs.length, 2);
|
|
const neoReq = reqs.find((x) => x.headers['x-api-key'] === neo._key); ok(neoReq.body.system.includes('네오') && neoReq.body.system.includes('릴리스 검토') && neoReq.body.system.includes('꼼꼼함'), '고유 정보'); ok(neoReq.body.system.includes('헤라'), '다른 참여자 이름도 안내'); ok(Array.isArray(neoReq.body.tools) && neoReq.body.tools.some((t) => t.name === 'hal_servers_list'), 'HAL 도구 제공');
|
|
// 이름 지정: @헤라 → 헤라만
|
|
h.fp.script.push(() => h.fp.text('[헤라] 네, 배포 상태만 보겠습니다'));
|
|
r = await op.as('POST', '/ops/api/ai/rooms/room_group/messages', { text: '@헤라 배포 상태만 봐 주세요' }); eq(r.json.responders, [hera.id]); await h.d.ai.drain();
|
|
eq(h.fp.calls.filter((x) => x.url.endsWith('/v1/messages')).length, 3);
|
|
const heraReq = h.fp.calls.filter((x) => x.url.endsWith('/v1/messages')).pop(); eq(heraReq.headers['x-api-key'], hera._key);
|
|
// 헤라가 받는 대화에는 네오의 앞선 발언이 "[네오] …" 로 보인다(다른 참여자 = user 역할 메시지)
|
|
ok(JSON.stringify(heraReq.body.messages).includes('[네오]'), '다른 모델 발언 전달');
|
|
// "네오:" 접두어도 지정
|
|
h.fp.script.push(() => h.fp.text('[네오] 네')); r = await op.as('POST', '/ops/api/ai/rooms/room_group/messages', { text: '네오: 릴리스 목록만' }); eq(r.json.responders, [neo.id]); await h.d.ai.drain();
|
|
// 모르는 이름을 지정하면 아무에게도 가지 않고 400
|
|
r = await op.as('POST', '/ops/api/ai/rooms/room_group/messages', { text: '@없는이 안녕' }); eq(r.status, 400); eq(r.json.error, 'unknown_addressee');
|
|
// 사용량 기록
|
|
r = await op.as('GET', '/ops/api/ai/rooms/room_group/messages'); const last = r.json.messages.filter((x) => x.role === 'assistant').pop(); eq(last.usage.inputTokens, 11); eq(last.usage.outputTokens, 7);
|
|
});
|
|
|
|
await test('통합 방: 모델→모델 전달(@이름)은 자동 1회만 답하고, 그 답에 대한 두 번째 반응은 사용자 지시 없이는 일어나지 않는다', async () => {
|
|
const h = makeHal(); const op = await loginOp(h);
|
|
const neo = await addConn(op, { name: '네오' }); const hera = await addConn(op, { name: '헤라' });
|
|
// 사용자 → 네오만. 네오가 @헤라 를 부른다 → 헤라 자동 1회 답(그 답에서 다시 @네오 를 불러도 네오는 자동으로 답하지 않는다)
|
|
h.fp.script.push(() => h.fp.text('@헤라 배포 전 확인 부탁합니다')); h.fp.script.push(() => h.fp.text('@네오 확인했습니다. 다음 단계 진행하세요'));
|
|
let r = await op.as('POST', '/ops/api/ai/rooms/room_group/messages', { text: '@네오 릴리스 준비 상태를 점검해 주세요' }); eq(r.json.responders, [neo.id]); await h.d.ai.drain();
|
|
const calls = h.fp.calls.filter((x) => x.url.endsWith('/v1/messages')); eq(calls.length, 2, '네오 1회 + 헤라 자동 1회, 네오 재반응 없음'); eq(calls[1].headers['x-api-key'], hera._key);
|
|
r = await op.as('GET', '/ops/api/ai/rooms/room_group/messages'); const m = texts(r.json.messages);
|
|
const asst = m.filter((x) => x.role === 'assistant'); eq(asst.length, 2); eq(asst[0].by, neo.id); eq(asst[0].depth, 0); eq(asst[1].by, hera.id); eq(asst[1].depth, 1);
|
|
ok(r.json.messages.some((x) => x.role === 'system' && /사용자 지시/.test(texts([x])[0].text)), '두 번째 반응은 사용자 지시로 진행한다는 안내');
|
|
eq(r.json.pending, 0);
|
|
// 사용자가 지시하면 이어진다
|
|
h.fp.script.push(() => h.fp.text('[네오] 진행합니다'));
|
|
r = await op.as('POST', '/ops/api/ai/rooms/room_group/messages', { text: '@네오 헤라 의견대로 진행 계획을 적어 주세요' }); await h.d.ai.drain(); eq(h.fp.calls.filter((x) => x.url.endsWith('/v1/messages')).length, 3);
|
|
// 자기 자신을 부르는 @네오 는 전달이 아니다 / 비활성 모델은 전달 대상이 아니다
|
|
const zed = await addConn(op, { name: '제드' }); await op.as('PATCH', `/ops/api/ai/connections/${zed.id}`, { status: 'disabled' });
|
|
h.fp.script.push(() => h.fp.text('@네오 스스로 메모 @제드 들리나요'));
|
|
r = await op.as('POST', '/ops/api/ai/rooms/room_group/messages', { text: '@네오 메모해 주세요' }); await h.d.ai.drain(); eq(h.fp.calls.filter((x) => x.url.endsWith('/v1/messages')).length, 4, '자기 자신·비활성 모델로는 전달되지 않음');
|
|
});
|
|
|
|
await test('개별 방: 1:1 대화, 읽기 도구(서버 목록·건강)는 바로 실행되어 결과가 모델에게 돌아가고 대화에 기록된다; 공급자 오류는 구분해 표시', async () => {
|
|
const h = makeHal(); const op = await loginOp(h); const neo = await addConn(op, { name: '네오' }); const hera = await addConn(op, { name: '헤라' });
|
|
core.createServer(h.d, { name: '웹1', role: 'web', location: '서울' });
|
|
const rooms = (await op.as('GET', '/ops/api/ai/rooms')).json.rooms; const neoRoom = rooms.find((x) => x.connectionId === neo.id);
|
|
// 1) 도구 호출 → 2) 결과를 받아 최종 답
|
|
h.fp.script.push(() => ({ id: 'm1', role: 'assistant', content: [{ type: 'text', text: '서버를 확인합니다' }, { type: 'tool_use', id: 'tu_1', name: 'hal_servers_list', input: {} }], stop_reason: 'tool_use', usage: { input_tokens: 5, output_tokens: 3 } }));
|
|
h.fp.script.push((req) => { const last = req.body.messages[req.body.messages.length - 1]; const tr = last.content.find((b) => b.type === 'tool_result'); return h.fp.text(`서버 ${JSON.parse(tr.content).servers.length}대: ${JSON.parse(tr.content).servers[0].name}`); });
|
|
let r = await op.as('POST', `/ops/api/ai/rooms/${neoRoom.id}/messages`, { text: '서버가 몇 대 있나요?' }); eq(r.status, 200); eq(r.json.responders, [neo.id]); await h.d.ai.drain();
|
|
r = await op.as('GET', `/ops/api/ai/rooms/${neoRoom.id}/messages`); const ms = r.json.messages;
|
|
const tu = ms.find((x) => (x.content || []).some((b) => b.type === 'tool_use')); ok(tu, '도구 호출 기록'); const trm = ms.find((x) => x.role === 'tool'); ok(trm && trm.content[0].type === 'tool_result' && trm.content[0].ok === true && trm.content[0].name === 'hal_servers_list');
|
|
eq(texts(ms).filter((x) => x.role === 'assistant').pop().text, '서버 1대: 웹1');
|
|
eq(h.fp.calls.filter((x) => x.url.endsWith('/v1/messages')).length, 2); ok(h.fp.calls.filter((x) => x.url.endsWith('/v1/messages')).every((x) => x.headers['x-api-key'] === neo._key), '개별 방은 그 모델만');
|
|
// 개별 방 대화는 다른 모델 방·통합 방에 섞이지 않는다
|
|
const heraRoom = rooms.find((x) => x.connectionId === hera.id); r = await op.as('GET', `/ops/api/ai/rooms/${heraRoom.id}/messages`); eq(r.json.messages.length, 0); r = await op.as('GET', '/ops/api/ai/rooms/room_group/messages'); eq(r.json.messages.length, 0);
|
|
// 공급자 오류(rate limit)는 모델 응답으로 꾸미지 않고 오류 메시지로 남긴다
|
|
h.fp.script.push(() => ({ __status: 429, error: { type: 'rate_limit_error', message: 'slow down' } }));
|
|
r = await op.as('POST', `/ops/api/ai/rooms/${neoRoom.id}/messages`, { text: '다시' }); await h.d.ai.drain();
|
|
r = await op.as('GET', `/ops/api/ai/rooms/${neoRoom.id}/messages`); const err = r.json.messages.pop(); eq(err.role, 'assistant'); eq(err.status, 'failed'); ok(/rate_limit|429/.test(err.error), err.error); eq(r.json.pending, 0);
|
|
// 비활성 연결의 방에는 보낼 수 없다
|
|
await op.as('PATCH', `/ops/api/ai/connections/${hera.id}`, { status: 'disabled' }); r = await op.as('POST', `/ops/api/ai/rooms/${heraRoom.id}/messages`, { text: 'x' }); eq(r.status, 409); eq(r.json.error, 'connection_inactive');
|
|
// 새 대화(기록 비우기)
|
|
r = await op.as('POST', `/ops/api/ai/rooms/${neoRoom.id}/reset`); eq(r.status, 200); r = await op.as('GET', `/ops/api/ai/rooms/${neoRoom.id}/messages`); eq(r.json.messages.length, 0);
|
|
});
|
|
|
|
await test('도구 경계: 쓰기·고위험 도구는 제안으로 멈추고 승인 전 실행되지 않는다; 승인은 재인증(고위험은 3단계 확인)을 거치고, 거부하면 실행되지 않으며, 승인 뒤 딱 한 번 실행하고 대화를 잇는다', async () => {
|
|
const h = makeHal(); const op = await loginOp(h); const neo = await addConn(op, { name: '네오' });
|
|
const s = core.createServer(h.d, { name: '웹1', role: 'web' });
|
|
const room = (await op.as('GET', '/ops/api/ai/rooms')).json.rooms.find((x) => x.connectionId === neo.id);
|
|
// 고위험(서버 격리) 제안
|
|
h.fp.script.push(() => ({ id: 'm1', role: 'assistant', content: [{ type: 'text', text: '웹1 을 격리하겠습니다' }, { type: 'tool_use', id: 'tu_iso', name: 'hal_server_isolate', input: { serverId: s.id, reason: '디스크 오류 의심' } }], stop_reason: 'tool_use', usage: { input_tokens: 1, output_tokens: 1 } }));
|
|
let r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '웹1 을 격리해 주세요' }); await h.d.ai.drain();
|
|
eq(core.getServer(h.d, s.id).status, 'pending', '승인 전 미실행');
|
|
r = await op.as('GET', `/ops/api/ai/rooms/${room.id}/messages`); eq(r.json.pending, 0); const prop = r.json.actions.find((a) => a.status === 'proposed'); ok(prop && prop.tool === 'hal_server_isolate' && prop.risk === 'high' && prop.identifiers.includes(s.id), JSON.stringify(r.json.actions));
|
|
ok(r.json.messages.some((m) => (m.content || []).some((b) => b.type === 'proposal' && b.actionId === prop.id)), '대화에 제안 카드');
|
|
eq(h.fp.calls.filter((x) => x.url.endsWith('/v1/messages')).length, 1, '제안 뒤 모델을 더 부르지 않음');
|
|
// 승인: 10분 지난 세션 → reauth_required, 미실행
|
|
stale(h); r = await op.as('POST', `/ops/api/ai/actions/${prop.id}/approve`, { confirmImpact: true, confirmIdentifiers: [s.id], confirmExecute: true }); eq(r.status, 403); eq(r.json.error, 'reauth_required'); eq(core.getServer(h.d, s.id).status, 'pending');
|
|
await op.as('POST', '/ops/api/reauth', { password: op.pw });
|
|
// 3단계 확인 없이 → 거부, 미실행
|
|
r = await op.as('POST', `/ops/api/ai/actions/${prop.id}/approve`, {}); eq(r.status, 400); eq(r.json.error, 'confirm_impact_required'); eq(core.getServer(h.d, s.id).status, 'pending');
|
|
r = await op.as('POST', `/ops/api/ai/actions/${prop.id}/approve`, { confirmImpact: true, confirmIdentifiers: ['srv_wrong'], confirmExecute: true }); eq(r.status, 400); eq(core.getServer(h.d, s.id).status, 'pending');
|
|
// 승인 → 실행 1회 → 결과가 도구 결과로 들어가고 모델이 이어서 답한다
|
|
h.fp.script.push((req) => { const last = req.body.messages[req.body.messages.length - 1]; const tr = last.content.find((b) => b.type === 'tool_result'); return h.fp.text(`격리 ${JSON.parse(tr.content).server.rawStatus === 'isolated' ? '완료' : '실패'}`); }); // publicServer: heartbeat 없는 서버는 status=offline, 실제 DB 상태는 rawStatus
|
|
r = await op.as('POST', `/ops/api/ai/actions/${prop.id}/approve`, { confirmImpact: true, confirmIdentifiers: [s.id], confirmExecute: true }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.action.status, 'executed'); eq(core.getServer(h.d, s.id).status, 'isolated'); await h.d.ai.drain();
|
|
r = await op.as('GET', `/ops/api/ai/rooms/${room.id}/messages`); eq(texts(r.json.messages).filter((x) => x.role === 'assistant').pop().text, '격리 완료');
|
|
// 같은 제안 재승인 불가(1회)
|
|
r = await op.as('POST', `/ops/api/ai/actions/${prop.id}/approve`, { confirmImpact: true, confirmIdentifiers: [s.id], confirmExecute: true }); eq(r.status, 409); eq(r.json.error, 'action_not_proposed');
|
|
const au = h.d.store.all('SELECT * FROM audit_log WHERE action LIKE ?', 'ai.%'); ok(au.some((a) => a.action === 'ai.action.execute' && a.subject_id === prop.id), '감사: 실행'); ok(au.every((a) => !JSON.stringify(a).includes(neo._key)));
|
|
// 쓰기(저위험) 도구: 사건 등록 → 재인증만 필요(3단계 없음) / 거부 → 미실행
|
|
h.fp.script.push(() => ({ id: 'm2', role: 'assistant', content: [{ type: 'tool_use', id: 'tu_inc', name: 'hal_incident_open', input: { key: 'disk-web1', title: '웹1 디스크 오류', severity: 'warning', customerImpact: '없음' } }], stop_reason: 'tool_use', usage: { input_tokens: 1, output_tokens: 1 } }));
|
|
r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '사건으로 등록해 주세요' }); await h.d.ai.drain();
|
|
r = await op.as('GET', `/ops/api/ai/rooms/${room.id}/messages`); const p2 = r.json.actions.find((a) => a.status === 'proposed'); eq(p2.risk, 'write'); eq(h.d.store.all('SELECT * FROM incidents').length, 0, '승인 전 미실행');
|
|
r = await op.as('POST', `/ops/api/ai/actions/${p2.id}/reject`, { reason: '지금은 아님' }); eq(r.status, 200); eq(r.json.action.status, 'rejected'); eq(h.d.store.all('SELECT * FROM incidents').length, 0, '거부 → 미실행');
|
|
eq(h.fp.calls.filter((x) => x.url.endsWith('/v1/messages')).length, 3, '거부는 모델을 자동으로 부르지 않음');
|
|
r = await op.as('GET', `/ops/api/ai/rooms/${room.id}/messages`); ok(r.json.messages.some((m) => m.role === 'tool' && m.content[0].ok === false && /거부/.test(m.content[0].output)), '거부가 도구 결과로 기록');
|
|
// 모델이 모르는 도구·잘못된 인자를 부르면 실행하지 않고 오류를 돌려준다
|
|
h.fp.script.push(() => ({ id: 'm3', role: 'assistant', content: [{ type: 'tool_use', id: 'tu_x', name: 'hal_shell_exec', input: { cmd: 'rm -rf /' } }], stop_reason: 'tool_use', usage: {} }));
|
|
h.fp.script.push((req) => { const tr = req.body.messages[req.body.messages.length - 1].content.find((b) => b.type === 'tool_result'); return h.fp.text(tr.is_error ? '그 도구는 없습니다' : '?'); });
|
|
r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '셸 실행' }); await h.d.ai.drain(); r = await op.as('GET', `/ops/api/ai/rooms/${room.id}/messages`); eq(texts(r.json.messages).filter((x) => x.role === 'assistant').pop().text, '그 도구는 없습니다');
|
|
eq(r.json.actions.filter((a) => a.status === 'proposed').length, 0);
|
|
// 도구 목록 API: 위험 등급이 있다
|
|
r = await op.as('GET', '/ops/api/ai/tools'); ok(r.json.tools.find((t) => t.name === 'hal_server_isolate').risk === 'high' && r.json.tools.find((t) => t.name === 'hal_servers_list').risk === 'read' && r.json.tools.find((t) => t.name === 'hal_module_deploy').risk === 'high');
|
|
});
|
|
|
|
await test('OpenAI 호환 공급자: chat/completions 형식·Bearer 키·function tool 호출 왕복', async () => {
|
|
const h = makeHal(); const op = await loginOp(h); const c = await addConn(op, { name: '지피', provider: 'openai', model: 'gpt-fake', baseUrl: 'https://llm.example.test/v1' });
|
|
const room = (await op.as('GET', '/ops/api/ai/rooms')).json.rooms.find((x) => x.connectionId === c.id);
|
|
h.fp.script.push(() => ({ choices: [{ message: { role: 'assistant', content: null, tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'hal_health', arguments: '{}' } }] }, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 9, completion_tokens: 2 } }));
|
|
h.fp.script.push((req) => { const last = req.body.messages[req.body.messages.length - 1]; eq(last.role, 'tool'); eq(last.tool_call_id, 'call_1'); return { choices: [{ message: { role: 'assistant', content: `HAL ${JSON.parse(last.content).version}` }, finish_reason: 'stop' }], usage: { prompt_tokens: 3, completion_tokens: 1 } }; });
|
|
let r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: 'HAL 버전?' }); await h.d.ai.drain();
|
|
const calls = h.fp.calls.filter((x) => x.url === 'https://llm.example.test/v1/chat/completions'); eq(calls.length, 2); eq(calls[0].headers.authorization, 'Bearer ' + c._key); ok(calls[0].body.tools.some((t) => t.type === 'function' && t.function.name === 'hal_servers_list')); eq(calls[0].body.messages[0].role, 'system');
|
|
r = await op.as('GET', `/ops/api/ai/rooms/${room.id}/messages`); eq(texts(r.json.messages).filter((x) => x.role === 'assistant').pop().text, `HAL ${h.d.version}`);
|
|
});
|
|
|
|
await test('로그인(로컬 CLI 세션) 연결: 실행 파일이 없으면 사용 불가 이유를 표시하고, 있으면 -p 로 실행해 JSON 결과를 답으로 쓴다(세션 파일·자격은 노출하지 않음)', async () => {
|
|
const h = makeHal(); const op = await loginOp(h);
|
|
let r = await op.as('POST', '/ops/api/ai/connections', { name: '로컬', provider: 'local-cli', authKind: 'login', model: 'claude-opus-5-5', cli: { bin: join(h.dataDir, 'no-such-claude') } }); eq(r.status, 200, JSON.stringify(r.json)); const c = r.json.connection;
|
|
r = await op.as('POST', `/ops/api/ai/connections/${c.id}/test`); eq(r.status, 424); eq(r.json.error, 'cli_unavailable'); ok(/실행 파일/.test(r.json.message), r.json.message); r = await op.as('GET', '/ops/api/ai/connections'); eq(r.json.connections[0].status, 'error'); ok(/실행 파일/.test(r.json.connections[0].lastError));
|
|
// 가짜 CLI: 인자를 기록하고 JSON 결과를 낸다
|
|
const fake = join(h.dataDir, 'fake-claude'); writeFileSync(fake, `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "fake 1.0"; exit 0; fi\necho "$@" > "${h.dataDir}/cli-args.txt"\necho '{"type":"result","result":"CLI 응답입니다","usage":{"input_tokens":4,"output_tokens":2},"session_id":"s1"}'\n`); chmodSync(fake, 0o755);
|
|
r = await op.as('PATCH', `/ops/api/ai/connections/${c.id}`, { cli: { bin: fake } }); eq(r.status, 200);
|
|
r = await op.as('POST', `/ops/api/ai/connections/${c.id}/test`); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.connection.status, 'active'); ok(r.json.cliVersion.includes('fake 1.0'));
|
|
const room = (await op.as('GET', '/ops/api/ai/rooms')).json.rooms.find((x) => x.connectionId === c.id);
|
|
r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '안녕' }); eq(r.status, 200); await h.d.ai.drain();
|
|
r = await op.as('GET', `/ops/api/ai/rooms/${room.id}/messages`); const last = r.json.messages.pop(); eq(last.role, 'assistant'); eq(texts([last])[0].text, 'CLI 응답입니다'); eq(last.usage.inputTokens, 4);
|
|
const args = readFileSync(join(h.dataDir, 'cli-args.txt'), 'utf8'); ok(args.includes('-p') && args.includes('--output-format json') && args.includes('--model claude-opus-5-5'), args); ok(!args.includes('api_key'), '자격 전달 없음');
|
|
});
|
|
|
|
await test('0.3.1 CLI 자동 탐색: 서비스 PATH 밖(~/.local/bin 등)의 claude·codex 를 찾아 버전·로그인 상태를 보이고, 연결은 bin 없이 탐색 결과를 쓴다 — codex 는 exec --json 으로 답하고, 로그인 안 된 claude 는 reauth_required', async () => {
|
|
const h = makeHal(); const op = await loginOp(h);
|
|
// 가짜 설치: HOME/.local/bin 에 claude(로그인 만료)·codex(로그인됨), HOME/.codex 에 models_cache.json·config.toml. 서비스 PATH 에는 없는 위치다.
|
|
const home = join(h.dataDir, 'home'); const bin = join(home, '.local', 'bin'); mkdirSync(bin, { recursive: true }); mkdirSync(join(home, '.codex'), { 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, "authMethod": "none"}'; exit 0; fi\necho '{"type":"result","subtype":"success","is_error":true,"result":"Failed to authenticate: OAuth session expired and could not be refreshed","usage":{}}'\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":"thread.started","thread_id":"t1"}'\necho '{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"OK from codex"}}'\necho '{"type":"turn.completed","usage":{"input_tokens":14013,"cached_input_tokens":12288,"output_tokens":5}}'\n`); chmodSync(join(bin, 'codex'), 0o755);
|
|
writeFileSync(join(home, '.codex', 'models_cache.json'), JSON.stringify({ models: [{ slug: 'gpt-6.1-sol', visibility: 'list', display_name: 'GPT-6.1 Sol' }, { slug: 'gpt-6-luna', visibility: 'list' }, { slug: 'gpt-hidden', visibility: 'hide' }] })); writeFileSync(join(home, '.codex', 'config.toml'), 'model = "gpt-6.1-sol"\nmodel_reasoning_effort = "high"\n');
|
|
const prevHome = process.env.HAL_CLI_HOME; const prevDirs = process.env.HAL_CLI_DIRS; process.env.HAL_CLI_HOME = home; process.env.HAL_CLI_DIRS = bin; // 탐색 위치를 고정(개발 PC 의 실제 CLI 가 PATH 에 있어도 테스트에 섞이지 않게)
|
|
try {
|
|
// 탐색 API: 두 CLI 모두 찾고(서비스 PATH 와 무관) 버전·로그인·기본 모델·모델 목록을 보인다
|
|
let r = await op.as('GET', '/ops/api/ai/cli'); eq(r.status, 200, JSON.stringify(r.json)); const by = Object.fromEntries(r.json.clis.map((c) => [c.tool, c]));
|
|
ok(by.claude && by.claude.bin === join(bin, 'claude') && /2\.1\.312/.test(by.claude.version) && by.claude.loggedIn === false && /claude auth login/.test(by.claude.loginHint), JSON.stringify(by.claude));
|
|
ok(by.codex && by.codex.bin === join(bin, 'codex') && /0\.162\.1/.test(by.codex.version) && by.codex.loggedIn === true && by.codex.defaultModel === 'gpt-6.1-sol' && by.codex.models.includes('gpt-6-luna') && !by.codex.models.includes('gpt-hidden'), JSON.stringify(by.codex));
|
|
ok(Array.isArray(r.json.searched) && r.json.searched.some((d) => d === bin), '탐색한 위치 안내');
|
|
// codex 연결: bin 없이 tool 만 → 탐색된 실행 파일, 모델 기본값 = codex 기본 모델
|
|
r = await op.as('POST', '/ops/api/ai/connections', { name: '코덱스', provider: 'local-cli', authKind: 'login', cli: { tool: 'codex' } }); eq(r.status, 200, JSON.stringify(r.json)); const cx = r.json.connection; eq(cx.model, 'gpt-6.1-sol'); eq(cx.cli.tool, 'codex');
|
|
r = await op.as('POST', `/ops/api/ai/connections/${cx.id}/test`); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.connection.status, 'active'); ok(r.json.models.includes('gpt-6.1-sol') && r.json.cliVersion.includes('0.162.1') && r.json.loggedIn === true); eq(r.json.connection.cli.bin, join(bin, 'codex'));
|
|
const room = (await op.as('GET', '/ops/api/ai/rooms')).json.rooms.find((x) => x.connectionId === cx.id);
|
|
r = await op.as('POST', `/ops/api/ai/rooms/${room.id}/messages`, { text: '안녕' }); eq(r.status, 200); await h.d.ai.drain();
|
|
r = await op.as('GET', `/ops/api/ai/rooms/${room.id}/messages`); const last = r.json.messages.pop(); eq(last.status, 'done', last.error); eq(texts([last])[0].text, 'OK from codex'); eq(last.usage.inputTokens, 14013); eq(last.usage.outputTokens, 5);
|
|
const args = readFileSync(join(h.dataDir, 'codex-args.txt'), 'utf8'); ok(args.startsWith('exec --json') && args.includes('-s read-only') && args.includes('--skip-git-repo-check') && args.includes('--ephemeral') && args.includes('-m gpt-6.1-sol'), args);
|
|
// claude 연결(로그인 만료): 탐색은 되지만 확인은 cli_not_logged_in → reauth_required + 서버에서 claude login 안내. 대화도 실패로 표시(모델 답으로 꾸미지 않음)
|
|
r = await op.as('POST', '/ops/api/ai/connections', { name: '클로드', provider: 'local-cli', authKind: 'login', cli: { tool: 'claude' } }); eq(r.status, 200, JSON.stringify(r.json)); const cl = r.json.connection; eq(cl.model, 'claude-opus-5-5');
|
|
r = await op.as('POST', `/ops/api/ai/connections/${cl.id}/test`); eq(r.status, 424); eq(r.json.error, 'cli_not_logged_in'); ok(/claude auth login/.test(r.json.message), r.json.message);
|
|
r = await op.as('GET', '/ops/api/ai/connections'); const c2 = r.json.connections.find((c) => c.id === cl.id); eq(c2.status, 'reauth_required'); ok(/claude auth login/.test(c2.lastError));
|
|
// 모르는 tool·상대 경로는 거부
|
|
r = await op.as('POST', '/ops/api/ai/connections', { name: 'x', provider: 'local-cli', authKind: 'login', cli: { tool: 'bash' } }); eq(r.status, 400);
|
|
// 아무것도 없는 HOME: 탐색 결과 비고 이유가 있다
|
|
process.env.HAL_CLI_HOME = join(h.dataDir, 'empty-home'); mkdirSync(process.env.HAL_CLI_HOME, { recursive: true }); process.env.HAL_CLI_DIRS = join(h.dataDir, 'empty-home');
|
|
r = await op.as('GET', '/ops/api/ai/cli'); eq(r.json.clis.length, 0); ok(r.json.searched.length > 0);
|
|
r = await op.as('POST', '/ops/api/ai/connections', { name: '없음', provider: 'local-cli', authKind: 'login', cli: { tool: 'codex' } }); eq(r.status, 200);
|
|
r = await op.as('POST', `/ops/api/ai/connections/${r.json.connection.id}/test`); eq(r.status, 424); eq(r.json.error, 'cli_unavailable'); ok(/탐색/.test(r.json.message) || /찾지/.test(r.json.message), r.json.message);
|
|
} finally { if (prevHome === undefined) delete process.env.HAL_CLI_HOME; else process.env.HAL_CLI_HOME = prevHome; if (prevDirs === undefined) delete process.env.HAL_CLI_DIRS; else process.env.HAL_CLI_DIRS = prevDirs; }
|
|
});
|
|
|
|
await test('화면·버전·문서: 공통 틀에 AI 패널(통합 탭 고정)·AI 연동 메뉴·연동 화면, 패키지 버전 0.3.0, 스킬 문서(상태·로그 의미) 제공', async () => {
|
|
const h = makeHal(); const op = await loginOp(h);
|
|
let r = await op.as('GET', '/ops/'); eq(r.status, 200); ok(r.text.includes('id="ai-panel"') && r.text.includes('/ops/ui/ai.js'), 'AI 패널·스크립트'); ok(r.text.includes('data-act="ai-toggle"'), '패널 열고 닫는 단추'); ok(r.text.includes('AI 연동'), '메뉴');
|
|
r = await op.as('GET', '/ops/ai'); eq(r.status, 200); ok(r.text.includes('연결 추가') && r.text.includes('API 키') && r.text.includes('OAuth') && r.text.includes('로그인'), '세 가지 연결 방식'); ok(r.text.includes('data-page="ai"'));
|
|
r = await invoke(h.app, 'GET', '/ops/ai', { cookies: { aw_session: 'customer' } }); ok(r.text.includes('운영자 로그인') && !r.text.includes('연결 추가'), '고객 세션은 로그인 화면만');
|
|
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.9'); eq(h.d.version, '0.3.9');
|
|
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 도구 문서');
|
|
});
|
|
|
|
await test('MCP(stdio) 서버: initialize·tools/list·읽기 도구 call 은 결과를, 쓰기 도구 call 은 운영자 승인 안내를 돌려준다(셸·임의 명령 없음)', async () => {
|
|
const dataDir = join(tmpDir(), 'hal-mcp'); mkdirSync(dataDir, { recursive: true });
|
|
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); };
|
|
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.9');
|
|
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);
|
|
});
|
|
done();
|