#!/usr/bin/env node // ============================================================================ // gatekeeper daemon —— 常驻守卫进程 // // 收益:LLM 连接常驻(免每次冷启动的 DNS/TLS 握手)、决策缓存与任务状态驻内存、 // 配置文件热重载(每次请求比较四个配置文件的内容签名,不一致即重载)。 // // 由 gatekeeper.mjs 客户端按需拉起(detached),无需手工运行;手工调试: // node gatekeeper-daemon.mjs # 前台运行,日志同时打到 stdout // node gatekeeper.mjs shutdown-daemon # 通过客户端停止 // // 协议(127.0.0.1:daemonPort,行分隔 JSON): // 请求/响应均使用 daemon-auth.mjs 的 nonce + timestamp + HMAC envelope; // 业务 payload: {"op":"ping"} | {"op":"shutdown"} | // {"op":"hook","mode":"review","layer":"hard|full","payload":{...hook输入...}} // ============================================================================ import net from 'node:net'; import { appendFileSync, existsSync, statSync, renameSync } from 'node:fs'; import path from 'node:path'; import { SCRIPT_DIR, STATE_DIR, loadAll, configSigs, memDeps, decide, } from './core.mjs'; import { daemonSecretFor, makeDaemonRequest, verifyDaemonRequest, makeDaemonResponse, verifyDaemonResponse, daemonInstanceIdFor, } from './daemon-auth.mjs'; const LOG = path.join(STATE_DIR, 'daemon.log'); function log(msg) { const line = '[' + new Date().toISOString() + '] ' + msg; try { if (existsSync(LOG) && statSync(LOG).size > 2 * 1024 * 1024) renameSync(LOG, LOG + '.' + Date.now()); appendFileSync(LOG, line + '\n', { mode: 0o600 }); } catch { /* 忽略 */ } if (!process.env.GATEKEEPER_DAEMON_QUIET) console.log(line); } let all = loadAll(); let deps = memDeps(); const PORT = all.config.daemonPort | 0 || 47811; const SECRET = daemonSecretFor(STATE_DIR); const DAEMON_INSTANCE_ID = daemonInstanceIdFor(SCRIPT_DIR); let loadedSigs = configSigs(); let llmWarm = false; const seenNonces = new Map(); let lastRejectedLog = 0; function logRejected(msg) { const now = Date.now(); if (now - lastRejectedLog < 5000) return; lastRejectedLog = now; log(msg); } function claimNonce(nonce) { const now = Date.now(); for (const [n, ts] of seenNonces) if (now - ts > 60_000) seenNonces.delete(n); if (seenNonces.has(nonce)) return false; seenNonces.set(nonce, now); return true; } function reloadIfStale() { const cur = configSigs(); if (cur.some((v, i) => v !== loadedSigs[i])) { all = loadAll(); loadedSigs = cur; llmWarm = false; log('config hot-reloaded (sigs changed)'); } } async function handleHook(req) { const env = req.env || {}; const effective = env.fullAccessGuard != null ? { ...all, config: { ...all.config, fullAccessGuard: all.config.fullAccessGuard || env.fullAccessGuard === true } } : all; const t0 = Date.now(); // 不共享 review 决策:一次性批准必须严格只消费并放行一个请求。 const result = await decide({ mode: req.mode, payload: req.payload, layer: req.layer }, deps, effective); log(`hook ${req.mode}/${req.payload?.tool_name || '?'} -> exit=${result.exitCode} (${Date.now() - t0}ms, llmWarm=${llmWarm})`); return result; } // allowHalfOpen: true —— 客户端会半关闭(write+end),hook 判定是异步的(LLM 数秒), // 默认模式下 Node 会在收到 FIN 时自动关闭本端,响应就写进了一条已死的连接。 // 空闲自动退出:ZCode 关闭/无调用一段时间后自动退出释放内存(~55MB),下次 hook 调用会自动重新拉起 let idleTimer = null; function armIdle() { const ms = all.config.daemonIdleMs | 0; if (idleTimer) clearTimeout(idleTimer); if (ms <= 0) return; idleTimer = setTimeout(() => { log('idle ' + Math.round(ms / 1000) + 's — exit to free memory'); process.exit(0); }, ms); } const server = net.createServer({ allowHalfOpen: true }, (socket) => { let buf = ''; let bufferedBytes = 0; socket.setEncoding('utf8'); socket.setTimeout(42_000, () => socket.destroy()); // 兜底:不允许超过 hook 超时 const dispatch = async (line) => { // 协议异常/未知 op 一律返回 {ok:false}(无 exitCode 字段)——客户端据此降级内联做完整审查, // 绝不能返回 {exitCode:0}(会被当作"明确放行"决策,形成 fail-open)。 let res = { ok: false, error: 'unknown op' }; let nonce = ''; try { const envelope = line.trim() ? JSON.parse(line.trim()) : null; if (!verifyDaemonRequest(SECRET, envelope)) { logRejected('rejected unauthenticated local request'); socket.destroy(); return; } if (!claimNonce(envelope.nonce)) { logRejected('rejected replayed local request'); socket.destroy(); return; } nonce = envelope.nonce; const req = envelope.payload; if (req.instanceId !== DAEMON_INSTANCE_ID) throw new Error('daemon instance mismatch'); reloadIfStale(); armIdle(); // 只有通过认证的请求(含 ping)才重置空闲计时 if (!req || req.op === 'ping') { res = { ok: true, llmWarm, version: 1 }; } else if (req.op === 'shutdown') { socket.write(JSON.stringify(makeDaemonResponse(SECRET, nonce, { ok: true, bye: true })) + '\n', () => { log('shutdown by authenticated request'); process.exit(0); }); return; } else if (req.op === 'hook') { res = await handleHook(req); } } catch (e) { res = { ok: false, error: 'request error: ' + (e?.message || e) }; log('request error: ' + (e?.message || e)); } if (!nonce) { socket.destroy(); return; } socket.write(JSON.stringify(makeDaemonResponse(SECRET, nonce, res)) + '\n', () => { try { socket.end(); } catch { /* 忽略 */ } }); }; socket.on('data', (chunk) => { // 行分帧:收到完整一行立即处理 buf += chunk; bufferedBytes += Buffer.byteLength(chunk, 'utf8'); if (bufferedBytes > 2 * 1024 * 1024) { logRejected('rejected oversized local request'); socket.destroy(); return; } let idx; while ((idx = buf.indexOf('\n')) >= 0) { const line = buf.slice(0, idx); buf = buf.slice(idx + 1); bufferedBytes = Buffer.byteLength(buf, 'utf8'); if (line.trim()) dispatch(line.trim()); } }); socket.on('end', () => { if (buf.trim()) { const rest = buf; buf = ''; dispatch(rest.trim()); } }); socket.on('error', () => { /* 客户端断开等,忽略 */ }); }); server.on('error', async (e) => { if (e.code === 'EADDRINUSE') { const alive = await ping(PORT, 400).catch(() => null); if (alive?.ok) { log('daemon already running on port ' + PORT + ' — exit quietly'); process.exit(0); } log('port ' + PORT + ' occupied but no daemon responding — exit 1'); process.exit(1); } log('server error: ' + (e?.message || e)); process.exit(1); }); server.listen(PORT, '127.0.0.1', () => { log('daemon listening on 127.0.0.1:' + PORT + ' (model=' + all.config.model + ', guard=' + all.config.fullAccessGuard + ', idleExit=' + Math.round((all.config.daemonIdleMs|0)/1000) + 's)'); armIdle(); // 启动即计时:无任何调用则按时退出 // 预热:开一条到审查端点的 TLS 连接,让第一次真实审查免握手 setTimeout(async () => { try { if (!all.config.apiKey || all.config.apiKey === 'PENDING' || !/^https?:\/\//i.test(String(all.config.baseUrl || '')) || !all.config.model) { log('llm warmup skipped (endpoint not configured)'); return; } const t0 = Date.now(); const res = await fetch(all.config.baseUrl.replace(/\/+$/, '') + '/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + all.config.apiKey }, body: JSON.stringify({ model: all.config.model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 1 }), signal: AbortSignal.timeout(10_000), }); if (!res.ok) throw new Error('HTTP ' + res.status); await res.json().catch(() => {}); llmWarm = true; log('llm connection warmed in ' + (Date.now() - t0) + 'ms'); } catch (e) { llmWarm = false; log('llm warmup failed (will retry implicitly on first review): ' + (e?.message || e)); } }, 300); }); async function ping(port, timeoutMs) { return new Promise((resolve, reject) => { const s = net.connect(port, '127.0.0.1'); let buf = ''; s.setEncoding('utf8'); const to = setTimeout(() => { s.destroy(); reject(new Error('timeout')); }, timeoutMs); const envelope = makeDaemonRequest(SECRET, { op: 'ping', instanceId: DAEMON_INSTANCE_ID }); s.on('connect', () => s.write(JSON.stringify(envelope) + '\n')); s.on('data', (c) => { buf += c; if (buf.includes('\n')) { clearTimeout(to); try { const parsed = JSON.parse(buf.slice(0, buf.indexOf('\n'))); resolve(verifyDaemonResponse(SECRET, parsed, envelope.nonce) ? parsed.result : null); } catch { resolve(null); } s.end(); } }); s.on('error', (e) => { clearTimeout(to); reject(e); }); }); } // 兜底:防止被遗留为孤儿进程后无法退出。setTimeout 上限为 2^31-1 ≈ 24.8 天(再大会溢出 32 位 // 整数被截成 1ms,导致 daemon 启动即退出),到期后自动退出,客户端会按需重新拉起。 setTimeout(() => { log('max lifetime reached, exiting'); process.exit(0); }, 2 ** 31 - 1).unref();