Files

228 lines
9.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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();