ruvnet-brain 4.7.21 → 4.7.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/bin/install.mjs +126 -9
- package/console/activity.js +122 -38
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/scripts/agentdb-recall-process.mjs +115 -5
- package/plugin/scripts/agentdb-recall-reader.mjs +44 -2
- package/plugin/scripts/agentdb-recall.mjs +34 -12
- package/plugin/scripts/completion-claim-evidence.mjs +185 -11
- package/plugin/scripts/continuation-gate.mjs +52 -7
- package/plugin/scripts/lesson-gate.mjs +7 -39
- package/plugin/scripts/lesson-scope.mjs +51 -0
- package/plugin/scripts/lesson-store.mjs +1 -1
- package/plugin/scripts/ruflo-bin.mjs +14 -6
- package/plugin/scripts/ruflo-memory-hooks.mjs +157 -19
- package/plugin/scripts/session-snapshot-hook.mjs +1 -1
- package/plugin/scripts/session-start-core.mjs +2 -1
- package/plugin/scripts/session-start-health.mjs +3 -2
- package/scripts/codex-boot-surface-proof.mjs +208 -0
- package/scripts/generation-census.mjs +220 -0
- package/scripts/issue-autofix-agents.mjs +172 -0
- package/scripts/issue-autofix-comment.mjs +224 -0
- package/scripts/issue-autofix-diff.mjs +108 -0
- package/scripts/issue-autofix-exec.mjs +128 -0
- package/scripts/issue-autofix-fix.mjs +104 -0
- package/scripts/issue-autofix-gate.mjs +137 -0
- package/scripts/issue-autofix-night.mjs +164 -0
- package/scripts/issue-autofix-notes.mjs +346 -0
- package/scripts/issue-autofix-policy.mjs +349 -0
- package/scripts/issue-autofix-prompts.mjs +105 -0
- package/scripts/issue-autofix-release-ship.mjs +226 -0
- package/scripts/issue-autofix-release-util.mjs +141 -0
- package/scripts/issue-autofix-release.mjs +272 -0
- package/scripts/issue-autofix-report.mjs +121 -0
- package/scripts/issue-autofix-runner.mjs +75 -0
- package/scripts/issue-autofix-sandbox.mjs +108 -0
- package/scripts/issue-autofix-security.mjs +346 -0
- package/scripts/issue-autofix-selftest.mjs +101 -0
- package/scripts/issue-autofix-state.mjs +61 -0
- package/scripts/issue-autofix.mjs +382 -0
- package/scripts/issue-fix-lock.mjs +40 -0
- package/scripts/issue-fix.mjs +33 -20
- package/scripts/no-silent-substitution.mjs +1 -1
- package/scripts/ntfy-inbox.mjs +15 -0
- package/scripts/onboarding-console.mjs +76 -15
- package/scripts/recall-latency-probe.mjs +48 -0
- package/scripts/release-prepush-checks.mjs +251 -0
- package/scripts/release-prepush.mjs +168 -0
- package/scripts/release-qualification-contract.mjs +41 -0
- package/scripts/wired-check.mjs +13 -5
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Proof that the Codex boot surface a release changed is the surface a FRESH Codex session will load (issue #391,
|
|
2
|
+
// ADR-0070, ADR-0051). A receipt that names `scripts/hook-shim.mjs`, `mcp/server.mjs` or `skills/` as changed used to stay
|
|
3
|
+
// restartScope:'unproven' forever, because only the two hook-declaration files could be cleared. Each path is proven by
|
|
4
|
+
// the strongest evidence that can be gathered without a Codex relaunch, and any path with no prover stays unproven,
|
|
5
|
+
// NAMED in the result:
|
|
6
|
+
// hooks/codex-hooks.json, hooks/hooks.json the fresh-declarations proof (scripts/codex-fresh-host-proof.mjs)
|
|
7
|
+
// skills/ that proof plus a byte-identical skills/ tree
|
|
8
|
+
// scripts/hook-shim.mjs byte-identical AND a real run of the INSTALLED shim with an unknown hook
|
|
9
|
+
// id (it exits 0 doing nothing and lists its roles; every registered Codex
|
|
10
|
+
// role must be listed)
|
|
11
|
+
// mcp/server.mjs byte-identical AND a real stdio initialize + tools/list that must
|
|
12
|
+
// include search_ruvnet
|
|
13
|
+
// The result ALWAYS reports windows that are already open as `unproven` and a host relaunch as `not-observed`. It is
|
|
14
|
+
// read-only: both real processes run against an isolated temporary HOME and brain home (the MCP server leases its
|
|
15
|
+
// generation while running), under a hard deadline, and the temp tree is removed. It never touches a receipt.
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { spawn } from 'node:child_process';
|
|
20
|
+
import { shellDiff } from '../plugin/scripts/host-shell-boundary.mjs';
|
|
21
|
+
|
|
22
|
+
export const BOOT_REASON_PREFIX = 'boot-level declarations changed: ';
|
|
23
|
+
export const PROVABLE_PATHS = Object.freeze(['hooks/codex-hooks.json', 'hooks/hooks.json', 'skills/',
|
|
24
|
+
'scripts/hook-shim.mjs', 'mcp/server.mjs']);
|
|
25
|
+
const MAX_CAPTURE = 64 * 1024;
|
|
26
|
+
|
|
27
|
+
/** The changed paths a machine-generated sessionSafetyReason names, or [] when it names none. */
|
|
28
|
+
export function parseChangedPaths(reason) {
|
|
29
|
+
if (typeof reason !== 'string' || !reason.startsWith(BOOT_REASON_PREFIX)) return [];
|
|
30
|
+
return reason.slice(BOOT_REASON_PREFIX.length).split(', ').map((entry) => entry.trim()).filter(Boolean);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The hook roles a Codex hooks file registers: the id after the timeout in each `... <timeoutMs> <role> [event]` command. */
|
|
34
|
+
export function registeredCodexRoles(hooksFile) {
|
|
35
|
+
const parsed = JSON.parse(fs.readFileSync(hooksFile, 'utf8'));
|
|
36
|
+
const roles = new Set();
|
|
37
|
+
for (const groups of Object.values(parsed.hooks || {})) {
|
|
38
|
+
for (const group of groups) {
|
|
39
|
+
for (const handler of group.hooks || []) {
|
|
40
|
+
const match = /"\s+\d+\s+([a-z][a-z0-9-]*)(?:\s+\S+)?\s*$/.exec(String(handler.command || ''));
|
|
41
|
+
if (match) roles.add(match[1]);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return [...roles];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Windows cannot start node (or resolve DLLs, temp, shells) without these; copied only when present, never a secret.
|
|
49
|
+
const WINDOWS_NEEDS = ['SystemRoot', 'windir', 'ComSpec', 'PATHEXT'];
|
|
50
|
+
const IS_WIN = process.platform === 'win32';
|
|
51
|
+
|
|
52
|
+
/** SIGKILL the probe's whole process group (it is spawned detached, so it leads one); the child alone on Windows. */
|
|
53
|
+
function killTree(child) {
|
|
54
|
+
try {
|
|
55
|
+
if (!IS_WIN && Number.isInteger(child?.pid) && child.pid > 1) process.kill(-child.pid, 'SIGKILL');
|
|
56
|
+
else child?.kill('SIGKILL');
|
|
57
|
+
} catch { try { child?.kill('SIGKILL'); } catch { /* gone */ } }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function isolatedEnv(tmp, extra = {}, source = process.env) {
|
|
61
|
+
const needs = {};
|
|
62
|
+
for (const name of WINDOWS_NEEDS) if (source[name]) needs[name] = source[name];
|
|
63
|
+
const env = { ...needs, PATH: source.PATH || source.Path || '', HOME: tmp, USERPROFILE: tmp, TMPDIR: tmp,
|
|
64
|
+
RUVNET_BRAIN_HOME: path.join(tmp, 'brain'), RUVNET_BRAIN_STATE_DIR: path.join(tmp, 'state'),
|
|
65
|
+
XDG_CACHE_HOME: path.join(tmp, 'xdg'), XDG_CONFIG_HOME: path.join(tmp, 'xdgc'), CODEX_HOME: path.join(tmp, 'codex'),
|
|
66
|
+
RUVNET_BRAIN_KB: path.join(tmp, 'kb'), RUVNET_NIGHTLY: '', ...extra };
|
|
67
|
+
if (IS_WIN) { env.TEMP = tmp; env.TMP = tmp; } // USERPROFILE is already the isolated tmp; TEMP/TMP must not point at the real profile
|
|
68
|
+
return env;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function runBounded(args, { cwd, env, timeoutMs }) {
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
let out = ''; let err = ''; let timedOut = false; let settled = false;
|
|
74
|
+
const child = spawn(process.execPath, args, { cwd, env, detached: !IS_WIN, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
75
|
+
const finish = (result) => { if (settled) return; settled = true; clearTimeout(timer); resolve({ stdout: out, stderr: err, timedOut, ...result }); };
|
|
76
|
+
const timer = setTimeout(() => { timedOut = true; killTree(child); }, timeoutMs);
|
|
77
|
+
child.stdout.on('data', (b) => { if (out.length < MAX_CAPTURE) out += b; });
|
|
78
|
+
child.stderr.on('data', (b) => { if (err.length < MAX_CAPTURE) err += b; });
|
|
79
|
+
child.once('error', (error) => finish({ code: null, signal: null, spawnError: error.message }));
|
|
80
|
+
child.once('close', (code, signal) => { killTree(child); finish({ code, signal }); });
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Run the INSTALLED shim with an id it does not know. Proven only by exit 0 plus a role list covering every registered role. */
|
|
85
|
+
export async function runShimProbe({ installedRoot, roles, tmp, timeoutMs }) {
|
|
86
|
+
const shim = path.join(installedRoot, 'scripts', 'hook-shim.mjs');
|
|
87
|
+
const started = Date.now();
|
|
88
|
+
const run = await runBounded([shim, 'boot-surface-proof-unknown-hook'], { cwd: tmp, env: isolatedEnv(tmp), timeoutMs });
|
|
89
|
+
const elapsedMs = Date.now() - started;
|
|
90
|
+
if (run.timedOut) return { ok: false, elapsedMs, reason: `the installed hook shim did not exit within ${timeoutMs} ms and was killed` };
|
|
91
|
+
if (run.spawnError) return { ok: false, elapsedMs, reason: `the installed hook shim could not start: ${run.spawnError}` };
|
|
92
|
+
if (run.code !== 0) return { ok: false, elapsedMs, reason: `the installed hook shim exited ${run.code ?? run.signal} for an unknown hook id (expected 0)` };
|
|
93
|
+
const listed = /known:\s*([^\n]*)/.exec(run.stderr)?.[1]?.split(',').map((s) => s.trim()).filter(Boolean) || [];
|
|
94
|
+
const missing = roles.filter((role) => !listed.includes(role));
|
|
95
|
+
if (!listed.length) return { ok: false, elapsedMs, reason: 'the installed hook shim listed no hook roles' };
|
|
96
|
+
if (missing.length) return { ok: false, elapsedMs, listed, reason: `the installed hook shim does not list registered Codex role(s): ${missing.join(', ')}` };
|
|
97
|
+
return { ok: true, elapsedMs, rolesListed: listed.length, rolesRegistered: roles.length };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const STUB_WORKER = "require('node:fs').writeFileSync(process.env.RNB_PROOF_MARKER,String(process.pid));process.exit(0);\n";
|
|
101
|
+
|
|
102
|
+
/** Real stdio MCP handshake against the INSTALLED server: initialize, tools/list. Never starts the real search worker. */
|
|
103
|
+
export async function runMcpProbe({ installedRoot, tmp, timeoutMs }) {
|
|
104
|
+
const server = path.join(installedRoot, 'mcp', 'server.mjs');
|
|
105
|
+
const stub = path.join(tmp, 'stub-worker.cjs'); const marker = path.join(tmp, 'worker-started');
|
|
106
|
+
fs.writeFileSync(stub, STUB_WORKER);
|
|
107
|
+
const env = isolatedEnv(tmp, { RUVNET_BRAIN_CHILD_MCP: stub, RNB_PROOF_MARKER: marker });
|
|
108
|
+
const started = Date.now();
|
|
109
|
+
return await new Promise((resolve) => {
|
|
110
|
+
let buffer = ''; let settled = false; let timedOut = false;
|
|
111
|
+
const replies = new Map(); let stderr = '';
|
|
112
|
+
const child = spawn(process.execPath, [server], { cwd: tmp, env, detached: !IS_WIN, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
113
|
+
const kill = () => killTree(child);
|
|
114
|
+
const deadline = setTimeout(() => { timedOut = true; kill(); }, timeoutMs);
|
|
115
|
+
const send = (message) => { try { child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', ...message })}\n`); } catch { /* exit handler reports */ } };
|
|
116
|
+
const done = (result) => { if (settled) return; settled = true; clearTimeout(deadline); resolve({ elapsedMs: Date.now() - started, ...result }); };
|
|
117
|
+
child.stdout.on('data', (chunk) => {
|
|
118
|
+
buffer += chunk;
|
|
119
|
+
let at;
|
|
120
|
+
while ((at = buffer.indexOf('\n')) >= 0) {
|
|
121
|
+
const line = buffer.slice(0, at); buffer = buffer.slice(at + 1);
|
|
122
|
+
let message; try { message = JSON.parse(line); } catch { continue; }
|
|
123
|
+
if (message.id !== undefined) replies.set(message.id, message);
|
|
124
|
+
if (message.id === 1) { send({ method: 'notifications/initialized' }); send({ id: 2, method: 'tools/list', params: {} }); }
|
|
125
|
+
if (message.id === 2) {
|
|
126
|
+
// initialize launches the warm search worker in the background (plugin/mcp/server.mjs `void ensureChild()`); the
|
|
127
|
+
// stub worker records that it was launched, so the result reports the fact instead of assuming it.
|
|
128
|
+
setTimeout(() => { try { child.stdin.end(); } catch { /* gone */ } }, 400);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
child.stderr.on('data', (b) => { if (stderr.length < MAX_CAPTURE) stderr += b; });
|
|
133
|
+
child.once('error', (error) => done({ ok: false, reason: `the installed MCP server could not start: ${error.message}` }));
|
|
134
|
+
child.once('close', (code, signal) => {
|
|
135
|
+
killTree(child);
|
|
136
|
+
const leaseLeft = fs.existsSync(path.join(tmp, 'brain', 'leases')) && fs.readdirSync(path.join(tmp, 'brain', 'leases')).length > 0;
|
|
137
|
+
const initialize = replies.get(1); const list = replies.get(2);
|
|
138
|
+
const workerStarted = fs.existsSync(marker);
|
|
139
|
+
const base = { exit: code ?? signal, leaseLeftBehind: leaseLeft, initializeStartsWarmWorker: workerStarted };
|
|
140
|
+
if (timedOut) return done({ ok: false, ...base, reason: `the installed MCP server did not finish an initialize + tools/list within ${timeoutMs} ms and was killed` });
|
|
141
|
+
if (!initialize?.result) return done({ ok: false, ...base, reason: 'the installed MCP server did not answer initialize' });
|
|
142
|
+
const names = Array.isArray(list?.result?.tools) ? list.result.tools.map((tool) => tool?.name) : null;
|
|
143
|
+
if (!names) return done({ ok: false, ...base, reason: 'the installed MCP server did not answer tools/list' });
|
|
144
|
+
if (!names.includes('search_ruvnet')) return done({ ok: false, ...base, tools: names, reason: 'the installed MCP server tools/list does not include search_ruvnet' });
|
|
145
|
+
if (leaseLeft) return done({ ok: false, ...base, tools: names, reason: 'the installed MCP server left its lease file behind after exit' });
|
|
146
|
+
return done({ ok: true, ...base, tools: names.length });
|
|
147
|
+
});
|
|
148
|
+
send({ id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'boot-surface-proof', version: '1' } } });
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @param {object} o
|
|
154
|
+
* @param {string[]} o.changedPaths paths the convergence receipt names as changed at boot level
|
|
155
|
+
* @param {string} o.releasedPluginRoot the release being converged to (its plugin/ directory)
|
|
156
|
+
* @param {string} o.installedPluginRoot the plugin tree Codex loads (its plugin cache for the active version)
|
|
157
|
+
* @param {object} [o.declarations] a probeFreshCodexDeclarations() result; required for the declaration paths
|
|
158
|
+
*/
|
|
159
|
+
export async function proveCodexBootSurface({ changedPaths, releasedPluginRoot, installedPluginRoot, declarations = null, timeoutMs = 15_000 } = {}) {
|
|
160
|
+
const proven = []; const unproven = [];
|
|
161
|
+
const result = (extra = {}) => ({ ok: !extra.error && changedPaths?.length > 0 && unproven.length === 0,
|
|
162
|
+
state: !extra.error && changedPaths?.length > 0 && unproven.length === 0 ? 'boot-surface-ready' : 'boot-surface-unproven',
|
|
163
|
+
scope: 'fresh-codex-boot-surface', changed: changedPaths || [], proven, unproven,
|
|
164
|
+
reason: extra.error || (unproven.length ? unproven.map((u) => `${u.path}: ${u.reason}`).join('; ') : null),
|
|
165
|
+
existingWindows: 'unproven', hostRelaunch: 'not-observed', inferenceRequests: 0, ...extra.detail });
|
|
166
|
+
if (!Array.isArray(changedPaths) || !changedPaths.length) return result({ error: 'no changed boot-level paths are recorded, so there is nothing to prove' });
|
|
167
|
+
if (!releasedPluginRoot || !installedPluginRoot) return result({ error: 'the released or installed plugin root is unknown' });
|
|
168
|
+
let diff;
|
|
169
|
+
try { diff = new Set(shellDiff(installedPluginRoot, releasedPluginRoot)); }
|
|
170
|
+
catch (error) { return result({ error: `the installed and released plugin trees could not be compared: ${error.message}` }); }
|
|
171
|
+
const identical = (rel) => !diff.has(rel);
|
|
172
|
+
const declarationsReady = declarations?.ok === true && declarations.state === 'fresh-declarations-ready';
|
|
173
|
+
const wants = (rel) => changedPaths.includes(rel);
|
|
174
|
+
let tmp = null; let shim = null; let mcp = null;
|
|
175
|
+
try {
|
|
176
|
+
if (wants('scripts/hook-shim.mjs') || wants('mcp/server.mjs')) {
|
|
177
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'rnb-boot-proof-'));
|
|
178
|
+
for (const dir of ['brain', 'state', 'xdg', 'xdgc', 'codex', 'kb']) fs.mkdirSync(path.join(tmp, dir), { recursive: true });
|
|
179
|
+
}
|
|
180
|
+
for (const rel of changedPaths) {
|
|
181
|
+
if (!PROVABLE_PATHS.includes(rel)) { unproven.push({ path: rel, reason: 'no prover exists for this path; it stays unproven' }); continue; }
|
|
182
|
+
if (rel === 'hooks/codex-hooks.json' || rel === 'hooks/hooks.json') {
|
|
183
|
+
if (declarationsReady) proven.push({ path: rel, evidence: 'fresh-declarations-ready' });
|
|
184
|
+
else unproven.push({ path: rel, reason: `fresh Codex hook declarations are not proven${declarations?.reason ? ` (${declarations.reason})` : ''}` });
|
|
185
|
+
} else if (rel === 'skills/') {
|
|
186
|
+
if (!identical(rel)) unproven.push({ path: rel, reason: 'the installed skills/ tree is not byte-identical to the release' });
|
|
187
|
+
else if (!declarationsReady) unproven.push({ path: rel, reason: `fresh Codex declarations are not proven${declarations?.reason ? ` (${declarations.reason})` : ''}` });
|
|
188
|
+
else proven.push({ path: rel, evidence: 'byte-identical skills/ tree plus fresh-declarations-ready' });
|
|
189
|
+
} else if (rel === 'scripts/hook-shim.mjs') {
|
|
190
|
+
if (!identical(rel)) { unproven.push({ path: rel, reason: 'the installed hook shim is not byte-identical to the release' }); continue; }
|
|
191
|
+
let roles;
|
|
192
|
+
try { roles = registeredCodexRoles(path.join(installedPluginRoot, 'hooks', 'codex-hooks.json')); }
|
|
193
|
+
catch (error) { unproven.push({ path: rel, reason: `the registered Codex roles could not be read: ${error.message}` }); continue; }
|
|
194
|
+
shim = await runShimProbe({ installedRoot: installedPluginRoot, roles, tmp, timeoutMs });
|
|
195
|
+
if (shim.ok) proven.push({ path: rel, evidence: `byte-identical; real run exited 0 and listed all ${shim.rolesRegistered} registered Codex roles` });
|
|
196
|
+
else unproven.push({ path: rel, reason: shim.reason });
|
|
197
|
+
} else if (rel === 'mcp/server.mjs') {
|
|
198
|
+
if (!identical(rel)) { unproven.push({ path: rel, reason: 'the installed MCP server is not byte-identical to the release' }); continue; }
|
|
199
|
+
mcp = await runMcpProbe({ installedRoot: installedPluginRoot, tmp, timeoutMs });
|
|
200
|
+
if (mcp.ok) proven.push({ path: rel, evidence: `byte-identical; real stdio initialize + tools/list returned ${mcp.tools} tools including search_ruvnet` });
|
|
201
|
+
else unproven.push({ path: rel, reason: mcp.reason });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
} finally {
|
|
205
|
+
if (tmp) { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
206
|
+
}
|
|
207
|
+
return result({ detail: { ...(shim ? { shim } : {}), ...(mcp ? { mcp } : {}) } });
|
|
208
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// DISTINCT-FROM: kb/zip-extract.mjs — that extracts a whole local archive from a file descriptor; this reads only the idmap sidecars of a REMOTE release asset over HTTP Range and never fetches the ~750 MB body.
|
|
3
|
+
//
|
|
4
|
+
// scripts/generation-census.mjs — the public census (public stores, chunks) of a published corpus
|
|
5
|
+
// generation, read straight from its release asset without downloading it.
|
|
6
|
+
//
|
|
7
|
+
// Release QE requires the committed claim surfaces (README, explainer/*) to quote the exact census of
|
|
8
|
+
// the NEWEST corpus generation, and the corpus republishes nightly. Assembling a candidate to learn
|
|
9
|
+
// two numbers costs ~750 MB and a minute; the numbers live in ~200 small `*.big.rvf.idmap.json`
|
|
10
|
+
// members of the zip. So: Range-read the end-of-central-directory, the central directory, and each
|
|
11
|
+
// idmap member; inflate, CRC-check, count `idToLabel` keys; exclude the private fence exactly as
|
|
12
|
+
// scripts/claims-verify.mjs computePublicChunkTotal does. Anything unexpected (zip64, another
|
|
13
|
+
// compression method, a CRC mismatch, a short read) fails loudly — a wrong census is worse than none.
|
|
14
|
+
//
|
|
15
|
+
// node scripts/generation-census.mjs --repo owner/name --tag corpus-sha256-<digest>
|
|
16
|
+
|
|
17
|
+
import crypto from 'node:crypto';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import zlib from 'node:zlib';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { isDirectInvocation } from '../plugin/scripts/entrypoint.mjs';
|
|
24
|
+
|
|
25
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
export const ARCHIVE_ASSET = 'ruvnet-brain.zip';
|
|
27
|
+
export const IDMAP_SUFFIX = '.big.rvf.idmap.json';
|
|
28
|
+
const TAIL_BYTES = 66_000; // EOCD (22) + the largest possible comment (65,535) + the zip64 locator (20)
|
|
29
|
+
const SIG = { eocd: 0x06054b50, zip64Locator: 0x07064b50, central: 0x02014b50, local: 0x04034b50 };
|
|
30
|
+
const U16 = 0xffff;
|
|
31
|
+
const U32 = 0xffffffff;
|
|
32
|
+
const CONCURRENCY = 8;
|
|
33
|
+
|
|
34
|
+
const zip64 = (what) => new Error(`zip64 ${what}: this reader refuses zip64 archives rather than guess at their layout`);
|
|
35
|
+
|
|
36
|
+
/** End-of-central-directory from the archive's last bytes. */
|
|
37
|
+
export function parseEocd(tail) {
|
|
38
|
+
for (let i = tail.length - 22; i >= 0; i -= 1) {
|
|
39
|
+
if (tail.readUInt32LE(i) !== SIG.eocd) continue;
|
|
40
|
+
if (i >= 20 && tail.readUInt32LE(i - 20) === SIG.zip64Locator) throw zip64('end-of-central-directory locator');
|
|
41
|
+
const disk = tail.readUInt16LE(i + 4), cdDisk = tail.readUInt16LE(i + 6);
|
|
42
|
+
const onDisk = tail.readUInt16LE(i + 8), total = tail.readUInt16LE(i + 10);
|
|
43
|
+
const cdSize = tail.readUInt32LE(i + 12), cdOffset = tail.readUInt32LE(i + 16);
|
|
44
|
+
if (total === U16 || onDisk === U16 || cdSize === U32 || cdOffset === U32) throw zip64('marker in the end-of-central-directory');
|
|
45
|
+
if (disk !== 0 || cdDisk !== 0 || onDisk !== total) throw new Error('multi-disk zip archives are not supported');
|
|
46
|
+
return { total, cdSize, cdOffset };
|
|
47
|
+
}
|
|
48
|
+
throw new Error('no end-of-central-directory record in the archive tail');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Every central-directory entry: name, method, flags, crc, sizes, local-header offset. */
|
|
52
|
+
export function parseCentralDirectory(cd, expected) {
|
|
53
|
+
const entries = [];
|
|
54
|
+
let p = 0;
|
|
55
|
+
while (p < cd.length) {
|
|
56
|
+
if (p + 46 > cd.length || cd.readUInt32LE(p) !== SIG.central) throw new Error(`malformed central directory at byte ${p}`);
|
|
57
|
+
const nameLength = cd.readUInt16LE(p + 28), extraLength = cd.readUInt16LE(p + 30), commentLength = cd.readUInt16LE(p + 32);
|
|
58
|
+
const entry = {
|
|
59
|
+
name: cd.subarray(p + 46, p + 46 + nameLength).toString('utf8'),
|
|
60
|
+
flags: cd.readUInt16LE(p + 8), method: cd.readUInt16LE(p + 10), crc: cd.readUInt32LE(p + 16),
|
|
61
|
+
compressedSize: cd.readUInt32LE(p + 20), size: cd.readUInt32LE(p + 24), offset: cd.readUInt32LE(p + 42),
|
|
62
|
+
extraLength,
|
|
63
|
+
};
|
|
64
|
+
if (entry.compressedSize === U32 || entry.size === U32 || entry.offset === U32) throw zip64(`marker on entry ${entry.name}`);
|
|
65
|
+
const extra = cd.subarray(p + 46 + nameLength, p + 46 + nameLength + extraLength);
|
|
66
|
+
for (let q = 0; q + 4 <= extra.length; q += 4 + extra.readUInt16LE(q + 2)) {
|
|
67
|
+
if (extra.readUInt16LE(q) === 0x0001) throw zip64(`extra field on entry ${entry.name}`);
|
|
68
|
+
}
|
|
69
|
+
entries.push(entry);
|
|
70
|
+
p += 46 + nameLength + extraLength + commentLength;
|
|
71
|
+
}
|
|
72
|
+
if (Number.isInteger(expected) && entries.length !== expected) {
|
|
73
|
+
throw new Error(`central directory lists ${entries.length} entries; the end record says ${expected}`);
|
|
74
|
+
}
|
|
75
|
+
return entries;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The public idmap members: no resource forks, no private store, no store counted twice. */
|
|
79
|
+
export function selectIdmapEntries(entries, privateStores = []) {
|
|
80
|
+
const fence = new Set(privateStores);
|
|
81
|
+
const seen = new Map();
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
if (!entry.name.endsWith(IDMAP_SUFFIX) || entry.name.split('/').includes('__MACOSX')) continue;
|
|
84
|
+
const base = path.posix.basename(entry.name);
|
|
85
|
+
if (base.startsWith('._')) continue;
|
|
86
|
+
const store = base.slice(0, -IDMAP_SUFFIX.length);
|
|
87
|
+
if (fence.has(store)) continue;
|
|
88
|
+
if (seen.has(store)) throw new Error(`store ${store} has two idmap members (${seen.get(store).name}, ${entry.name})`);
|
|
89
|
+
seen.set(store, { ...entry, store });
|
|
90
|
+
}
|
|
91
|
+
return [...seen.values()];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** One member's bytes from a buffer that starts at its local header; null when the buffer is too short. */
|
|
95
|
+
export function decodeEntry(entry, local) {
|
|
96
|
+
if (local.length < 30 || local.readUInt32LE(0) !== SIG.local) throw new Error(`no local header for ${entry.name}`);
|
|
97
|
+
const start = 30 + local.readUInt16LE(26) + local.readUInt16LE(28);
|
|
98
|
+
if (local.length < start + entry.compressedSize) return { need: start + entry.compressedSize };
|
|
99
|
+
const raw = local.subarray(start, start + entry.compressedSize);
|
|
100
|
+
let data;
|
|
101
|
+
if (entry.method === 0) data = raw;
|
|
102
|
+
else if (entry.method === 8) data = zlib.inflateRawSync(raw);
|
|
103
|
+
else throw new Error(`${entry.name} uses compression method ${entry.method}; only stored (0) and deflate (8) are read`);
|
|
104
|
+
if (typeof zlib.crc32 !== 'function') throw new Error('zlib.crc32 is unavailable (Node >= 20.15 / 22.2 required) — cannot verify members');
|
|
105
|
+
if (data.length !== entry.size) throw new Error(`${entry.name} inflated to ${data.length} bytes; the directory says ${entry.size}`);
|
|
106
|
+
const crc = zlib.crc32(data) >>> 0;
|
|
107
|
+
if (crc !== entry.crc >>> 0) throw new Error(`${entry.name} failed its CRC-32 check (expected ${entry.crc.toString(16)}, got ${crc.toString(16)})`);
|
|
108
|
+
return { data };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function countIdmap(buffer, name) {
|
|
112
|
+
const { idToLabel } = JSON.parse(buffer.toString('utf8'));
|
|
113
|
+
if (!idToLabel || typeof idToLabel !== 'object' || Array.isArray(idToLabel)) throw new Error(`${name} has no idToLabel object`);
|
|
114
|
+
return Object.keys(idToLabel).length;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function readPrivateStores(file = path.join(ROOT, 'kb', 'PRIVATE-STORES.json')) {
|
|
118
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
119
|
+
if (!Array.isArray(parsed?.privateStores)) throw new Error(`${file} lacks a privateStores array`);
|
|
120
|
+
return [...parsed.privateStores].sort();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The release asset row (id, size, url) from the GitHub API. */
|
|
124
|
+
async function resolveAsset({ repo, tag, fetch, token }) {
|
|
125
|
+
const headers = { Accept: 'application/vnd.github+json', ...(token ? { Authorization: `Bearer ${token}` } : {}) };
|
|
126
|
+
const response = await fetch(`https://api.github.com/repos/${repo}/releases/tags/${encodeURIComponent(tag)}`, { headers });
|
|
127
|
+
if (!response.ok) throw new Error(`release ${tag}: GitHub API answered HTTP ${response.status}`);
|
|
128
|
+
const assets = ((await response.json())?.assets || []).filter((asset) => asset?.name === ARCHIVE_ASSET);
|
|
129
|
+
if (assets.length !== 1) throw new Error(`release ${tag} has ${assets.length} ${ARCHIVE_ASSET} assets; expected exactly one`);
|
|
130
|
+
return { id: assets[0].id, size: assets[0].size, url: assets[0].browser_download_url };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Range reads against the asset; the redirect is resolved once and re-resolved if the signed URL lapses. */
|
|
134
|
+
function rangeReader({ url, size, fetch }) {
|
|
135
|
+
let target = url;
|
|
136
|
+
let fetched = 0;
|
|
137
|
+
const read = async (start, end, retried = false) => {
|
|
138
|
+
const response = await fetch(target, { headers: { Range: `bytes=${start}-${end}` }, redirect: 'follow' });
|
|
139
|
+
if (response.status !== 206) {
|
|
140
|
+
if (!retried && target !== url) { target = url; return read(start, end, true); }
|
|
141
|
+
throw new Error(`range ${start}-${end} of ${url}: expected HTTP 206, got ${response.status}`);
|
|
142
|
+
}
|
|
143
|
+
const total = Number(/\/(\d+)$/.exec(response.headers.get('content-range') || '')?.[1]);
|
|
144
|
+
if (total !== size) throw new Error(`asset is ${total} bytes; the release says ${size}`);
|
|
145
|
+
if (response.url) target = response.url;
|
|
146
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
147
|
+
if (buffer.length !== end - start + 1) throw new Error(`short range read ${start}-${end}: ${buffer.length} bytes`);
|
|
148
|
+
fetched += buffer.length;
|
|
149
|
+
return buffer;
|
|
150
|
+
};
|
|
151
|
+
return { read, fetched: () => fetched };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function pool(items, limit, work) {
|
|
155
|
+
const out = new Array(items.length);
|
|
156
|
+
let next = 0;
|
|
157
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
158
|
+
while (next < items.length) { const i = next++; out[i] = await work(items[i]); }
|
|
159
|
+
}));
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function rangeCensus({ repo, tag, bytes = null, privateStoresFile, fetch = globalThis.fetch,
|
|
164
|
+
cacheDir = os.tmpdir(), token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || null, asset = null,
|
|
165
|
+
concurrency = CONCURRENCY } = {}) {
|
|
166
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(String(repo || ''))) throw new Error('repo must be owner/name');
|
|
167
|
+
if (!/^[A-Za-z0-9._-]+$/.test(String(tag || ''))) throw new Error(`invalid release tag: ${tag}`);
|
|
168
|
+
const privateStores = readPrivateStores(privateStoresFile);
|
|
169
|
+
const resolved = asset || await resolveAsset({ repo, tag, fetch, token });
|
|
170
|
+
if (!Number.isSafeInteger(resolved.size) || resolved.size < 22) throw new Error(`release ${tag} asset has no usable size`);
|
|
171
|
+
if (bytes !== null && bytes !== resolved.size) throw new Error(`release ${tag} asset is ${resolved.size} bytes; expected ${bytes}`);
|
|
172
|
+
|
|
173
|
+
const key = crypto.createHash('sha256').update(JSON.stringify({ repo, tag, assetId: resolved.id, size: resolved.size, privateStores }))
|
|
174
|
+
.digest('hex').slice(0, 32);
|
|
175
|
+
const cacheFile = path.join(cacheDir, 'ruvnet-brain-generation-census', `${key}.json`);
|
|
176
|
+
try {
|
|
177
|
+
const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
|
178
|
+
if (cached?.tag === tag && Number.isSafeInteger(cached.chunks)) return { ...cached, cached: true };
|
|
179
|
+
} catch { /* no usable cache entry: measure */ }
|
|
180
|
+
|
|
181
|
+
const started = Date.now();
|
|
182
|
+
const { read, fetched } = rangeReader({ url: resolved.url || `https://github.com/${repo}/releases/download/${tag}/${ARCHIVE_ASSET}`,
|
|
183
|
+
size: resolved.size, fetch });
|
|
184
|
+
const tailStart = Math.max(0, resolved.size - TAIL_BYTES);
|
|
185
|
+
const eocd = parseEocd(await read(tailStart, resolved.size - 1));
|
|
186
|
+
if (eocd.cdOffset + eocd.cdSize > resolved.size) throw new Error('central directory lies outside the archive');
|
|
187
|
+
const entries = parseCentralDirectory(await read(eocd.cdOffset, eocd.cdOffset + eocd.cdSize - 1), eocd.total);
|
|
188
|
+
const selected = selectIdmapEntries(entries, privateStores);
|
|
189
|
+
if (!selected.length) throw new Error(`release ${tag} contains no public *${IDMAP_SUFFIX} members`);
|
|
190
|
+
const counts = await pool(selected, concurrency, async (entry) => {
|
|
191
|
+
const guess = 30 + Buffer.byteLength(entry.name) + entry.extraLength + entry.compressedSize + 256;
|
|
192
|
+
let local = await read(entry.offset, Math.min(resolved.size, entry.offset + guess) - 1);
|
|
193
|
+
let decoded = decodeEntry(entry, local);
|
|
194
|
+
if (decoded.need) {
|
|
195
|
+
local = await read(entry.offset, entry.offset + decoded.need - 1);
|
|
196
|
+
decoded = decodeEntry(entry, local);
|
|
197
|
+
if (decoded.need) throw new Error(`${entry.name}: local header disagrees with the central directory`);
|
|
198
|
+
}
|
|
199
|
+
return countIdmap(decoded.data, entry.name);
|
|
200
|
+
});
|
|
201
|
+
const result = { tag, publicStores: selected.length, chunks: counts.reduce((a, b) => a + b, 0), builtStores: selected.length,
|
|
202
|
+
assetId: resolved.id ?? null, archiveBytes: resolved.size, fetchedBytes: fetched(), entries: entries.length,
|
|
203
|
+
seconds: Math.round((Date.now() - started) / 100) / 10 };
|
|
204
|
+
try {
|
|
205
|
+
fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
|
|
206
|
+
const temp = `${cacheFile}.${process.pid}.tmp`;
|
|
207
|
+
fs.writeFileSync(temp, `${JSON.stringify(result, null, 2)}\n`);
|
|
208
|
+
fs.renameSync(temp, cacheFile);
|
|
209
|
+
} catch { /* a cache is an optimisation; the measured result stands */ }
|
|
210
|
+
return { ...result, cached: false };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const arg = (argv, name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; };
|
|
214
|
+
|
|
215
|
+
if (isDirectInvocation(import.meta.url)) {
|
|
216
|
+
const argv = process.argv.slice(2);
|
|
217
|
+
rangeCensus({ repo: arg(argv, '--repo') || process.env.GITHUB_REPOSITORY, tag: arg(argv, '--tag') })
|
|
218
|
+
.then((result) => process.stdout.write(`${JSON.stringify(result, null, 2)}\n`))
|
|
219
|
+
.catch((error) => { console.error(`[generation-census] ${error.message}`); process.exitCode = 1; });
|
|
220
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// scripts/issue-autofix-agents.mjs — how the daily autofix job (ADR-107) spawns `claude -p`.
|
|
2
|
+
//
|
|
3
|
+
// Four LLM stages (plan / build / verify / note) with no git, gh or network access: the orchestrator does every
|
|
4
|
+
// push and API call itself. This module only decides WHAT is spawned (exact argv, tool allowlist, model alias,
|
|
5
|
+
// stripped environment, wall clock) and PROVES what answered (the model named in the result, not the one we asked
|
|
6
|
+
// for). It never asks the CLI to bypass permissions: least-privilege --allowedTools instead.
|
|
7
|
+
//
|
|
8
|
+
// Verified live against claude 2.1.312 (2026-10-29): --max-turns (undocumented in --help but accepted),
|
|
9
|
+
// --json-schema with --output-format json returns the object in `structured_output` and the models that served the
|
|
10
|
+
// call as the keys of `modelUsage`.
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import { AutofixAbort } from './issue-autofix-policy.mjs';
|
|
16
|
+
|
|
17
|
+
// NO stage can run a command: Bash is absent from every tool list, and --restricted removes every code-running tool
|
|
18
|
+
// that --tools does not name. Code a model wrote runs only in the orchestrator, under the macOS sandbox
|
|
19
|
+
// (issue-autofix-sandbox.mjs), after the static security scan. The note and red-team stages get no tools at all and
|
|
20
|
+
// run in an empty temporary directory: they see only what the prompt hands them.
|
|
21
|
+
export const STAGE_CONFIG = Object.freeze({
|
|
22
|
+
plan: { model: 'opus', maxTurns: 40, timeoutMs: 20 * 60_000, tools: ['Read', 'Glob', 'Grep'], schema: 'config/issue-autofix/plan.schema.json', family: /opus/ },
|
|
23
|
+
build: { model: 'sonnet', maxTurns: 80, timeoutMs: 45 * 60_000, tools: ['Read', 'Edit', 'Write', 'Glob', 'Grep'], schema: null, family: /sonnet|opus/ },
|
|
24
|
+
verify: { model: 'opus', maxTurns: 60, timeoutMs: 30 * 60_000, tools: ['Read', 'Glob', 'Grep'], schema: 'config/issue-autofix/verify.schema.json', family: /opus/ },
|
|
25
|
+
redteam: { model: 'opus', maxTurns: 4, timeoutMs: 15 * 60_000, tools: [], schema: 'config/issue-autofix/redteam.schema.json', family: /opus/ },
|
|
26
|
+
note: { model: 'opus', maxTurns: 4, timeoutMs: 5 * 60_000, tools: [], schema: 'config/issue-autofix/note.schema.json', family: /opus/ },
|
|
27
|
+
});
|
|
28
|
+
export const GRACE_MS = 20_000;
|
|
29
|
+
const MAX_STDOUT = 32 * 1024 * 1024;
|
|
30
|
+
const NO_MCP = 'config/issue-autofix/no-mcp.json';
|
|
31
|
+
|
|
32
|
+
// BILLING SAFETY (issue-fix.mjs): an ambient API key either fails the run (stale key) or bills it. The git/gh
|
|
33
|
+
// credentials go too, so an LLM stage that somehow got a shell still cannot push or comment.
|
|
34
|
+
export const STRIPPED_ENV = [/^ANTHROPIC_/, /^CLAUDE_API_KEY$/, /^CLAUDE_CODE_USE_/, /^GH_TOKEN$/, /^GITHUB_TOKEN$/, /^GH_ENTERPRISE_TOKEN$/, /^SSH_AUTH_SOCK$/];
|
|
35
|
+
|
|
36
|
+
// SUBSCRIPTION LOGIN UNDER LAUNCHD (measured 2026-10-30). Under launchd the login keychain is readable, and the
|
|
37
|
+
// "Claude Code-credentials" item there was an EXPIRED OAuth session that could not refresh: `claude auth status`
|
|
38
|
+
// said loggedIn false. Claude Code's file login (~/.claude/.credentials.json), which the owner's own sessions keep
|
|
39
|
+
// fresh, is the one that works; claude uses it when the keychain cannot be reached. So on macOS every stage runs
|
|
40
|
+
// with keychain lookups denied: measured under a test LaunchAgent, loggedIn true (max) and served by opus. It also
|
|
41
|
+
// means no stage can read any other keychain item. No API key is ever used (BILLING SAFETY above).
|
|
42
|
+
export const KEYCHAIN_DENY_PROFILE = '(version 1)(allow default)(deny mach-lookup (global-name "com.apple.SecurityServer") '
|
|
43
|
+
+ '(global-name "com.apple.securityd.xpc") (global-name "com.apple.security.agent") (global-name "com.apple.secd"))';
|
|
44
|
+
const SANDBOX_EXEC = '/usr/bin/sandbox-exec';
|
|
45
|
+
/** [command, args] that start claude without keychain access on macOS (the real spawn only; an injected spawn is not wrapped). */
|
|
46
|
+
export function claudeCommand(claudeBin, args, { platform = process.platform, exists = fs.existsSync } = {}) {
|
|
47
|
+
return platform === 'darwin' && exists(SANDBOX_EXEC) ? [SANDBOX_EXEC, ['-p', KEYCHAIN_DENY_PROFILE, claudeBin, ...args]] : [claudeBin, args];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function sanitizedEnv(baseEnv, { ghConfigDir }) {
|
|
51
|
+
const env = {};
|
|
52
|
+
for (const [k, v] of Object.entries(baseEnv || {})) {
|
|
53
|
+
if (STRIPPED_ENV.some((re) => re.test(k)) || /^GIT_CONFIG_/.test(k)) continue;
|
|
54
|
+
env[k] = v;
|
|
55
|
+
}
|
|
56
|
+
return { ...env, GH_CONFIG_DIR: ghConfigDir, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '/usr/bin/false',
|
|
57
|
+
GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'credential.helper', GIT_CONFIG_VALUE_0: '' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const stageOf = (stage) => { const c = STAGE_CONFIG[stage]; if (!c) throw new Error(`unknown stage: ${stage}`); return c; };
|
|
61
|
+
|
|
62
|
+
export function buildClaudeArgs(stage, prompt, { root }) {
|
|
63
|
+
const c = stageOf(stage);
|
|
64
|
+
const schema = c.schema ? ['--json-schema', JSON.stringify(JSON.parse(fs.readFileSync(path.join(root, c.schema), 'utf8')))] : [];
|
|
65
|
+
// --restricted: no code-running tool, user/project/local settings ignored (so a hook or permission the build stage
|
|
66
|
+
// writes into the worktree never loads), file tools confined to the cwd, writes to settings/git files refused.
|
|
67
|
+
// --safe-mode: no CLAUDE.md, skills, plugins, hooks or MCP from anywhere. Both measured live on claude 2.1.314
|
|
68
|
+
// (2026-10-30): planted .claude/settings.json hook and CLAUDE.md inert; reads/writes outside the cwd refused.
|
|
69
|
+
// --permission-prompts none: anything that would ask is denied. Subscription OAuth still works (no API key used).
|
|
70
|
+
const tools = c.tools.length ? c.tools.join(',') : '';
|
|
71
|
+
const allow = c.tools.length ? ['--allowedTools', ...c.tools] : [];
|
|
72
|
+
return ['-p', prompt, '--model', c.model, '--max-turns', String(c.maxTurns), '--output-format', 'json', ...schema,
|
|
73
|
+
'--restricted', '--safe-mode', '--tools', tools, ...allow, '--disallowedTools', 'Bash', 'WebFetch', 'WebSearch', 'Task',
|
|
74
|
+
'--permission-prompts', 'none', '--strict-mcp-config', '--mcp-config', path.join(root, NO_MCP),
|
|
75
|
+
'--no-session-persistence', '--max-budget-usd', '5'];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export { buildPrompt } from './issue-autofix-prompts.mjs';
|
|
79
|
+
import { buildPrompt } from './issue-autofix-prompts.mjs';
|
|
80
|
+
|
|
81
|
+
// ── minimal JSON-schema check (type/enum/const/required/additionalProperties/items/min/max). The claude CLI
|
|
82
|
+
// validates too; this keeps a hallucinated shape from ever reaching the policy code. ──
|
|
83
|
+
function check(schema, v, at, errs) {
|
|
84
|
+
if ('const' in schema && v !== schema.const) errs.push(`${at}: must be ${JSON.stringify(schema.const)}`);
|
|
85
|
+
if (schema.enum && !schema.enum.includes(v)) errs.push(`${at}: not one of ${schema.enum.join('|')}`);
|
|
86
|
+
const t = schema.type;
|
|
87
|
+
const actual = Array.isArray(v) ? 'array' : v === null ? 'null' : Number.isInteger(v) ? 'integer' : typeof v;
|
|
88
|
+
if (t && !(t === actual || (t === 'number' && actual === 'integer'))) { errs.push(`${at}: expected ${t}, got ${actual}`); return; }
|
|
89
|
+
if (t === 'string') {
|
|
90
|
+
if (schema.maxLength != null && v.length > schema.maxLength) errs.push(`${at}: longer than ${schema.maxLength}`);
|
|
91
|
+
if (schema.pattern && !new RegExp(schema.pattern).test(v)) errs.push(`${at}: does not match ${schema.pattern}`);
|
|
92
|
+
}
|
|
93
|
+
if (t === 'integer' && schema.minimum != null && v < schema.minimum) errs.push(`${at}: below ${schema.minimum}`);
|
|
94
|
+
if (t === 'array') {
|
|
95
|
+
if (schema.minItems != null && v.length < schema.minItems) errs.push(`${at}: fewer than ${schema.minItems} items`);
|
|
96
|
+
if (schema.maxItems != null && v.length > schema.maxItems) errs.push(`${at}: more than ${schema.maxItems} items`);
|
|
97
|
+
if (schema.items) v.forEach((x, i) => check(schema.items, x, `${at}[${i}]`, errs));
|
|
98
|
+
}
|
|
99
|
+
if (t === 'object') {
|
|
100
|
+
for (const k of schema.required || []) if (!(k in v)) errs.push(`${at}.${k}: missing`);
|
|
101
|
+
for (const [k, x] of Object.entries(v)) {
|
|
102
|
+
if (schema.properties?.[k]) check(schema.properties[k], x, `${at}.${k}`, errs);
|
|
103
|
+
else if (schema.additionalProperties === false) errs.push(`${at}.${k}: not allowed`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The model that served the call: the key of `modelUsage` that cost the most (helper calls to a smaller model do
|
|
109
|
+
// not count), else the `model` field.
|
|
110
|
+
function resolveModel(out) {
|
|
111
|
+
const usage = out?.modelUsage && typeof out.modelUsage === 'object' ? Object.entries(out.modelUsage) : [];
|
|
112
|
+
if (usage.length) return usage.sort((a, b) => (Number(b[1]?.costUSD) || 0) - (Number(a[1]?.costUSD) || 0))[0][0];
|
|
113
|
+
return typeof out?.model === 'string' && out.model ? out.model : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function assertModelFamily(stage, resolvedModel) {
|
|
117
|
+
const c = stageOf(stage);
|
|
118
|
+
if (!resolvedModel || !c.family.test(resolvedModel)) {
|
|
119
|
+
throw new AutofixAbort(stage, 'model-downgrade', `asked for ${c.model}; ${resolvedModel ? `served by ${resolvedModel}` : 'the result names no model'}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function runAgent({ stage, cwd, input, claudeBin, logPath, root, ghConfigDir, timeoutMs, graceMs, spawnImpl = spawn }) {
|
|
124
|
+
const c = stageOf(stage);
|
|
125
|
+
const limit = timeoutMs ?? STAGE_CONFIG[stage].timeoutMs;
|
|
126
|
+
const grace = graceMs ?? GRACE_MS;
|
|
127
|
+
const t0 = Date.now();
|
|
128
|
+
const ownDir = ghConfigDir ? null : fs.mkdtempSync(path.join(os.tmpdir(), 'autofix-gh-'));
|
|
129
|
+
const finish = (r) => ({ ok: false, stage, exitCode: null, signal: null, timedOut: false, resolvedModel: null, result: null, error: null, logPath, ...r, durationMs: Date.now() - t0 });
|
|
130
|
+
try {
|
|
131
|
+
// Behind sandbox-exec a missing binary would only show as an exit code: say what is wrong plainly.
|
|
132
|
+
if (path.isAbsolute(claudeBin) && !fs.existsSync(claudeBin)) return finish({ error: `spawn failed: ENOENT ${claudeBin}` });
|
|
133
|
+
const args = buildClaudeArgs(stage, buildPrompt(stage, input), { root });
|
|
134
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
135
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
136
|
+
fs.writeSync(logFd, `===== autofix ${stage} — started ${new Date().toISOString()} (model ${c.model}, max-turns ${c.maxTurns}, timeout ${limit}ms) =====\n`);
|
|
137
|
+
const env = sanitizedEnv(process.env, { ghConfigDir: ghConfigDir || ownDir });
|
|
138
|
+
const res = await new Promise((resolve) => {
|
|
139
|
+
let stdout = ''; let timedOut = false; let killTimer; let graceTimer; let child;
|
|
140
|
+
const [cmd, argv] = spawnImpl === spawn ? claudeCommand(claudeBin, args) : [claudeBin, args];
|
|
141
|
+
try { child = spawnImpl(cmd, argv, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }); } catch (e) { resolve({ spawnError: e }); return; }
|
|
142
|
+
child.on('error', (e) => { clearTimeout(killTimer); clearTimeout(graceTimer); resolve({ spawnError: e }); });
|
|
143
|
+
child.stdout.on('data', (d) => { if (stdout.length < MAX_STDOUT) stdout += d; fs.writeSync(logFd, d); });
|
|
144
|
+
child.stderr.on('data', (d) => fs.writeSync(logFd, d));
|
|
145
|
+
killTimer = setTimeout(() => {
|
|
146
|
+
timedOut = true;
|
|
147
|
+
fs.writeSync(logFd, `\n===== WALL-CLOCK TIMEOUT (${limit}ms) — SIGTERM =====\n`);
|
|
148
|
+
child.kill('SIGTERM');
|
|
149
|
+
graceTimer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* already dead */ } }, grace);
|
|
150
|
+
}, limit);
|
|
151
|
+
child.on('close', (code, signal) => { clearTimeout(killTimer); clearTimeout(graceTimer); resolve({ code, signal, timedOut, stdout }); });
|
|
152
|
+
});
|
|
153
|
+
try { fs.writeSync(logFd, `\n===== autofix ${stage} — ended ${new Date().toISOString()} =====\n`); fs.closeSync(logFd); } catch { /* log only */ }
|
|
154
|
+
if (res.spawnError) return finish({ error: `spawn failed: ${res.spawnError.message}` });
|
|
155
|
+
const base = { exitCode: res.code, signal: res.signal, timedOut: res.timedOut };
|
|
156
|
+
if (res.timedOut) return finish({ ...base, error: `${stage} timed out after ${limit}ms` });
|
|
157
|
+
let out;
|
|
158
|
+
try { out = JSON.parse(res.stdout); } catch { out = null; }
|
|
159
|
+
const resolvedModel = resolveModel(out);
|
|
160
|
+
if (res.code !== 0) return finish({ ...base, resolvedModel, error: `claude exited with exit ${res.code}${res.signal ? ` (${res.signal})` : ''}` });
|
|
161
|
+
if (!out || typeof out !== 'object') return finish({ ...base, error: 'claude output was not JSON' });
|
|
162
|
+
if (out.is_error || (out.subtype && out.subtype !== 'success')) return finish({ ...base, resolvedModel, error: `claude reported ${out.subtype || 'error'}` });
|
|
163
|
+
assertModelFamily(stage, resolvedModel); // fail closed: never accept work from a model we did not ask for
|
|
164
|
+
if (!c.schema) return finish({ ...base, ok: true, resolvedModel });
|
|
165
|
+
let result = out.structured_output;
|
|
166
|
+
if (result === undefined && typeof out.result === 'string') { try { result = JSON.parse(out.result); } catch { /* handled below */ } }
|
|
167
|
+
if (!result || typeof result !== 'object') return finish({ ...base, resolvedModel, error: 'no structured output in the result' });
|
|
168
|
+
const errs = []; check(JSON.parse(fs.readFileSync(path.join(root, c.schema), 'utf8')), result, '$', errs);
|
|
169
|
+
if (errs.length) return finish({ ...base, resolvedModel, error: `structured output failed the schema: ${errs.slice(0, 5).join('; ')}` });
|
|
170
|
+
return finish({ ...base, ok: true, resolvedModel, result });
|
|
171
|
+
} finally { if (ownDir) fs.rmSync(ownDir, { recursive: true, force: true }); }
|
|
172
|
+
}
|