HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
/**
* 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 ;
2026-10-29 01:11:32 +00:00
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 ));
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
// 가짜 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' ), '자격 전달 없음' );
});
2026-10-29 01:11:32 +00:00
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 ; }
});
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
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>' ));
2026-10-29 01:11:32 +00:00
eq ( JSON . parse ( readFileSync ( join ( ROOT , 'package.json' ), 'utf8' )). version , '0.3.1' ); eq ( h . d . version , '0.3.1' );
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
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' ), '상태·로그 의미 설명' );
2026-10-29 01:11:32 +00:00
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' ));
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
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 )));
2026-10-29 01:11:32 +00:00
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' );
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
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 );
});
done ();