2026-10-26 11:58:15 +00:00
/**
* hal/index.mjs — HAL 프로세스 진입점과 CLI.
*
* node hal/index.mjs 서버 실행(기본). 기동 때 "하나의 HAL" 임대를 잡고(§34) 실행 버전을 기록한다(§32).
* node hal/index.mjs setup-token 최초 운영자 계정을 만들 설정 토큰을 발급(1회 표시, data/setup.token). 부트스트랩 스크립트가 부른다.
* node hal/index.mjs grant --login <id> [--hours 8] 허용 네트워크 밖 기기용 접근권 URL 발급(§25)
* node hal/index.mjs register-release --manifest <dist/<ver>/manifest.json> [--approve] 릴리스 등록(부트스트랩·CI)
* node hal/index.mjs link-portal 포털 연동 서비스 키 생성(service:portal / service:hal) — 포털 보관소에도 같은 값을 넣는다(출력 1회)
* node hal/index.mjs bootstrap-release --version <v> --commit <sha> --sha256 <artifact sha> 부트스트랩 때 선택한 릴리스 정보를 기록
2026-10-27 05:25:05 +00:00
* node hal/index.mjs server-token --server <srv_id> [--by <who>] 관리 서버의 Node Agent 일회용 등록 토큰(10분, 1회 표시) — 운영자 콘솔이 닿지 않는 부트스트랩 단계용(시스템 신원, 감사 기록)
* node hal/index.mjs mail-import --discovery <json> (--server <srv_id> | --create-server --name <n> [--location <l>]) [--apply] [--by <who>]
* 기존 AriMail 인스턴스를 HAL 관리형 메일 서비스로 가져오기(기본 dry-run, hal/mailsvc.mjs)
* node hal/index.mjs mail-import-rollback --import <mimp_id> [--by <who>] 적용된 가져오기 되돌리기
* node hal/index.mjs mail-services HAL 관리형 메일 서비스 readback(JSON, 비밀 없음)
2026-10-27 09:27:23 +00:00
* node hal/index.mjs config-set [--publicUrl <u>] [--portalUrl <u>] [--offlineAfterMs <n>] [--metricsRetentionDays <n>] [--by <who>] [--reason <text>]
* 운영 값(data/config.json) 기록 — 비밀값 거부, 감사 기록. 서비스 프로세스는 기동 때 읽으므로 재시작해야 반영된다
* node hal/index.mjs module-deploy --module <m> --version <v> --servers <srv_id[,srv_id]> [--by <who>] [--reason <text>] [--nonce <n>]
* 모듈 배포 작업(module.deploy)을 운영자 API 와 같은 멱등 규칙으로 큐에 넣는다(시스템 신원, 감사 기록). 실행은 serve 프로세스의 작업 엔진이 맡는다
* node hal/index.mjs job --id <j_id> 작업·자식 작업 readback(JSON)
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
* node hal/index.mjs mcp HAL 도구를 MCP(stdio) 서버로 내보낸다(0.3.0, 읽기 도구만 실행 — hal/ai/mcp.mjs)
2026-10-26 11:58:15 +00:00
*/
import { loadConfig } from './config.mjs' ;
import { createHalDeps , VERSION } from './deps.mjs' ;
import { buildApp } from './app.mjs' ;
import * as core from './core.mjs' ;
import * as ops from './ops.mjs' ;
import * as emergency from './emergency.mjs' ;
import * as dr from './dr.mjs' ;
2026-10-27 05:25:05 +00:00
import * as mailsvc from './mailsvc.mjs' ;
2026-10-26 11:58:15 +00:00
import { purgeSessions } from '../lib/session.mjs' ;
2026-10-27 09:27:23 +00:00
import { audit } from '../lib/audit.mjs' ;
import { describe } from '../lib/jobs.mjs' ;
import { descriptor } from './modules/index.mjs' ;
2026-10-26 11:58:15 +00:00
import { randomToken } from '../lib/crypto.mjs' ;
import { writeFileSync , readFileSync , copyFileSync , mkdirSync , existsSync } from 'node:fs' ;
import { join , dirname , basename } from 'node:path' ;
const log = (... a ) => console . log ( new Date (). toISOString (), ... a );
const cfg = loadConfig (); const args = process . argv . slice ( 2 ); const cmd = args [ 0 ] && ! args [ 0 ]. startsWith ( '--' ) ? args [ 0 ] : 'serve' ;
const opt = ( name , def = null ) => { const i = args . indexOf ( '--' + name ); return i >= 0 ? ( args [ i + 1 ] && ! args [ i + 1 ]. startsWith ( '--' ) ? args [ i + 1 ] : true ) : def ; };
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
const d = createHalDeps ({ config : cfg , log : cmd === 'mcp' ? (... a ) => console . error ( new Date (). toISOString (), ... a ) : log }); // mcp 모드는 stdout 을 JSON-RPC 전용으로 비워 둔다
2026-10-26 11:58:15 +00:00
if ( cmd === 'setup-token' ) {
if ( core . ensureOpsAccountExists ( d )) { console . error ( '운영자 계정이 이미 있습니다 — 설정 토큰은 최초 1회만 쓸 수 있습니다' ); process . exit ( 1 ); }
const t = randomToken ( 24 ); writeFileSync ( join ( cfg . dataDir , 'setup.token' ), t , { mode : 0o600 });
console . log ( `설정 토큰(1회 표시, 공유 금지): ${ t } \n운영자 콘솔 ${ cfg . publicUrl } /ops/ 의 최초 설정 또는 curl -X POST ${ cfg . publicUrl } /ops/api/setup -d '{"setupToken":"…","login":"…","password":"…","displayName":"…"}' 로 첫 운영자 계정을 만드세요` );
} else if ( cmd === 'grant' ) {
const g = ops . issueGrant ( d , { accountId : opt ( 'login' ), hours : Number ( opt ( 'hours' , 8 )), by : null , reason : opt ( 'reason' , 'cli' ) }); console . log ( `접근권 URL(1회, ${ g . expiresAt } 까지): ${ g . url } ` );
} else if ( cmd === 'register-release' ) {
2026-10-28 15:07:35 +00:00
const mf = JSON . parse ( readFileSync ( opt ( 'manifest' ), 'utf8' ));
// 0.2.0 검토 수정 4차: artifact 설치는 불변·fail-closed — 원본 바이트를 manifest 해시·크기와 대조하고, 승인된 버전은 같은 바이트·메타데이터일 때만 멱등(복사 없음), 다르면 기존 파일·DB 를 건드리지 않고 거부. 새 설치는 임시 파일 → rename
let r ; try {
const artifacts = core . installArtifacts ( d , { version : mf . version , mainCommit : mf . mainCommit , builtAt : mf . builtAt , checks : mf . checks || null , artifacts : Object . fromEntries ( Object . entries ( mf . artifacts ). map (([ m , a ]) => [ m , { file : basename ( a . file ), sha256 : a . sha256 , size : a . size , src : join ( dirname ( opt ( 'manifest' )), a . file ) }])) });
r = core . registerRelease ( d , { version : mf . version , mainCommit : mf . mainCommit , builtAt : mf . builtAt , artifacts , checks : mf . checks || null , approve : !! opt ( 'approve' ), by : opt ( 'by' , 'cli' ) });
} catch ( e ) { console . error ( `릴리스 등록 거부 ( ${ e . code || 'error' } ): ${ e . message } ` ); process . exit ( 1 ); } // 해시 불일치(artifact_hash_mismatch)·승인된 버전 불변(release_immutable)·승인 규칙(release_checks_failed) — 종료 1, 파일·DB 불변
2026-10-26 11:58:15 +00:00
console . log ( `릴리스 ${ r . version } 등록( ${ r . status } ) — artifact ${ Object . keys ( r . artifacts ). join ( ', ' ) } ` );
} else if ( cmd === 'link-portal' ) {
2026-10-26 13:13:39 +00:00
// [E2E 결함 수정] 포털은 양방향 모두 보관소 이름 'service:hal' 하나를 쓴다(포털→HAL 서명, HAL→포털 검증). 그래서 공유 비밀 하나를 HAL 의 두 이름(service:portal·service:hal)에 같이 둔다.
const shared = randomToken ( 32 ); d . vault . set ( 'service:portal' , shared , { kind : 'service-key' }); d . vault . set ( 'service:hal' , shared , { kind : 'service-key' });
console . log ( `포털 보관소에 넣을 값(1회 표시, 공유 금지):\n service:hal = ${ shared } \n (포털 서버에서: ARIWEB_PORTAL_DATA=<포털 데이터> node -e "import('./lib/vault.mjs').then(m=>m.openVault(process.env.ARIWEB_PORTAL_DATA).set('service:hal', process.argv[1]))" <값>)` );
2026-10-26 11:58:15 +00:00
} else if ( cmd === 'bootstrap-release' ) {
core . setBootstrap ( d , { release_version : opt ( 'version' ), main_commit : opt ( 'commit' ), artifact_sha256 : opt ( 'sha256' ) }); console . log ( '부트스트랩 릴리스 정보를 기록했습니다' );
2026-10-27 05:25:05 +00:00
} else if ( cmd === 'server-token' ) {
// 운영자 콘솔(네트워크 관문·운영자 세션)이 닿지 않는 부트스트랩·연동 단계에서 HAL 데이터 소유자가 발급한다. 토큰은 1회 표시, 감사 기록(actor cli:<by>).
const sid = opt ( 'server' ); if ( ! sid || ! core . getServer ( d , sid )) { console . error ( '--server <srv_id> 가 필요합니다(없는 서버)' ); process . exit ( 1 ); }
const t = core . issueRegistrationToken ( d , { kind : 'managed' , serverId : sid , by : `cli: ${ opt ( 'by' , 'cli' ) } ` });
console . log ( `등록 토큰(1회 표시, ${ t . expiresAt } 까지, 공유 금지): ${ t . token } \n설치: ${ t . installCommand } ` );
} else if ( cmd === 'mail-import' ) {
const file = opt ( 'discovery' ); if ( ! file ) { console . error ( '--discovery <json> 이 필요합니다' ); process . exit ( 1 ); }
const doc = JSON . parse ( readFileSync ( file , 'utf8' )); const v = mailsvc . validateDiscovery ( doc ); if ( ! v . ok ) { console . error ( `discovery 검증 실패:\n - ${ v . errors . join ( '\n - ' ) } ` ); process . exit ( 1 ); }
let serverId = opt ( 'server' ); const by = `cli: ${ opt ( 'by' , 'cli' ) } ` ;
if ( ! serverId && opt ( 'create-server' )) { const s = core . createServer ( d , { name : opt ( 'name' , doc . source . hostname ), role : 'mail' , location : opt ( 'location' , '' ), by }); serverId = s . id ; console . log ( `서버 생성(pending, Agent 미연결): ${ s . id } ${ s . name } ` ); }
if ( ! serverId ) { console . error ( '--server <srv_id> 또는 --create-server 가 필요합니다' ); process . exit ( 1 ); }
try { const r = mailsvc . applyImport ( d , doc , { serverId , by , dryRun : ! opt ( 'apply' ) }); console . log ( JSON . stringify ({ importId : r . importId , mode : r . mode , serverId , authority : r . plan . authority , summary : r . plan . summary , items : r . plan . items , missingInSource : r . plan . missingInSource , created : r . created , updated : r . updated }, null , 1 )); if ( r . mode === 'rejected' ) process . exit ( 3 ); }
catch ( e ) { console . error ( `가져오기 실패: ${ e . code || '' } ${ e . message } ` ); process . exit ( 2 ); }
} else if ( cmd === 'mail-import-rollback' ) {
try { const r = mailsvc . rollbackImport ( d , opt ( 'import' ), `cli: ${ opt ( 'by' , 'cli' ) } ` ); console . log ( JSON . stringify ( r )); } catch ( e ) { console . error ( `되돌리기 실패: ${ e . code || '' } ${ e . message } ` ); process . exit ( 2 ); }
} else if ( cmd === 'mail-services' ) {
console . log ( JSON . stringify ( mailsvc . readback ( d ), null , 1 ));
2026-10-27 09:27:23 +00:00
} else if ( cmd === 'config-set' ) {
// 운영 중 바꾸는 값만(hal/config.mjs 의 set — 비밀 키 이름은 거부). 받는 키를 고정해 비밀값이 설정 파일에 들어갈 길을 막는다. 감사 actor cli:<by>.
const patch = {}; for ( const k of [ 'publicUrl' , 'portalUrl' , 'offlineAfterMs' , 'metricsRetentionDays' ]) { const v = opt ( k ); if ( v != null && v !== true ) patch [ k ] = String ( v ); }
if ( ! Object . keys ( patch ). length ) { console . error ( '바꿀 값이 없습니다: --publicUrl / --portalUrl / --offlineAfterMs / --metricsRetentionDays 중 하나 이상' ); process . exit ( 1 ); }
const by = `cli: ${ opt ( 'by' , 'cli' ) } ` ; cfg . set ( patch , by );
audit ( d . store , { actorType : 'operator' , actorId : by , action : 'config.update' , subjectType : 'config' , subjectId : 'hal' , detail : { keys : Object . keys ( patch ), values : patch , reason : opt ( 'reason' , '' ) } });
console . log ( JSON . stringify ({ updated : Object . keys ( patch ), file : cfg . file , note : '서비스 프로세스(ariweb-hal)는 기동 때 설정을 읽는다 — 재시작 뒤 반영' }));
} else if ( cmd === 'module-deploy' ) {
// 운영자 API(POST /ops/api/modules/:name/deploy)와 같은 작업·멱등 키 규칙. 검증(모듈·릴리스·서버)은 여기서, 배포 전제 검사(승인·HAL 먼저·부트스트랩)는 작업의 precheck 단계가 한다.
const module = opt ( 'module' ); const version = opt ( 'version' ); const serverIds = String ( opt ( 'servers' , '' ) || '' ). split ( ',' ). map (( x ) => x . trim ()). filter ( Boolean ); const by = `cli: ${ opt ( 'by' , 'cli' ) } ` ;
if ( ! module || module === true || ! version || version === true || ! serverIds . length ) { console . error ( '--module <name> --version <v> --servers <srv_id[,srv_id]> 가 필요합니다' ); process . exit ( 1 ); }
try { descriptor ( module ); } catch ( e ) { console . error ( e . message ); process . exit ( 1 ); }
if ( ! core . getRelease ( d , version )) { console . error ( `릴리스 ${ version } 이(가) 등록되어 있지 않습니다` ); process . exit ( 1 ); }
const missing = serverIds . filter (( sid ) => ! core . getServer ( d , sid )); if ( missing . length ) { console . error ( `없는 서버: ${ missing . join ( ', ' ) } ` ); process . exit ( 1 ); }
const r = d . jobs . enqueue ({ kind : 'module.deploy' , subject : { module , version , serverIds }, idempotencyKey : `module.deploy: ${ module } : ${ version } : ${ serverIds . slice (). sort (). join ( ',' ) } : ${ opt ( 'nonce' , '' ) === true ? '' : opt ( 'nonce' , '' ) } ` , createdBy : by , actorType : 'operator' , subjectType : 'module' , subjectId : module , label : ` ${ module } ${ version } 배포` });
audit ( d . store , { actorType : 'operator' , actorId : by , action : 'deploy.module.request' , subjectType : 'module' , subjectId : module , detail : { version , serverIds , reason : opt ( 'reason' , '' ), jobId : r . job . id , created : r . created } });
console . log ( JSON . stringify ({ jobId : r . job . id , created : r . created , status : r . job . status , kind : r . job . kind , label : r . job . label , note : '실행은 ariweb-hal 서비스 프로세스의 작업 엔진이 맡는다 — `job --id` 로 확인' }));
} else if ( cmd === 'job' ) {
const j = opt ( 'id' ) && opt ( 'id' ) !== true ? d . jobs . get ( opt ( 'id' )) : null ; if ( ! j ) { console . error ( '--id <j_id> 가 필요합니다(없는 작업)' ); process . exit ( 1 ); }
console . log ( JSON . stringify ({ job : describe ( j ), children : d . jobs . list ({ parentId : j . id , limit : 100 }). map ( describe ) }, null , 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
} else if ( cmd === 'mcp' ) {
// 0.3.0: HAL 도구를 MCP(stdio) 서버로 내보낸다(hal/ai/mcp.mjs). 읽기 도구만 실행, 쓰기·고위험은 운영자 콘솔 승인 안내. 로그는 stderr.
const { runMcpServer } = await import ( './ai/mcp.mjs' ); await runMcpServer ( d ); d . store . close ();
2026-10-26 11:58:15 +00:00
} else if ( cmd === 'serve' ) {
// "하나의 HAL" 임대(§34). 다른 인스턴스가 살아 있으면 기동 거부(--takeover 는 재해복구 절차에서만).
try { core . claimLive ( d , { instanceId : d . instanceId , version : VERSION , takeover : !! opt ( 'takeover' ) }); } catch ( e ) { console . error ( `[hal] 기동 거부: ${ e . message } ` ); process . exit ( 2 ); }
const app = buildApp ( d ); d . jobs . start (); d . jobsRunning = true ;
const every = ( ms , fn ) => setInterval (() => { try { const r = fn (); if ( r && r . catch ) r . catch (( e ) => log ( '[hal] 주기 작업 오류' , e . message )); } catch ( e ) { log ( '[hal] 주기 작업 오류' , e . message ); } }, ms ). unref ();
every ( 20000 , () => core . renewLease ( d , d . instanceId )); every ( 30000 , () => core . markOffline ( d )); every ( 3000 , () => core . flushPortalEvents ( d )); every ( 60000 , () => emergency . expireCredentials ( d ));
every ( 3600000 , () => { core . purgeMetrics ( d ); purgeSessions ( d . store ); });
every ( 60000 , () => { const c = dr . getConfig ( d ); if ( ! c . snapshot_interval_minutes ) return ; const last = d . store . get ( 'SELECT created_at FROM dr_snapshots WHERE verify_state=\'ok\' ORDER BY created_at DESC LIMIT 1' ); if ( ! last || Date . now () - Date . parse ( last . created_at ) >= c . snapshot_interval_minutes * 60000 ) dr . snapshot ( d , { kind : 'scheduled' }); });
await app . listen ( cfg . port , cfg . host );
log ( `[hal] Ariweb HAL v ${ VERSION } — http:// ${ cfg . host } : ${ cfg . port } (공개 ${ cfg . publicUrl } , 데이터 ${ cfg . dataDir } , 부트스트랩 ${ core . bootstrapComplete ( d ) ? '완료' : '진행 중' } )` );
process . on ( 'SIGTERM' , () => { d . stopping = true ; d . jobs . stop (); setTimeout (() => process . exit ( 0 ), 500 ); });
} else { console . error ( `알 수 없는 명령: ${ cmd } ` ); process . exit ( 1 ); }