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건 통과
2026-10-29 01:43:02 +00:00
/**
* 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 });
HAL 0.3.9: 로그인 터미널·텍스트 셸·모델 선택·슬래시 명령·점검 수정 — claude 실제 로그인 명령 auth login(대화형 세션·신뢰 설정도 허용, 작업 폴더 HOME), 텍스트 셸 범용화(순수 단말 에뮬레이터 term.mjs 로 TUI 렌더, 원시 키 전달·키 단추·붙여넣기·단말 크기), 유령 세션 한도 오류 수정(닫기=종료, 살아 있는 프로세스만 계산, 한도 3, 모두 종료·세션 목록), 모델 선택(확인된 목록·PATCH·/model), 슬래시 명령 20개(/help /status /order /deploy …, 원문 기록·확장 지시), 패널 로그인·재인증 단추, 개발 지시 v0.3.9(Oz)·docs/HAL-AI.md §8·VERSION_HISTORY 0.3.9 — test/hal-ai-039.test.mjs RED 0/6 → GREEN 6/6, 전체 28파일 195건 통과
2026-10-29 02:22:21 +00:00
// [0.3.9] 실제 명령은 `claude auth login`(`claude login` 은 대화형 세션) — 가짜 CLI 도 그 형식을 따른다
writeFileSync ( join ( bin , 'claude' ), `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "2.1.312 (Claude Code)"; exit 0; fi\nif [ "$1" = "auth" ] && [ "$2" = "status" ]; then echo '{"loggedIn": false}'; exit 0; fi\nif [ "$1" = "auth" ] && [ "$2" = "login" ]; then echo "Open https://login.example.test/device and paste the code:"; read code; echo "Logged in with code $code"; exit 0; fi\necho "unknown"; exit 2\n` ); chmodSync ( join ( bin , 'claude' ), 0o755 );
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건 통과
2026-10-29 01:43:02 +00:00
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>' ));
2026-11-06 08:40:09 +00:00
eq ( JSON . parse ( readFileSync ( join ( ROOT , 'package.json' ), 'utf8' )). version , '0.4.1' ); eq ( h . d . version , '0.4.1' );
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건 통과
2026-10-29 01:43:02 +00:00
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 ();