HAL 0.3.1: 서버에 설치된 CLI 자동 탐색·연결 가능화 — HAL 서비스 PATH 밖(~/.local/bin 등)의 Claude Code·Codex CLI 를 찾아 버전·로그인 상태·기본 모델을 AI 연동 화면에 보이고(GET /ops/api/ai/cli, "이 CLI 로 연결 추가"), Codex exec --json 지원, 로그인 안 됨은 cli_not_logged_in → reauth_required + 서버 로그인 안내, 확인 실패 응답 424(원인 표시), 점검 결과·개발 지시 v0.3.1(Oz)·docs/HAL-AI.md·VERSION_HISTORY 0.3.1 — test/hal-ai.test.mjs RED → GREEN 12/12, 전체 26파일 183건 통과
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+39
-4
@@ -248,7 +248,7 @@ await test('OpenAI 호환 공급자: chat/completions 형식·Bearer 키·functi
|
||||
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, 502); eq(r.json.error, 'cli_unavailable'); r = await op.as('GET', '/ops/api/ai/connections'); eq(r.json.connections[0].status, 'error'); ok(/실행 파일/.test(r.json.connections[0].lastError));
|
||||
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);
|
||||
@@ -259,6 +259,41 @@ await test('로그인(로컬 CLI 세션) 연결: 실행 파일이 없으면 사
|
||||
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 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 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 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 연동'), '메뉴');
|
||||
@@ -267,9 +302,9 @@ 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.0'); eq(h.d.version, '0.3.0');
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.1'); eq(h.d.version, '0.3.1');
|
||||
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, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.0'));
|
||||
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 도구 문서');
|
||||
});
|
||||
|
||||
@@ -280,7 +315,7 @@ 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.0');
|
||||
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: 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.0(HAL AI 채팅룸, 도입 모듈 변경 없음) 이다', () => {
|
||||
await test('도입 모듈에 직접 편집 순수 모듈·런타임·화면 파일이 있고 Webmaker 1.8.0 · Suite 0.3.1(HAL AI 채팅룸·CLI 자동 탐색, 도입 모듈 변경 없음) 이다', () => {
|
||||
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.0');
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.1');
|
||||
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