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파일 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-29 06:46:45 +00:00
co-authored by Claude Opus 5.5
parent 54db663531
commit 9f1e490b51
44 changed files with 1075 additions and 82 deletions
+7
View File
@@ -21,6 +21,9 @@ import * as dr from '../dr.mjs';
import * as mailsvc from '../mailsvc.mjs';
import { MODULES, DEPLOY_ORDER, DEPLOYABLE_MODULES } from '../modules/index.mjs';
import { overviewLocal, changelog } from '../ui.mjs';
import * as settingsStore from '../settings/index.mjs'; // 0.4.0 통합 설정 관제
import { MANAGED_MODULES, keyDef } from '../settings/catalog.mjs';
import { enqueueSettingsApply } from '../app.mjs';
/** 스킬 문서(상태·로그의 뜻). system prompt 와 hal_skill 도구가 같은 내용을 쓴다. */
export const SKILL_DOC = readFileSync(new URL('./skill.md', import.meta.url), 'utf8');
@@ -54,6 +57,7 @@ export const TOOLS = [
{ name: 'hal_action_status', risk: 'read', description: '승인 요청(제안) 하나의 상태·결과를 본다(proposed·executing·executed·failed·rejected·cancelled). 쓰기·고위험 도구를 부른 뒤 운영자 승인 결과를 확인할 때 쓴다.', schema: S({ actionId: str('제안 id(aia_…)') }, ['actionId']), run: (d, a) => { const x = d.ai && d.ai.getAction(a.actionId); if (!x) throw notFound('action_not_found', '제안이 없습니다'); return { action: x }; } },
{ name: 'hal_actions_pending', risk: 'read', description: '운영자 승인을 기다리는 제안 목록(모든 출처).', schema: S({}), run: (d) => ({ actions: d.ai ? d.ai.listActions({ status: 'proposed', limit: 100 }) : [] }) },
{ name: 'hal_changelog', risk: 'read', description: 'VERSION_HISTORY.md 의 버전별 변경 이력(버전을 주면 그 절만).', schema: S({ version: str('버전(예: 0.3.0)') }), run: (d, a) => { const c = changelog(); return a.version ? { version: a.version, text: c[a.version] || null } : { versions: Object.keys(c) }; } },
{ name: 'hal_settings_get', risk: 'read', description: '[0.4.0] 통합 설정 관제: 모듈(webmaker·arimail·agent)의 HAL 관리 설정을 본다. scope 는 global(기본) 또는 server:<서버 id>. 서버 범위면 유효값(전체←서버)과 적용 상태(적용됨·적용 필요·실패)도 준다. 비밀값은 설정 여부만.', schema: S({ module: str('모듈', { enum: MANAGED_MODULES }), scope: str('global 또는 server:<서버 id>') }, ['module']), run: (d, a) => { const sc = settingsStore.parseScope(a.scope || 'global'); const g = settingsStore.getSettings(d, a.module, sc.scope); const out = { module: g.module, scope: g.scope, values: g.values, confidential: Object.entries(g.secrets).map(([name, set]) => ({ name, set })) /* 'secret' 이 든 키 이름은 redact 가 가리므로 confidential */, updatedAt: g.updatedAt }; if (sc.serverId) { const e = settingsStore.effectiveSettings(d, a.module, sc.serverId); out.effective = { values: e.values, sources: e.sources }; out.state = settingsStore.stateFor(d, a.module, sc.serverId); } else out.overview = settingsStore.settingsOverview(d)[a.module]; return out; } },
// ---------------- 쓰기(write): 승인 + 재인증
{ name: 'hal_server_create', risk: 'write', description: '관리 서버를 등록(pending)한다. 등록 토큰 발급·Agent 설치·승인은 운영자가 콘솔에서 한다.', schema: S({ name: str('서버 이름'), role: str('역할', { enum: core.SERVER_ROLES }), location: str('위치') }, ['name', 'role']), run: (d, a, actor) => ({ server: core.publicServer(d, core.createServer(d, { name: a.name, role: a.role, location: a.location || '', by: actor.operatorId })) }) },
{ name: 'hal_server_approve', risk: 'write', description: 'Agent 가 연결된 pending 서버를 승인해 배치 가능(ready)으로 만든다.', schema: S({ serverId: SERVER_ID }, ['serverId']), identifiers: (a) => [a.serverId], run: (d, a, actor) => ({ server: core.publicServer(d, core.approveServer(d, a.serverId, actor.operatorId)) }) },
@@ -72,6 +76,8 @@ export const TOOLS = [
{ name: 'hal_release_approve', risk: 'high', description: '등록된 릴리스를 승인한다(testsPassed·dirty=false·provenanceClean 이 모두 기록된 릴리스만 가능).', schema: S({ version: str('릴리스 버전') }, ['version']), identifiers: (a) => [a.version], run: (d, a, actor) => ({ release: core.approveRelease(d, a.version, actor.operatorId) }) },
{ name: 'hal_job_revert', risk: 'high', description: '끝난 작업을 되돌린다(revert 를 지원하는 종류만).', schema: S({ jobId: str('작업 id') }, ['jobId']), identifiers: (a) => [a.jobId], run: async (d, a, actor) => ({ job: jobView(await d.jobs.revert(a.jobId, actor.operatorId)) }) },
{ name: 'hal_config_set', risk: 'high', description: 'HAL 운영 설정(공개 주소·포털 주소·offline 판정 시간·지표 보존 일수)을 바꾼다. 비밀값은 받지 않는다. 서비스 재시작 뒤 반영되는 값도 있다.', schema: S({ publicUrl: str('HAL 공개 주소'), portalUrl: str('포털 내부 주소'), offlineAfterMs: num('heartbeat 없음 → offline 판정(ms)'), metricsRetentionDays: num('지표 보존 일수(최소 90)') }), identifiers: () => ['hal'], run: (d, a, actor) => { const patch = {}; for (const k of ['publicUrl', 'portalUrl', 'offlineAfterMs', 'metricsRetentionDays']) if (a[k] !== undefined) patch[k] = a[k]; if (!Object.keys(patch).length) throw new HttpError(400, 'nothing_to_set', '바꿀 값이 없습니다'); d.config.set(patch, actor.operatorId); return { updated: Object.keys(patch) }; } },
{ name: 'hal_settings_set', risk: 'high', description: '[0.4.0] 통합 설정 관제: 모듈 설정을 HAL 에 저장한다(마진·가격·모델·서비스·서버별 항목). 비밀값(API 키·토큰·비밀번호)은 받지 않는다 — 운영자가 화면에서 직접 넣는다. 저장만으로는 서버에 반영되지 않으며 hal_settings_apply 로 적용한다. 값을 null 로 주면 그 지정을 지운다.', schema: S({ module: str('모듈', { enum: MANAGED_MODULES }), scope: str('global 또는 server:<서버 id>'), values: { type: 'object', description: '{ 설정 키: 값 } — 키는 hal_settings_get 의 목록을 따른다' } }, ['module', 'values']), identifiers: (a) => [`${a.module}:${a.scope || 'global'}`], run: (d, a, actor) => { for (const k of Object.keys(a.values || {})) { const def = keyDef(a.module, k); if (def && def.type === 'secret') throw new HttpError(400, 'secret_not_allowed', `비밀값(${k})은 AI 도구로 넣을 수 없습니다 — 운영자가 통합 설정 화면에서 직접 입력하세요`); } const r = settingsStore.setSettings(d, { module: a.module, scope: a.scope || 'global', values: a.values || {}, by: actor.operatorId, via: 'ai' }); return { module: r.module, scope: r.scope, changed: r.changed, restartNeeded: r.restartNeeded }; } },
{ name: 'hal_settings_apply', risk: 'high', description: '[0.4.0] 통합 설정 관제: 저장된 설정을 서버에 적용하는 작업(settings.apply)을 큐에 넣는다. serverIds 를 비우면 그 모듈이 배치된 서버 전체. restart 는 재시작 필요 항목을 바꿨을 때.', schema: S({ module: str('모듈', { enum: MANAGED_MODULES }), serverIds: arr('대상 서버 id 목록(비우면 배치된 서버 전체)'), restart: bool('적용 뒤 서비스 재시작') }, ['module']), identifiers: (a) => (a.serverIds && a.serverIds.length ? a.serverIds : [a.module]), run: (d, a, actor) => { const r = enqueueSettingsApply(d, { module: a.module, serverIds: a.serverIds || [], restart: a.restart, nonce: 'ai', by: actor.operatorId }); return { job: jobView(d.jobs.get(r.job.id)), targets: r.targets, restart: r.restart }; } },
];
const BY_NAME = new Map(TOOLS.map((t) => [t.name, t]));
/** [0.3.2] 연결별 도구 정책 프리셋. 기본값 all(전부) — 쓰기·고위험은 어차피 승인 전 실행되지 않으므로 "바로 쓸 수 있게" 전부 켜 둔다. */
@@ -109,6 +115,7 @@ export function validateArgs(tool, args) {
if (p.type === 'string') { if (typeof v !== 'string') return `${k} 는 문자열이어야 합니다`; if (v.length > 4000) return `${k} 가 너무 깁니다`; if (p.enum && !p.enum.includes(v)) return `${k} 는 ${p.enum.join('·')} 중 하나여야 합니다`; }
else if (p.type === 'number') { if (typeof v !== 'number' || !Number.isFinite(v)) return `${k} 는 숫자여야 합니다`; }
else if (p.type === 'boolean') { if (typeof v !== 'boolean') return `${k} 는 true/false 여야 합니다`; }
else if (p.type === 'object') { if (typeof v !== 'object' || Array.isArray(v)) return `${k} 는 객체여야 합니다`; if (Object.keys(v).length > 100) return `${k} 항목이 너무 많습니다`; }
else if (p.type === 'array') { if (!Array.isArray(v)) return `${k} 는 배열이어야 합니다`; if (v.length > 200) return `${k} 항목이 너무 많습니다`; if (p.items?.type === 'string' && !v.every((x) => typeof x === 'string' && x.length <= 400)) return `${k} 항목은 문자열이어야 합니다`; }
}
return null;