HAL 0.3.2: AI 연동 편의성 + 도구 기본 설정·승인 요청 — 공급자 프리셋(Claude·ChatGPT·Gemini·OpenRouter + 기타 OpenAI 호환, Gemini 자체 API 어댑터), 사전 준비 안내, OAuth 저장 직후 로그인 창 자동(완료 페이지 postMessage·닫기, Gemini Google 프리셋, OpenRouter PKCE 키 발급), CLI 로그인용 텍스트 셸(허용 명령만 PTY, 재인증, 입력 미저장, 미로그인 시 자동 열기), 연결별 도구 정책(기본 전부)·로컬 CLI 에 HAL MCP 설정 자동 주입·MCP 설정 스니펫, MCP 쓰기·고위험 호출은 승인 요청(제안)으로 + hal_action_status/hal_actions_pending, 개발 지시 v0.3.2(Oz)·docs/HAL-AI.md §7·VERSION_HISTORY 0.3.2 — test/hal-ai-032.test.mjs RED 0/6 → GREEN 6/6, 전체 27파일 189건 통과

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5.1
2026-10-29 01:43:02 +00:00
parent ad96f0b986
commit 8a111bcb8b
21 changed files with 609 additions and 78 deletions
+15 -2
View File
@@ -51,6 +51,8 @@ export const TOOLS = [
{ name: 'hal_mail_services_list', risk: 'read', description: 'HAL 관리형 메일 서비스 등록부 readback(중복·비밀 누출 검사 포함, 값 없음).', schema: S({}), run: (d) => mailsvc.readback(d) },
{ name: 'hal_emergency_list', risk: 'read', description: '긴급 복구 세션 목록과 허용 범위·작업 종류.', schema: S({}), run: (d) => ({ sessions: emergency.listSessions(d), scopes: emergency.SCOPES, actions: emergency.ACTIONS }) },
{ name: 'hal_dr_status', risk: 'read', description: '재해복구 준비도·스냅샷·복원·훈련 목록.', schema: S({}), run: (d) => ({ readiness: dr.readiness(d), snapshots: dr.listSnapshots(d), restores: d.store.all('SELECT * FROM dr_restores ORDER BY rowid DESC LIMIT 50'), drills: d.store.all('SELECT * FROM dr_drills ORDER BY started_at DESC LIMIT 50') }) },
{ 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) }; } },
// ---------------- 쓰기(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 })) }) },
@@ -72,13 +74,24 @@ export const TOOLS = [
{ 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) }; } },
];
const BY_NAME = new Map(TOOLS.map((t) => [t.name, t]));
/** [0.3.2] 연결별 도구 정책 프리셋. 기본값 all(전부) — 쓰기·고위험은 어차피 승인 전 실행되지 않으므로 "바로 쓸 수 있게" 전부 켜 둔다. */
export const TOOL_PRESETS = [
{ id: 'all', label: '전부(기본값) — 읽기 즉시, 쓰기·고위험은 승인', tools: TOOLS.map((t) => t.name) },
{ id: 'read_write', label: '읽기 + 쓰기(고위험 제외)', tools: TOOLS.filter((t) => t.risk !== 'high').map((t) => t.name) },
{ id: 'read_only', label: '읽기만', tools: TOOLS.filter((t) => t.risk === 'read').map((t) => t.name) },
{ id: 'none', label: '도구 없음(대화만)', tools: [] },
];
export const presetTools = (id) => (TOOL_PRESETS.find((p) => p.id === id) || TOOL_PRESETS[0]).tools;
export const toolByName = (name) => BY_NAME.get(String(name || '')) || null;
export const RISK_LABEL = { read: '읽기', write: '쓰기(승인·재인증)', high: '고위험(승인·재인증·3단계 확인)' };
/** 공급자별 도구 선언. anthropic: {name, description, input_schema} / openai: {type:'function', function:{name, description, parameters}} / mcp: {name, description, inputSchema} */
export function toolDefs(format = 'anthropic') {
return TOOLS.map((t) => { const description = `[${RISK_LABEL[t.risk]}] ${t.description}`; if (format === 'openai') return { type: 'function', function: { name: t.name, description, parameters: t.schema } }; if (format === 'mcp') return { name: t.name, description, inputSchema: t.schema }; return { name: t.name, description, input_schema: t.schema }; });
export function toolDefs(format = 'anthropic', only = null) {
const list = only ? TOOLS.filter((t) => only.includes(t.name)) : TOOLS;
return list.map((t) => { const description = `[${RISK_LABEL[t.risk]}] ${t.description}`; if (format === 'openai') return { type: 'function', function: { name: t.name, description, parameters: t.schema } }; if (format === 'mcp') return { name: t.name, description, inputSchema: t.schema }; if (format === 'gemini') return { name: t.name, description, parameters: geminiSchema(t.schema) }; return { name: t.name, description, input_schema: t.schema }; });
}
/** Gemini 선언용 스키마 변환(순환 import 를 피해 여기 둔다) */
function geminiSchema(sc) { if (!sc || typeof sc !== 'object') return sc; const out = {}; if (sc.type) out.type = String(sc.type).toUpperCase(); if (sc.description) out.description = sc.description; if (sc.enum) out.enum = sc.enum; if (sc.required && sc.required.length) out.required = sc.required; if (sc.properties) { out.properties = {}; for (const [k, v] of Object.entries(sc.properties)) out.properties[k] = geminiSchema(v); } if (sc.items) out.items = geminiSchema(sc.items); return out; }
/** 공개용 목록(화면·API) */
export const publicTools = () => TOOLS.map((t) => ({ name: t.name, risk: t.risk, riskLabel: RISK_LABEL[t.risk], description: t.description, schema: t.schema }));