HAL 0.4.0: 통합 설정 관제 — Webmaker·AriMail·Agent 서버 단위 설정을 HAL 이 관리(설정 목록·범위 global/server·비밀값 Vault·유효값 hash, /ops/api/settings·재인증, settings.apply → settings.apply.target → Agent module.configure(HMAC 설정 받기·hash 확인·hal-managed.json 0600·재시작·모듈 적용 확인), /ops/settings 화면·운영 현황 "설정 적용 필요", AI 도구 hal_settings_get/set/apply(비밀값 거부)), 도입 모듈 Webmaker 1.9.0·AriMail 1.1.0(HAL 0.4.0 대응 halCompat, hal-managed 적용·관리 항목 409 managed_by_hal 잠금·"HAL 관리" 표시) 재도입(pin 8f8716c·f959b65, import-sources 미수정 파일 갱신), Agent heartbeat 관리, module-as-user write --mode 600, 개발 지시 v0.4.0(Oz)·docs/HAL-SETTINGS.md·VERSION_HISTORY 0.4.0 — test/hal-040.test.mjs RED → GREEN 5/5, agent 11/11, 전체 31파일 통과
2026-10-29 06:46:45 +00:00
/**
* test/hal-040.test.mjs — Ariweb Suite 0.4.0(개발 지시 `Ariweb-Suitle_Dev-Order_v0.4.0.md`, Oz 2026-10-29): 통합 설정 관제.
*
* 계약(요약)
* 1) 설정 목록(catalog): 모듈별(webmaker·arimail·agent) 관리 항목 묶음. Webmaker 는 API 키·인증 / 마진·가격 / 모델 / 전체 적용 서비스 / 서버별 설정. AriMail 은 서버 단위 항목(도메인별 LIMIT_* 제외). Agent 는 heartbeatMs.
* 2) 저장소: scope global | server:<id>, 비밀값은 Vault(`managed:<scope>:<module>:<key>`)에만. 유효값 = 전체 ← 서버 덮어쓰기. 형식·범위·알 수 없는 항목은 bad_setting.
* 3) 운영자 API: 조회(비밀값은 있음/없음만)·저장(재인증·CSRF)·삭제·적용(대상 비우면 배치 서버 전체, 없으면 no_targets). 감사 기록에 값 없음.
* 4) 적용 작업 settings.apply → settings.apply.target → Agent module.configure: Agent 는 자기 서버 범위의 설정만 받고(HMAC), 결과 hash 가 다르면 실패·상태 failed.
* 5) 화면·AI 도구·버전·문서·모듈 대응(Webmaker 1.9.0 · AriMail 1.1.0, halCompat 0.4.0).
*/
import { test , eq , ok , tmpDir , done , sleep } from '../lib/test.mjs' ;
import { invoke } from '../lib/http.mjs' ;
import { signHeaders } from '../lib/serviceauth.mjs' ;
import { listAudit } from '../lib/audit.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 { SETTINGS_CATALOG , catalogFor , keyDef , MANAGED_MODULES } from '../hal/settings/catalog.mjs' ;
import * as S from '../hal/settings/index.mjs' ;
import { MIGRATIONS } from '../hal/schema.mjs' ;
import { TOOLS , toolByName , runTool , SKILL_DOC } from '../hal/ai/tools.mjs' ;
import * as R from '../hal/ui/render.mjs' ;
import { KINDS as AGENT_KINDS } from '../agent/jobs/index.mjs' ;
import { mkdirSync , readFileSync , existsSync } from 'node:fs' ;
import { join , dirname } from 'node:path' ;
import { fileURLToPath } from 'node:url' ;
import { randomBytes } from 'node:crypto' ;
const ROOT = join ( dirname ( fileURLToPath ( import . meta . url )), '..' );
const rnd = ( p = 'fx' ) => ` ${ p } - ${ randomBytes ( 12 ). toString ( 'hex' ) } ` ;
function makeHal () {
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 : 4000 , fanoutTimeoutMs : 6000 , values : {}, set ( p ) { Object . assign ( this , p ); } };
const d = createHalDeps ({ config , dbFile : ':memory:' , fetchImpl : async () => ({ ok : true , status : 200 , text : async () => 'ok' , json : async () => ({ ok : true }) }) }); d . jobsRunning = true ;
return { d , app : buildApp ( d ), dataDir };
}
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 ); return { pw , cookie : r . cookies . aw_ops , csrf : r . json . csrf , as : ( m , p , b ) => invoke ( h . app , m , p , { body : b , cookies : { aw_ops : r . cookies . aw_ops }, headers : { 'x-csrf' : r . json . csrf } }) }; }
async function addServer ( h , { name , role }) {
const s = core . createServer ( h . d , { name , role }); const t = core . issueRegistrationToken ( h . d , { kind : 'managed' , serverId : s . id });
const r = await invoke ( h . app , 'POST' , '/agent/v1/register' , { body : { token : t . token , facts : { hostname : name , nginx : true , units : { webmaker : 'webmaker' }, appDirs : { webmaker : '/srv/webmaker' } }, version : '0.3.17' } }); ok ( r . json . ok , JSON . stringify ( r . json ));
h . d . store . run ( 'UPDATE servers SET last_seen_at=?, capacity_json=? WHERE id=?' , new Date (). toISOString (), JSON . stringify ({ diskFreeBytes : 50e9 , diskTotalBytes : 100e9 , sitesHosted : 0 , reservedBytes : 0 }), s . id ); core . approveServer ( h . d , s . id , 'op' );
return { serverId : s . id , agentId : r . json . agentId , secret : r . json . secret };
}
function bootstrapDone ( d , version ) { core . claimLive ( d , { instanceId : d . instanceId , version }); core . setBootstrap ( d , { release_version : version , main_commit : 'abc' , artifact_sha256 : 'x' , first_agent_id : d . store . get ( 'SELECT id FROM agents LIMIT 1' ) ? . id || 'ag_x' , first_agent_registered_at : new Date (). toISOString (), completed_at : new Date (). toISOString () }); }
const placeModule = ( h , module , serverId , version = '0.3.17' ) => h . d . store . run ( 'INSERT INTO module_targets(module, server_id, target_version, actual_version, health, active, last_check_at, updated_at) VALUES(?,?,?,?,?,?,?,?)' , module , serverId , version , version , 'ok' , 1 , new Date (). toISOString (), new Date (). toISOString ());
const agentGet = ( h , a , path ) => invoke ( h . app , 'GET' , path , { headers : Object . fromEntries ( Object . entries ( signHeaders ( `agent: ${ a . agentId } ` , a . secret , 'GET' , path , '' )). map (([ k , v ]) => [ k . toLowerCase (), v ])) });
/** Agent 흉내: 작업을 받아 결과를 낸다 */
async function runAgents ( h , agents , result , maxMs = 8000 ) {
const t0 = Date . now (); const ps = []; const seen = [];
while ( Date . now () - t0 < maxMs ) { ps . push ( h . d . jobs . tick (). catch (() => {})); for ( const a of agents ) { const j = core . nextAgentJob ( h . d , a . agentId ); if ( j ) { seen . push ( j ); core . agentJobResult ( h . d , j . id , a . agentId , await result ( j , a )); } } if ( ! h . d . jobs . list ({ status : [ 'queued' , 'running' ], limit : 5 }). length ) { await sleep ( 30 ); if ( ! h . d . jobs . list ({ status : [ 'queued' , 'running' ], limit : 5 }). length ) break ; } await sleep ( 10 ); }
await Promise . all ( ps ); return seen ;
}
await test ( '1·2) 설정 목록·저장소: 묶음·형식, 전체/서버 범위, 비밀값은 Vault 에만, 유효값 합성·hash, 형식·범위·알 수 없는 항목 거부' , async () => {
eq ( MANAGED_MODULES , [ 'webmaker' , 'arimail' , 'agent' ]);
const wm = catalogFor ( 'webmaker' ); eq ( wm . groups . map (( g ) => g . id ), [ 'auth' , 'pricing' , 'models' , 'service' , 'server' ]);
const has = ( m , k ) => !! keyDef ( m , k ); for ( const k of [ 'auth.mode' , 'auth.apiKey' , 'serviceApiKey' , 'marginPct' , 'fx.mode' , 'tiers.basic' , 'internalRef' , 'ai.maxConcurrentJobs' , 'service.allowSignup' , 'service.publicBaseUrl' , 'source.token' , 'mail.adminPassword' , 'endpoints.resendApiBase' ]) ok ( has ( 'webmaker' , k ), 'webmaker ' + k );
eq ( keyDef ( 'webmaker' , 'marginPct' ). type , 'number' ); eq ( keyDef ( 'webmaker' , 'auth.apiKey' ). type , 'secret' ); eq ( keyDef ( 'webmaker' , 'service.publicBaseUrl' ). scope , 'server' ); ok ( keyDef ( 'webmaker' , 'ai.claudeBin' ). restart === true , '재시작 필요 표시' );
const am = catalogFor ( 'arimail' ); for ( const k of [ 'inbound' , 'outbound' , 'RESEND_API_KEY' , 'DNS_PROVIDER' , 'SMTP_RELAY_HOST' , 'HARAKA_HOSTNAME' , 'MAIL_FROM_NAME' , 'USAGE_API_ALLOW' , 'ARIWEB_SSO_SECRET' ]) ok ( has ( 'arimail' , k ), 'arimail ' + k );
ok ( ! am . groups . some (( g ) => g . keys . some (( k ) => /^ LIMIT_ / . test ( k . key ))), '도메인별 LIMIT_* 는 HAL 이 다루지 않는다' ); eq ( keyDef ( 'arimail' , 'RESEND_API_KEY' ). type , 'secret' ); eq ( keyDef ( 'arimail' , 'inbound' ). options . includes ( 'haraka' ), true );
ok ( has ( 'agent' , 'heartbeatMs' ) && keyDef ( 'agent' , 'heartbeatMs' ). type === 'int' );
ok ( MIGRATIONS . some (( m ) => m . id === 'hal_0010_managed_settings' && /managed_settings_state/ . test ( m . sql )));
const h = makeHal (); const sec = rnd ( 'sk' ); const srv = core . createServer ( h . d , { name : '웹1' , role : 'web' });
const r1 = S . setSettings ( h . d , { module : 'webmaker' , scope : 'global' , values : { marginPct : '35' , 'auth.mode' : 'api-key' , 'auth.apiKey' : sec , 'service.allowSignup' : 'false' }, by : 'op' });
eq ( r1 . changed . sort (), [ 'auth.apiKey' , 'auth.mode' , 'marginPct' , 'service.allowSignup' ]);
const rows = h . d . store . all ( 'SELECT * FROM managed_settings' ); ok ( ! JSON . stringify ( rows ). includes ( sec ), '행에 비밀값 없음' ); eq ( h . d . vault . get ( 'managed:global:webmaker:auth.apiKey' ), sec );
const g = S . getSettings ( h . d , 'webmaker' , 'global' ); eq ( g . values . marginPct , 35 ); eq ( g . values [ 'service.allowSignup' ], false ); eq ( g . values [ 'auth.mode' ], 'api-key' ); eq ( g . secrets [ 'auth.apiKey' ], true ); ok ( ! ( 'auth.apiKey' in g . values ) && ! JSON . stringify ( g ). includes ( sec ));
S . setSettings ( h . d , { module : 'webmaker' , scope : `server: ${ srv . id } ` , values : { marginPct : 40 , 'service.publicBaseUrl' : 'https://wm.example.test' }, by : 'op' });
const e = S . effectiveSettings ( h . d , 'webmaker' , srv . id ); eq ( e . values . marginPct , 40 ); eq ( e . sources . marginPct , 'server' ); eq ( e . values [ 'auth.mode' ], 'api-key' ); eq ( e . sources [ 'auth.mode' ], 'global' ); eq ( e . values [ 'service.publicBaseUrl' ], 'https://wm.example.test' ); ok ( /^sha256:[0-9a-f]{64}$/ . test ( e . hash )); ok ( ! JSON . stringify ( e ). includes ( sec ));
const es = S . effectiveSettings ( h . d , 'webmaker' , srv . id , { includeSecrets : true }); eq ( es . values [ 'auth.apiKey' ], sec ); eq ( es . hash , e . hash , '비밀값 포함 여부와 무관하게 같은 hash(hash 는 비밀값을 포함해 계산)' );
const other = core . createServer ( h . d , { name : '웹2' , role : 'web' }); const e2 = S . effectiveSettings ( h . d , 'webmaker' , other . id ); eq ( e2 . values . marginPct , 35 ); ok ( e2 . hash !== e . hash );
for ( const [ vals , why ] of [[{ marginPct : 'abc' }, '숫자 아님' ], [{ marginPct : 5000 }, '범위 초과' ], [{ nope : 1 }, '알 수 없는 항목' ], [{ 'auth.mode' : 'magic' }, 'enum 밖' ], [{ 'ai.maxConcurrentJobs' : 1.5 }, '정수 아님' ]]) { let code = null ; try { S . setSettings ( h . d , { module : 'webmaker' , scope : 'global' , values : vals , by : 'op' }); } catch ( x ) { code = x . code ; } eq ( code , 'bad_setting' , why ); }
let code = null ; try { S . setSettings ( h . d , { module : 'webmaker' , scope : 'global' , values : { 'service.publicBaseUrl' : 'https://x' }, by : 'op' }); } catch ( x ) { code = x . code ; } eq ( code , 'bad_setting' , '서버별 항목은 전체 범위에 둘 수 없다' );
code = null ; try { S . setSettings ( h . d , { module : 'nope' , scope : 'global' , values : {}, by : 'op' }); } catch ( x ) { code = x . code ; } eq ( code , 'unknown_module' );
S . deleteSetting ( h . d , { module : 'webmaker' , scope : 'global' , key : 'auth.apiKey' , by : 'op' }); eq ( h . d . vault . has ( 'managed:global:webmaker:auth.apiKey' ), false ); eq ( S . getSettings ( h . d , 'webmaker' , 'global' ). secrets [ 'auth.apiKey' ], false );
S . setSettings ( h . d , { module : 'webmaker' , scope : 'global' , values : { 'auth.apiKey' : '' }, by : 'op' }); eq ( h . d . vault . has ( 'managed:global:webmaker:auth.apiKey' ), false , '빈 값은 비밀값 삭제' );
S . setSettings ( h . d , { module : 'arimail' , scope : 'global' , values : { inbound : 'haraka' , SMTP_RELAY_PORT : 2525 , MAIL_FROM_NAME : '아리메일' }, by : 'op' }); eq ( S . getSettings ( h . d , 'arimail' , 'global' ). values . SMTP_RELAY_PORT , 2525 );
S . setSettings ( h . d , { module : 'agent' , scope : 'global' , values : { heartbeatMs : 45000 }, by : 'op' }); eq ( S . effectiveSettings ( h . d , 'agent' , srv . id ). values . heartbeatMs , 45000 );
});
await test ( '3) 운영자 API: 목록·조회(비밀값 없음)·저장(CSRF·재인증)·삭제·적용 대상, 감사 기록에 값 없음' , async () => {
const h = makeHal (); const op = await loginOp ( h ); const sec = rnd ( 'sk' );
let r = await op . as ( 'GET' , '/ops/api/settings' ); eq ( r . status , 200 ); eq ( r . json . modules , [ 'webmaker' , 'arimail' , 'agent' ]); ok ( r . json . catalog . webmaker . groups . length === 5 && r . json . catalog . arimail . groups . length >= 3 , JSON . stringify ( Object . keys ( r . json . catalog )));
r = await invoke ( h . app , 'PUT' , '/ops/api/settings/webmaker' , { body : { scope : 'global' , values : { marginPct : 35 } }, cookies : { aw_ops : op . cookie } }); eq ( r . status , 403 ); eq ( r . json . error , 'csrf' );
r = await op . as ( 'PUT' , '/ops/api/settings/webmaker' , { scope : 'global' , values : { marginPct : 35 , 'auth.apiKey' : sec , 'tiers.premium' : 'anthropic:claude-opus-5-5' } }); eq ( r . status , 200 , JSON . stringify ( r . json )); eq ( r . json . changed . sort (), [ 'auth.apiKey' , 'marginPct' , 'tiers.premium' ]); ok ( ! r . text . includes ( sec ));
r = await op . as ( 'GET' , '/ops/api/settings/webmaker?scope=global' ); eq ( r . status , 200 ); eq ( r . json . values . marginPct , 35 ); eq ( r . json . secrets [ 'auth.apiKey' ], true ); ok ( ! r . text . includes ( sec ), '조회 응답에 비밀값 없음' ); ok ( r . json . catalog && r . json . catalog . groups , '응답에 목록' );
r = await op . as ( 'PUT' , '/ops/api/settings/webmaker' , { scope : 'global' , values : { marginPct : 'abc' } }); eq ( r . status , 400 ); eq ( r . json . error , 'bad_setting' );
r = await op . as ( 'PUT' , '/ops/api/settings/nope' , { scope : 'global' , values : {} }); eq ( r . status , 404 ); eq ( r . json . error , 'unknown_module' );
// 재인증 만료 → 저장·적용 거부(reauth_required), 조회는 허용
h . d . store . run ( 'UPDATE sessions SET reauth_at=?' , new Date ( Date . now () - 11 * 60000 ). toISOString ());
r = await op . as ( 'PUT' , '/ops/api/settings/webmaker' , { scope : 'global' , values : { marginPct : 36 } }); eq ( r . status , 403 ); eq ( r . json . error , 'reauth_required' );
r = await op . as ( 'POST' , '/ops/api/settings/webmaker/apply' , { serverIds : [] }); eq ( r . status , 403 ); eq ( r . json . error , 'reauth_required' );
r = await op . as ( 'GET' , '/ops/api/settings/webmaker?scope=global' ); eq ( r . status , 200 ); eq ( r . json . values . marginPct , 35 );
r = await op . as ( 'POST' , '/ops/api/reauth' , { password : op . pw }); eq ( r . status , 200 , JSON . stringify ( r . json ));
r = await op . as ( 'DELETE' , '/ops/api/settings/webmaker/auth.apiKey?scope=global' ); eq ( r . status , 200 ); eq ( h . d . vault . has ( 'managed:global:webmaker:auth.apiKey' ), false );
r = await op . as ( 'POST' , '/ops/api/settings/webmaker/apply' , { serverIds : [] }); eq ( r . status , 400 ); eq ( r . json . error , 'no_targets' , '배치된 서버가 없으면 즉시 no_targets' );
const au = listAudit ( h . d . store , { action : 'settings.set' }); ok ( au . length >= 1 && ! JSON . stringify ( au ). includes ( sec ), '감사 기록에 비밀값 없음' ); ok ( JSON . stringify ( au ). includes ( 'auth.apiKey' ), '키 이름은 남긴다' );
});
await test ( '4) 적용 작업: settings.apply → 서버별 → Agent module.configure(자기 범위만, HMAC) → 상태 ok / hash 불일치 failed / 변경 뒤 upToDate=false' , async () => {
const h = makeHal (); const op = await loginOp ( h ); const web = await addServer ( h , { name : '웹1' , role : 'web' }); const web2 = await addServer ( h , { name : '웹2' , role : 'web' }); const mail = await addServer ( h , { name : '메일1' , role : 'mail' }); bootstrapDone ( h . d , '0.4.0' );
placeModule ( h , 'webmaker' , web . serverId ); placeModule ( h , 'webmaker' , web2 . serverId ); placeModule ( h , 'arimail' , mail . serverId );
const sec = rnd ( 'sk' ); await op . as ( 'PUT' , '/ops/api/settings/webmaker' , { scope : 'global' , values : { marginPct : 35 , 'auth.apiKey' : sec } }); await op . as ( 'PUT' , '/ops/api/settings/webmaker' , { scope : `server: ${ web . serverId } ` , values : { marginPct : 40 , 'service.publicBaseUrl' : 'https://wm1.example.test' } });
// Agent 설정 API: 자기 서버 범위, 비밀값 포함, 다른 서버의 덮어쓰기는 보이지 않음, 서명 없으면 거부
let r = await agentGet ( h , web , '/agent/v1/settings/webmaker' ); eq ( r . status , 200 , JSON . stringify ( r . json )); eq ( r . json . settings . module , 'webmaker' ); eq ( r . json . settings . serverId , web . serverId ); eq ( r . json . settings . values . marginPct , 40 ); eq ( r . json . settings . values [ 'auth.apiKey' ], sec ); eq ( r . json . settings . values [ 'service.publicBaseUrl' ], 'https://wm1.example.test' ); ok ( /^sha256:[0-9a-f]{64}$/ . test ( r . json . settings . hash ));
const hash1 = r . json . settings . hash ;
r = await agentGet ( h , web2 , '/agent/v1/settings/webmaker' ); eq ( r . json . settings . values . marginPct , 35 ); ok ( ! ( 'service.publicBaseUrl' in r . json . settings . values )); ok ( r . json . settings . hash !== hash1 );
r = await invoke ( h . app , 'GET' , '/agent/v1/settings/webmaker' ); eq ( r . status , 401 );
r = await agentGet ( h , web , '/agent/v1/settings/nope' ); eq ( r . status , 404 );
// 적용(대상 비움 → webmaker 가 배치된 서버 전체)
r = await op . as ( 'POST' , '/ops/api/settings/webmaker/apply' , { serverIds : [], restart : true }); eq ( r . status , 200 , JSON . stringify ( r . json )); eq ( r . json . targets . sort (), [ web . serverId , web2 . serverId ]. sort ()); eq ( r . json . job . kind , 'settings.apply' ); const jobId = r . json . job . id ;
const seen = await runAgents ( h , [ web , web2 , mail ], async ( j , a ) => { eq ( j . kind , 'module.configure' ); eq ( j . payload . module , 'webmaker' ); eq ( j . payload . settingsUrl , '/agent/v1/settings/webmaker' ); eq ( j . payload . unit , 'webmaker' ); eq ( j . payload . configDir , '/data/webmaker' ); eq ( j . payload . restart , true ); ok ( j . payload . hash && ! JSON . stringify ( j . payload ). includes ( sec ), '작업 payload 에 비밀값 없음(Agent 가 직접 받는다)' );
const s = await agentGet ( h , a , j . payload . settingsUrl ); return { status : 'done' , result : { module : 'webmaker' , hash : s . json . settings . hash , file : '/data/webmaker/hal-managed.json' , restarted : true , health : { ok : true } } }; });
eq ( seen . length , 2 ); const job = h . d . jobs . get ( jobId ); eq ( job . status , 'done' , JSON . stringify ( h . d . jobs . list ({ kind : 'settings.apply.target' , limit : 5 }). map (( c ) => [ c . status , c . error , c . error_code ])));
const st = h . d . store . all ( 'SELECT server_id, status, applied_hash FROM managed_settings_state WHERE module=? ORDER BY server_id' , 'webmaker' ); eq ( st . length , 2 ); ok ( st . every (( x ) => x . status === 'ok' )); eq ( st . find (( x ) => x . server_id === web . serverId ). applied_hash , hash1 );
r = await op . as ( 'GET' , `/ops/api/settings/webmaker?scope=server: ${ web . serverId } ` ); eq ( r . json . state . status , 'ok' ); eq ( r . json . state . upToDate , true ); eq ( r . json . effective . values . marginPct , 40 ); ok ( ! r . text . includes ( sec ));
// 설정이 바뀌면 적용 상태는 최신이 아니다 → 운영 현황·목록에 보인다
await op . as ( 'PUT' , '/ops/api/settings/webmaker' , { scope : 'global' , values : { 'fx.spreadPct' : 2 } }); // 웹1 은 marginPct 를 서버에서 덮어쓰므로 다른 전체 항목을 바꾼다
r = await op . as ( 'GET' , `/ops/api/settings/webmaker?scope=server: ${ web . serverId } ` ); eq ( r . json . state . upToDate , false );
r = await op . as ( 'GET' , '/ops/api/settings' ); ok ( r . json . overview . webmaker && r . json . overview . webmaker . servers . length === 2 && r . json . overview . webmaker . stale === 2 , JSON . stringify ( r . json . overview . webmaker ));
r = await op . as ( 'GET' , '/ops/api/overview' ); eq ( r . json . settings . stale , 2 );
// hash 불일치 → 대상 작업 실패, 상태 failed, 부모 partial/실패
r = await op . as ( 'POST' , '/ops/api/settings/webmaker/apply' , { serverIds : [ web . serverId ], nonce : '2' }); eq ( r . status , 200 , JSON . stringify ( r . json )); const j2 = r . json . job . id ;
await runAgents ( h , [ web ], async () => ({ status : 'done' , result : { module : 'webmaker' , hash : 'deadbeef' , file : 'x' , restarted : false , health : { ok : true } } }));
const job2 = h . d . jobs . get ( j2 ); ok ( job2 . status === 'failed' , job2 . status ); eq ( h . d . store . get ( 'SELECT status FROM managed_settings_state WHERE module=? AND server_id=?' , 'webmaker' , web . serverId ). status , 'failed' );
const child = h . d . jobs . list ({ kind : 'settings.apply.target' , limit : 10 }). find (( x ) => x . parent_id === j2 ); eq ( child . error_code || child . errorCode , 'settings_hash_mismatch' );
// 대상 지정: arimail 을 웹 서버에 적용하려 하면 거부
r = await op . as ( 'POST' , '/ops/api/settings/arimail/apply' , { serverIds : [ web . serverId ], nonce : '3' }); eq ( r . status , 400 ); eq ( r . json . error , 'not_placed' );
});
await test ( '5) 화면·AI 도구·정적 계약' , async () => {
const h = makeHal (); const op = await loginOp ( h );
let r = await op . as ( 'GET' , '/ops/settings' ); eq ( r . status , 200 ); ok ( r . text . includes ( '통합 설정 관제' ) && r . text . includes ( 'data-page="settings"' ) && r . text . includes ( 'href="/ops/settings"' ), '화면·메뉴' );
const sec = rnd ( 'sk' ); const html = R . settingsFormHtml ( SETTINGS_CATALOG . webmaker , { scope : 'global' , values : { marginPct : 35 , 'auth.mode' : 'api-key' }, secrets : { 'auth.apiKey' : true , 'auth.oauthToken' : false } });
ok ( html . includes ( 'name="marginPct"' ) && html . includes ( 'value="35"' ), '값' ); ok ( /name="auth\.apiKey"[^>]*type="password"|type="password"[^>]*name="auth\.apiKey"/ . test ( html ), '비밀값은 비밀번호 입력' ); ok ( html . includes ( '설정됨' ) && html . includes ( '없음' ), '비밀값 있음/없음' ); ok ( ! html . includes ( sec ));
ok ( ! html . includes ( 'name="service.publicBaseUrl"' ), '전체 범위에는 서버별 항목이 없다' ); ok ( html . includes ( 'selected' ) && html . includes ( 'api-key' ), 'enum 선택' ); ok ( html . includes ( 'data-act="settings-save"' ));
const html2 = R . settingsFormHtml ( SETTINGS_CATALOG . webmaker , { scope : 'server:srv_1' , values : {}, secrets : {}, effective : { values : { marginPct : 35 }, sources : { marginPct : 'global' } } }); ok ( html2 . includes ( 'name="service.publicBaseUrl"' ) && html2 . includes ( '전체 값 35' ), '서버 범위: 서버별 항목·전체 값 안내' );
const stateHtml = R . settingsStateHtml ([{ serverId : 'srv_1' , serverName : '웹1' , status : 'ok' , upToDate : false , appliedAt : '2026-10-29T00:00:00Z' , appliedHash : 'abc' , error : null }]); ok ( stateHtml . includes ( '웹1' ) && stateHtml . includes ( '적용 필요' ) && stateHtml . includes ( 'data-act="settings-apply"' ));
const js = readFileSync ( join ( ROOT , 'hal' , 'ui' , 'public' , 'ops.js' ), 'utf8' ); for ( const s of [ 'settings:' , "'settings-save'" , "'settings-apply'" , '/ops/api/settings' ]) ok ( js . includes ( s ), 'ops.js ' + s );
// AI 도구
const names = TOOLS . map (( t ) => t . name ); for ( const n of [ 'hal_settings_get' , 'hal_settings_set' , 'hal_settings_apply' ]) ok ( names . includes ( n ), n );
eq ( toolByName ( 'hal_settings_get' ). risk , 'read' ); eq ( toolByName ( 'hal_settings_set' ). risk , 'high' ); eq ( toolByName ( 'hal_settings_apply' ). risk , 'high' );
S . setSettings ( h . d , { module : 'webmaker' , scope : 'global' , values : { marginPct : 35 , 'auth.apiKey' : sec }, by : 'op' });
const g = await runTool ( h . d , toolByName ( 'hal_settings_get' ), { module : 'webmaker' }, { operatorId : 'op' }); eq ( g . values . marginPct , 35 ); ok ( g . confidential . some (( x ) => x . name === 'auth.apiKey' && x . set === true ), JSON . stringify ( g . confidential )); ok ( ! JSON . stringify ( g ). includes ( sec ));
let code = null ; try { await runTool ( h . d , toolByName ( 'hal_settings_set' ), { module : 'webmaker' , scope : 'global' , values : { 'auth.apiKey' : 'x' } }, { operatorId : 'op' }); } catch ( x ) { code = x . code ; } eq ( code , 'secret_not_allowed' , 'AI 도구는 비밀값을 받지 않는다' );
const s2 = await runTool ( h . d , toolByName ( 'hal_settings_set' ), { module : 'webmaker' , scope : 'global' , values : { marginPct : 31 } }, { operatorId : 'op' }); eq ( s2 . changed , [ 'marginPct' ]); eq ( S . getSettings ( h . d , 'webmaker' , 'global' ). values . marginPct , 31 );
ok ( SKILL_DOC . includes ( 'hal_settings_get' ) && SKILL_DOC . includes ( 'hal_settings_apply' ));
ok ( AGENT_KINDS [ 'module.configure' ], 'Agent 작업 종류' );
});
await test ( '버전·문서·모듈 대응(0.4.0 · Webmaker 1.9.0 · AriMail 1.1.0)' , async () => {
2026-11-06 08:40:09 +00:00
const h = makeHal (); eq ( JSON . parse ( readFileSync ( join ( ROOT , 'package.json' ), 'utf8' )). version , '0.4.1' ); eq ( h . d . version , '0.4.1' );
HAL 0.4.0: 통합 설정 관제 — Webmaker·AriMail·Agent 서버 단위 설정을 HAL 이 관리(설정 목록·범위 global/server·비밀값 Vault·유효값 hash, /ops/api/settings·재인증, settings.apply → settings.apply.target → Agent module.configure(HMAC 설정 받기·hash 확인·hal-managed.json 0600·재시작·모듈 적용 확인), /ops/settings 화면·운영 현황 "설정 적용 필요", AI 도구 hal_settings_get/set/apply(비밀값 거부)), 도입 모듈 Webmaker 1.9.0·AriMail 1.1.0(HAL 0.4.0 대응 halCompat, hal-managed 적용·관리 항목 409 managed_by_hal 잠금·"HAL 관리" 표시) 재도입(pin 8f8716c·f959b65, import-sources 미수정 파일 갱신), Agent heartbeat 관리, module-as-user write --mode 600, 개발 지시 v0.4.0(Oz)·docs/HAL-SETTINGS.md·VERSION_HISTORY 0.4.0 — test/hal-040.test.mjs RED → GREEN 5/5, agent 11/11, 전체 31파일 통과
2026-10-29 06:46:45 +00:00
ok ( readFileSync ( join ( ROOT , 'Ariweb-Suitle_Dev-Order_v0.4.0.md' ), 'utf8' ). includes ( 'Oz' )); ok ( readFileSync ( join ( ROOT , 'VERSION_HISTORY.md' ), 'utf8' ). includes ( '## 0.4.0' )); ok ( readFileSync ( join ( ROOT , 'README.md' ), 'utf8' ). includes ( '0.4.0' ));
ok ( existsSync ( join ( ROOT , 'docs' , 'HAL-SETTINGS.md' )));
const wm = JSON . parse ( readFileSync ( join ( ROOT , 'modules' , 'webmaker' , 'package.json' ), 'utf8' )); eq ( wm . version , '1.9.0' ); eq ( wm . ariwebSuite && wm . ariwebSuite . halCompat , '0.4.0' );
const am = JSON . parse ( readFileSync ( join ( ROOT , 'modules' , 'arimail' , 'package.json' ), 'utf8' )); eq ( am . version , '1.1.0' ); eq ( am . ariwebSuite && am . ariwebSuite . halCompat , '0.4.0' );
ok ( existsSync ( join ( ROOT , 'modules' , 'webmaker' , 'lib' , 'hal-managed.js' )) && existsSync ( join ( ROOT , 'modules' , 'arimail' , 'server' , 'hal-managed.mjs' )));
ok ( readFileSync ( join ( ROOT , 'modules' , 'webmaker' , 'server.js' ), 'utf8' ). includes ( 'managed_by_hal' )); ok ( readFileSync ( join ( ROOT , 'modules' , 'arimail' , 'server' , 'setup.mjs' ), 'utf8' ). includes ( 'managed_by_hal' ));
ok ( readFileSync ( join ( ROOT , 'modules' , 'webmaker' , 'README.md' ), 'utf8' ). includes ( 'HAL 0.4.0 대응' ), 'Webmaker README(모듈 CHANGELOG 는 도입 제외 — 원본 저장소에 있음)' ); ok ( readFileSync ( join ( ROOT , 'IMPORTED_SOURCES.md' ), 'utf8' ). includes ( '1.1.0' ));
const imp = JSON . parse ( readFileSync ( join ( ROOT , 'IMPORTED_SOURCES.json' ), 'utf8' )); eq ( imp . sources . find (( s ) => s . name === 'webmaker' ). upstream . version , '1.9.0' ); eq ( imp . sources . find (( s ) => s . name === 'arimail' ). upstream . version , '1.1.0' );
});
done ();