ruvnet-brain 4.5.3 → 4.5.5
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 +148 -31
- package/config/model-router/catalog.template.json +126 -52
- package/config/model-router/policy.default.mjs +94 -75
- package/config/model-router/qualification-contract.json +124 -0
- package/config/model-router/routing-eval-cases.json +275 -0
- package/config/model-router/routing-policy.template.json +76 -0
- package/config/model-router/weekly-analyst-instruction.md +60 -0
- package/data/model-catalog.json +44 -49
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +40 -3
- package/plugin/hooks/hook-contracts.json +218 -19
- package/plugin/hooks/hooks.json +51 -2
- package/plugin/scripts/agentdb-recall.mjs +101 -30
- package/plugin/scripts/codex-hook-adapter.mjs +18 -9
- package/plugin/scripts/continuity-hook-policy.mjs +9 -0
- package/plugin/scripts/continuity-journal.mjs +33 -33
- package/plugin/scripts/ground-ruvnet.sh +5 -5
- package/plugin/scripts/hook-shim.mjs +4 -30
- package/plugin/scripts/project-capture-queue.mjs +333 -0
- package/plugin/scripts/project-progression-contract.mjs +1 -1
- package/plugin/scripts/project-progression-hook.mjs +3 -3
- package/plugin/scripts/project-progression-producer.mjs +53 -28
- package/plugin/scripts/project-progression-session-start.mjs +44 -4
- package/plugin/scripts/project-progression-store.mjs +14 -0
- package/plugin/scripts/project-transition-hook.mjs +204 -0
- package/plugin/scripts/session-snapshot-hook.mjs +44 -272
- package/plugin/scripts/session-start-budget.mjs +2 -2
- package/plugin/scripts/turn-outcome-capture.mjs +125 -47
- package/plugin/scripts/turn-transport-journal.mjs +106 -0
- package/scripts/codex-hook-trust-reconcile.mjs +247 -0
- package/scripts/codex-routed.sh +3 -36
- package/scripts/goldie-weekly.sh +8 -64
- package/scripts/metaharness-router.mjs +7 -1
- package/scripts/model-analyst-sandbox.mjs +54 -0
- package/scripts/model-currency-evidence.mjs +139 -0
- package/scripts/model-currency.mjs +230 -0
- package/scripts/model-native-catalog.mjs +111 -0
- package/scripts/model-native-qualification.mjs +251 -0
- package/scripts/model-router-agent-hook.mjs +136 -0
- package/scripts/model-router-dispatch.mjs +161 -0
- package/scripts/model-router-engine.mjs +155 -104
- package/scripts/model-routing-eval.mjs +108 -0
- package/scripts/model-routing-gateway.mjs +420 -0
- package/scripts/model-routing-launchers.mjs +174 -0
- package/scripts/model-routing-policy-promotion.mjs +203 -0
- package/scripts/model-weekly-analyst.mjs +299 -0
- package/scripts/model-weekly-assessment.mjs +91 -0
- package/scripts/model-weekly-cycle.mjs +183 -0
- package/scripts/model-weekly-qualification.mjs +362 -0
- package/scripts/native-subscription-usage.mjs +57 -0
- package/scripts/release-qualification-contract.mjs +54 -0
- package/scripts/security-guidance-codex-compat.mjs +142 -0
- package/scripts/user-model-prompt-hook.mjs +69 -0
|
@@ -31,9 +31,8 @@
|
|
|
31
31
|
*
|
|
32
32
|
* LATENCY: Stop runs synchronously in the host's turn, and one `ruflo memory store` costs ~0.7s
|
|
33
33
|
* (measured). So the writes run in ONE detached worker (this file, `--run-steps`), store then
|
|
34
|
-
* distill in order; the hook itself only reads, fingerprints and spawns. A
|
|
35
|
-
*
|
|
36
|
-
* write is visible rather than silent. Advisory always: nothing here throws to the caller.
|
|
34
|
+
* distill in order; the hook itself only reads, fingerprints and spawns. A project-local journal containing redacted content and canonical binding is fsynced BEFORE
|
|
35
|
+
* spawning; fresh sessions replay stable keys without upsert and require exact readback receipts. Advisory always: nothing here throws to the caller.
|
|
37
36
|
*/
|
|
38
37
|
import fs from 'node:fs';
|
|
39
38
|
import os from 'node:os';
|
|
@@ -44,6 +43,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
44
43
|
import { resolveRuflo, rufloInvocation } from './ruflo-bin.mjs';
|
|
45
44
|
import { redactText, userLevelAgentdbHooks } from './continuity-events.mjs';
|
|
46
45
|
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
46
|
+
import { digest, journalTurn, readJournal, pendingTurnFiles, acknowledgeJournal, appendReceipt, storeData } from './turn-transport-journal.mjs';
|
|
47
47
|
|
|
48
48
|
export const TURN_NAMESPACE = 'turns';
|
|
49
49
|
export const MIN_OUTCOME_CHARS = 200;
|
|
@@ -154,8 +154,9 @@ const consentMap = (value) => value !== null && typeof value === 'object'
|
|
|
154
154
|
&& !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
|
|
155
155
|
&& Object.values(value).every((setting) => setting === 'on' || setting === 'off');
|
|
156
156
|
|
|
157
|
-
export function resolveTurnDb({ projectDir, brainHome, requestedStorePath, gitTimeoutMs = 1000 } = {}) {
|
|
157
|
+
export function resolveTurnDb({ projectDir, brainHome, requestedStorePath, gitTimeoutMs = 1000, unknownOriginalPath = false } = {}) {
|
|
158
158
|
const resolved = resolveProjectStore({ projectDir, requestedStorePath, gitTimeoutMs });
|
|
159
|
+
const capturePath = fs.realpathSync.native(projectDir);
|
|
159
160
|
let policy = {};
|
|
160
161
|
const file = brainHome && turnCapturePolicyFile(brainHome);
|
|
161
162
|
if (file && fs.existsSync(file)) {
|
|
@@ -166,15 +167,19 @@ export function resolveTurnDb({ projectDir, brainHome, requestedStorePath, gitTi
|
|
|
166
167
|
} catch { return { skipped: 'turn capture policy unreadable or invalid', projectRoot: resolved.projectRoot }; }
|
|
167
168
|
}
|
|
168
169
|
// A path rule wins over a project rule, allowing a linked checkout/subdirectory to opt out.
|
|
169
|
-
const setting = policy.paths?.[
|
|
170
|
+
const setting = policy.paths?.[capturePath] ?? policy.projects?.[resolved.projectRoot];
|
|
170
171
|
if (setting !== undefined && !['on', 'off'].includes(setting)) return { skipped: 'invalid turn capture consent', projectRoot: resolved.projectRoot };
|
|
171
172
|
const db = resolved.canonicalAgentDbPath;
|
|
173
|
+
if (unknownOriginalPath && Object.entries(policy.paths || {}).some(([origin, choice]) => choice === 'off'
|
|
174
|
+
&& (origin === resolved.checkoutRoot || origin.startsWith(`${resolved.checkoutRoot}${path.sep}`)))) {
|
|
175
|
+
return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, skipped: 'capture origin cannot be verified under path opt-out' };
|
|
176
|
+
}
|
|
172
177
|
assertTurnStoreFiles(db);
|
|
173
|
-
if (setting === 'off') return { db, scope: 'project', projectRoot: resolved.projectRoot, skipped: 'persisted turn capture opt-out' };
|
|
178
|
+
if (setting === 'off') return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, skipped: 'persisted turn capture opt-out' };
|
|
174
179
|
let exists = false;
|
|
175
180
|
try { exists = fs.statSync(db).isFile(); } catch { /* absent */ }
|
|
176
|
-
if (!exists && setting !== 'on') return { db, scope: 'project', projectRoot: resolved.projectRoot, skipped: 'no project memory db; persisted opt-in required' };
|
|
177
|
-
return { db, scope: 'project', projectRoot: resolved.projectRoot, optedIn: setting === 'on' };
|
|
181
|
+
if (!exists && setting !== 'on') return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, skipped: 'no project memory db; persisted opt-in required' };
|
|
182
|
+
return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, optedIn: setting === 'on' };
|
|
178
183
|
}
|
|
179
184
|
|
|
180
185
|
// Revalidation narrows the queue-to-launch window; it does not make SQLite's later open atomic.
|
|
@@ -197,9 +202,9 @@ function assertTurnStoreFiles(db) {
|
|
|
197
202
|
const projectName = (projectDir) => redactText(path.basename(path.resolve(projectDir))).replace(/[^A-Za-z0-9._-]/g, '_') || 'project';
|
|
198
203
|
|
|
199
204
|
/** Detached worker launch: the hook returns immediately; the worker runs the steps in order. */
|
|
200
|
-
export function launchDetached(steps, { receipts }) {
|
|
201
|
-
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '--run-steps', JSON.stringify({ steps, receipts })], {
|
|
202
|
-
cwd: os.tmpdir(), detached: true, stdio: 'ignore', windowsHide: true, env: { ...
|
|
205
|
+
export function launchDetached(steps, { receipts, brainHome, projectDir, env = process.env }) {
|
|
206
|
+
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '--run-steps', JSON.stringify({ steps: steps.map((step) => step.kind === 'store' && step.journalFile ? { kind: 'journal', journalFile: step.journalFile, db: step.args[step.args.indexOf('--path') + 1] } : step), receipts })], {
|
|
207
|
+
cwd: os.tmpdir(), detached: true, stdio: 'ignore', windowsHide: true, env: { ...env, ...RUFLO_ENV, ...(brainHome ? { RUVNET_BRAIN_HOME: brainHome } : {}), ...(projectDir ? { RUVNET_TURN_PROJECT_DIR: projectDir } : {}) },
|
|
203
208
|
});
|
|
204
209
|
child.unref();
|
|
205
210
|
return { launched: true, pid: child.pid };
|
|
@@ -257,7 +262,8 @@ export function captureTurnOutcome({
|
|
|
257
262
|
Object.assign(report, { db, scope });
|
|
258
263
|
if (target.skipped) return { ...report, skipped: target.skipped, distill: { queued: false, skipped: target.skipped } };
|
|
259
264
|
const steps = [];
|
|
260
|
-
const
|
|
265
|
+
const rootStat = fs.statSync(projectRoot);
|
|
266
|
+
const binding = { projectRoot, projectDir: fs.realpathSync.native(projectDir), brainHome, rootIdentity: `${rootStat.dev}:${rootStat.ino}` };
|
|
261
267
|
let dedupe;
|
|
262
268
|
const project = projectName(projectRoot);
|
|
263
269
|
const receipts = path.join(brainHome, 'turn-capture', 'receipts.jsonl');
|
|
@@ -292,11 +298,17 @@ export function captureTurnOutcome({
|
|
|
292
298
|
if (sameTurnSeen(stateFile, identity, fingerprint, receipts)) report.skipped = 'same turn outcome already queued or verified';
|
|
293
299
|
else {
|
|
294
300
|
const at = now();
|
|
295
|
-
const key = `turn-${project}-${
|
|
301
|
+
const key = `turn-${project}-${digest(`${identity}:${fingerprint}`).slice(0, 40)}`;
|
|
296
302
|
dedupe = { stateFile, identity, fingerprint, key };
|
|
297
|
-
|
|
303
|
+
let value = buildTurnRecord({ turn, project, host, session: payload.session_id, at });
|
|
304
|
+
// Retry uses the original durable content (including its timestamp), never a new value.
|
|
305
|
+
const pendingFile = path.join(path.dirname(db), 'turn-outbox', `${digest(key)}.json`);
|
|
306
|
+
if (fs.existsSync(pendingFile)) {
|
|
307
|
+
try { const pending = readJournal(pendingFile, db); value = pending.value; }
|
|
308
|
+
catch (error) { return { ...report, skipped: `durable turn queue invalid: ${redactText(error.message)}` }; }
|
|
309
|
+
}
|
|
298
310
|
steps.push({ ...binding, kind: 'store', ruflo, args: ['memory', 'store', '-k', key, '--value', value, '-n', TURN_NAMESPACE,
|
|
299
|
-
'--path', db, '--tags', `project=${project},host=${host}`, '--provenance', 'agent_output'] });
|
|
311
|
+
'--path', db, '--no-upsert', '--tags', `project=${project},host=${host}`, '--provenance', 'agent_output'] });
|
|
300
312
|
// This synchronous boundary proves only queuing; the worker's exact receipt proves recording.
|
|
301
313
|
Object.assign(report, { queued: true, key, value });
|
|
302
314
|
}
|
|
@@ -316,10 +328,12 @@ export function captureTurnOutcome({
|
|
|
316
328
|
// Only explicit persisted consent permits creating a project store directory.
|
|
317
329
|
if (target.optedIn) fs.mkdirSync(path.dirname(db), { recursive: true, mode: 0o700 });
|
|
318
330
|
if (report.queued) {
|
|
331
|
+
const step = steps.find((item) => item.kind === 'store');
|
|
332
|
+
step.journalFile = journalTurn(step, db, report.key, { onDurability: (evidence) => { report.durability = evidence; } });
|
|
319
333
|
fs.appendFileSync(path.join(path.dirname(db), 'agentdb-turns.jsonl'),
|
|
320
334
|
`${JSON.stringify({ ts: Date.now(), key: report.key, hash: crypto.createHash('sha256').update(report.value).digest('hex'), len: report.value.length })}\n`, { mode: 0o600 });
|
|
321
335
|
}
|
|
322
|
-
report.launch = launch(steps, { receipts });
|
|
336
|
+
report.launch = launch(steps, { receipts, brainHome, projectDir: binding.projectDir, env });
|
|
323
337
|
if (dedupe) markTurnQueued(dedupe.stateFile, dedupe.identity, dedupe.fingerprint, dedupe.key);
|
|
324
338
|
} catch (error) {
|
|
325
339
|
return { ...report, queued: false, recorded: false, distill: { queued: false, skipped: `launch failed: ${redactText(error.message)}` }, skipped: `launch failed: ${redactText(error.message)}` };
|
|
@@ -335,48 +349,106 @@ function readBack({ ruflo, db, key, run, options }) {
|
|
|
335
349
|
}
|
|
336
350
|
|
|
337
351
|
/** The detached worker: bounded steps, safe error evidence, exact content readback. */
|
|
338
|
-
export function runSteps({ steps = [], receipts } = {}, { run = spawnSync, read = readBack,
|
|
352
|
+
export function runSteps({ steps = [], receipts } = {}, { run: suppliedRun = spawnSync, read = readBack,
|
|
353
|
+
env = process.env, deadlineMs, home = os.homedir(),
|
|
354
|
+
projectDir = env.RUVNET_TURN_PROJECT_DIR || process.cwd(),
|
|
355
|
+
brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain') } = {}) {
|
|
356
|
+
const run = (binary, args, options) => {
|
|
357
|
+
if (deadlineMs === undefined) return suppliedRun(binary, args, options);
|
|
358
|
+
const remaining = deadlineMs - Date.now();
|
|
359
|
+
if (remaining <= 0) return { status: 1, error: new Error('turn replay deadline exhausted') };
|
|
360
|
+
return suppliedRun(binary, args, { ...options, timeout: Math.min(options.timeout, remaining) });
|
|
361
|
+
};
|
|
339
362
|
const results = [];
|
|
340
|
-
for (const
|
|
341
|
-
let status =
|
|
342
|
-
const
|
|
343
|
-
const db = args[args.indexOf(step.kind === 'store' ? '--path' : '--db') + 1];
|
|
344
|
-
const key = step.kind === 'store' ? args[args.indexOf('-k') + 1] : undefined;
|
|
345
|
-
const valueIndex = args.indexOf('--value') + 1;
|
|
346
|
-
if (step.kind === 'store') args[valueIndex] = redactText(args[valueIndex]);
|
|
363
|
+
for (const queued of steps) {
|
|
364
|
+
let status = 1; let error = null; let verified = false; let db = ''; let key;
|
|
365
|
+
const kind = queued.kind === 'journal' ? 'store' : queued.kind;
|
|
347
366
|
try {
|
|
348
|
-
if (!step.projectRoot || !step.projectDir || !step.brainHome) throw new Error('queued step has no canonical project binding');
|
|
349
|
-
const target = resolveTurnDb({ projectDir: step.projectDir, brainHome: step.brainHome, requestedStorePath: db, gitTimeoutMs: 1000 });
|
|
350
|
-
if (target.skipped) throw new Error(target.skipped);
|
|
351
|
-
if (target.projectRoot !== step.projectRoot || target.db !== db) throw new Error('queued canonical project/store identity changed');
|
|
352
367
|
if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') throw new Error('RUVNET_TURN_CAPTURE=off');
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
368
|
+
if (kind === 'store' && queued.kind !== 'journal') { const data = storeData(queued); key = data.key; db = data.db; }
|
|
369
|
+
if (queued.kind !== 'journal' && (!queued.projectRoot || !queued.projectDir || !queued.brainHome)) throw new Error('queued step has no canonical project binding');
|
|
370
|
+
const target = resolveTurnDb({ projectDir, brainHome, gitTimeoutMs: 1000 });
|
|
371
|
+
if (target.skipped) throw new Error(target.skipped);
|
|
372
|
+
db = target.db;
|
|
373
|
+
let args; let value; let binding;
|
|
374
|
+
if (kind === 'store') {
|
|
375
|
+
const data = queued.kind === 'journal' ? readJournal(queued.journalFile, db) : storeData(queued);
|
|
376
|
+
if (queued.kind === 'journal' && queued.db !== db) throw new Error('foreign turn queue store rejected');
|
|
377
|
+
if (queued.kind !== 'journal' && data.db !== db) throw new Error('foreign turn store rejected');
|
|
378
|
+
if (data.legacyBrainHome && data.legacyBrainHome !== brainHome) throw new Error('queued consent settings differ from trusted caller');
|
|
379
|
+
if (queued.kind !== 'journal' && queued.brainHome !== brainHome) throw new Error('queued consent settings differ from trusted caller');
|
|
380
|
+
binding = queued.kind === 'journal' ? data.binding : queued;
|
|
381
|
+
key = data.key; value = redactText(data.value);
|
|
382
|
+
args = ['memory', 'store', '-k', key, '--value', value, '-n', TURN_NAMESPACE, '--path', db, '--no-upsert', '--provenance', 'agent_output'];
|
|
383
|
+
} else if (kind === 'distill') {
|
|
384
|
+
if (queued.brainHome !== brainHome) throw new Error('queued consent settings differ from trusted caller');
|
|
385
|
+
const recipe = queued.args;
|
|
386
|
+
if (!Array.isArray(recipe) || recipe.slice(0, 3).join(' ') !== 'memory distill run') throw new Error('invalid turn distill operation');
|
|
387
|
+
const seen = new Set();
|
|
388
|
+
for (let i = 3; i < recipe.length; i += 2) {
|
|
389
|
+
const flag = recipe[i]; const value = recipe[i + 1];
|
|
390
|
+
if (seen.has(flag) || !['--db', '--namespace', '--max-entries'].includes(flag) || typeof value !== 'string') throw new Error('invalid turn distill flags');
|
|
391
|
+
seen.add(flag);
|
|
392
|
+
if (flag === '--db' && value !== db || flag === '--namespace' && value !== TURN_NAMESPACE || flag === '--max-entries' && value !== '500') throw new Error('invalid turn distill scope');
|
|
393
|
+
}
|
|
394
|
+
if (!seen.has('--db')) throw new Error('missing turn distill database');
|
|
395
|
+
binding = queued;
|
|
396
|
+
args = ['memory', 'distill', 'run', '--db', db, '--namespace', TURN_NAMESPACE, '--max-entries', '500'];
|
|
397
|
+
} else throw new Error('invalid turn worker operation');
|
|
398
|
+
if (!binding.projectRoot || !binding.projectDir) throw new Error('queued step has no canonical project binding');
|
|
399
|
+
const rootStat = fs.statSync(target.projectRoot);
|
|
400
|
+
if (binding.rootIdentity && binding.rootIdentity !== `${rootStat.dev}:${rootStat.ino}`) throw new Error('queued canonical project identity changed');
|
|
401
|
+
if (binding.projectRoot !== target.projectRoot) throw new Error('queued canonical project/store identity changed');
|
|
402
|
+
const origin = resolveTurnDb({ projectDir: binding.projectDir, brainHome, requestedStorePath: db, gitTimeoutMs: 1000 });
|
|
403
|
+
if (origin.skipped) throw new Error(origin.skipped);
|
|
404
|
+
if (origin.projectRoot !== target.projectRoot || origin.db !== db) throw new Error('queued canonical capture origin changed');
|
|
405
|
+
const ruflo = resolveRuflo({ env, home });
|
|
406
|
+
if (!ruflo) throw new Error('ruflo not found for durable replay');
|
|
407
|
+
const invocation = rufloInvocation(ruflo, args);
|
|
408
|
+
const remaining = deadlineMs === undefined ? STEP_TIMEOUT_MS : deadlineMs - Date.now();
|
|
409
|
+
if (remaining <= 0) throw new Error('turn replay deadline exhausted');
|
|
410
|
+
const options = { encoding: 'utf8', timeout: Math.min(STEP_TIMEOUT_MS, remaining), cwd: path.dirname(db), windowsHide: true,
|
|
411
|
+
maxBuffer: 1024 * 1024, env: { ...env, ...RUFLO_ENV, CLAUDE_FLOW_MEMORY_PATH: path.dirname(db) } };
|
|
412
|
+
const existing = kind === 'store' && queued.journalFile ? read({ ruflo, db, key, run, options }) : null;
|
|
413
|
+
if (existing !== null && existing !== value) throw new Error('existing turn key content differs; no upsert permitted');
|
|
414
|
+
verified = kind === 'store' && existing === value;
|
|
415
|
+
const r = verified ? { status: 0 } : run(invocation.executable, invocation.args, options);
|
|
359
416
|
status = Number.isInteger(r.status) ? r.status : 1;
|
|
360
|
-
if (
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
}
|
|
417
|
+
if (kind === 'store' && !verified) verified = read({ ruflo, db, key, run, options }) === value;
|
|
418
|
+
if (verified) status = 0;
|
|
419
|
+
if (!verified && (r.error || status !== 0)) error = redactText(r.error?.message || String(r.stderr || '').trim().split(/\r?\n/)[0] || `ruflo exited ${status}`).slice(0, 300);
|
|
420
|
+
else if (kind === 'store' && !verified) { status = 1; error = 'exact turn key/content readback failed'; }
|
|
365
421
|
} catch (e) { status = 1; error = redactText(e.message).slice(0, 300); }
|
|
366
|
-
const row = { at: new Date().toISOString(), kind
|
|
367
|
-
|
|
368
|
-
...(step.kind === 'store' ? { verified } : {}) };
|
|
422
|
+
const row = { at: new Date().toISOString(), kind, db: redactText(db), storeIdentity: digest(db), key, status, error,
|
|
423
|
+
...(kind === 'store' ? { verified } : {}) };
|
|
369
424
|
results.push(row);
|
|
370
425
|
if (receipts) {
|
|
371
|
-
try {
|
|
372
|
-
|
|
373
|
-
fs.appendFileSync(receipts, `${JSON.stringify(row)}\n`, { mode: 0o600 });
|
|
374
|
-
} catch { /* receipts are best effort */ }
|
|
426
|
+
try { appendReceipt(receipts, row); if (verified && queued.journalFile) acknowledgeJournal(queued.journalFile, db); }
|
|
427
|
+
catch { /* failed receipt persistence retains the journal for exact-key retry */ }
|
|
375
428
|
}
|
|
376
429
|
}
|
|
377
430
|
return results;
|
|
378
431
|
}
|
|
379
432
|
|
|
433
|
+
/** Replay only bounded canonical project entries; consent suspends rather than deletes them. */
|
|
434
|
+
export function replayTurnQueue({ projectDir = process.cwd(), env = process.env, home = os.homedir(),
|
|
435
|
+
brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain'), limit = 10,
|
|
436
|
+
launch = launchDetached, synchronous = false, runner, deadlineMs } = {}) {
|
|
437
|
+
if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') return { queued: false, skipped: 'RUVNET_TURN_CAPTURE=off' };
|
|
438
|
+
try {
|
|
439
|
+
const target = resolveTurnDb({ projectDir, brainHome });
|
|
440
|
+
if (target.skipped) return { queued: false, skipped: target.skipped };
|
|
441
|
+
const steps = pendingTurnFiles(target.db, limit).map((journalFile) => ({ kind: 'journal', journalFile, db: target.db }));
|
|
442
|
+
if (!steps.length) return { queued: false, count: 0 };
|
|
443
|
+
if (synchronous) {
|
|
444
|
+
const results = runSteps({ steps, receipts: path.join(brainHome, 'turn-capture', 'receipts.jsonl') }, { env, home, projectDir, brainHome, ...(runner ? { run: runner } : {}), deadlineMs });
|
|
445
|
+
const pending = pendingTurnFiles(target.db, 25).length;
|
|
446
|
+
return { queued: false, count: steps.length, pending, failed: results.filter((row) => row.status !== 0).length, verified: results.filter((row) => row.verified === true).length, results };
|
|
447
|
+
}
|
|
448
|
+
return { queued: true, count: steps.length, pending: steps.length, launch: launch(steps, { receipts: path.join(brainHome, 'turn-capture', 'receipts.jsonl'), projectDir, brainHome, env }) };
|
|
449
|
+
} catch (error) { return { queued: false, skipped: redactText(error.message) }; }
|
|
450
|
+
}
|
|
451
|
+
|
|
380
452
|
/** Bounded recent evidence, isolated by canonical db. A continuity success cannot mask turn failures. */
|
|
381
453
|
export function turnRecordingStatus({ projectDir = process.cwd(), env = process.env, home = os.homedir(), now = Date.now() } = {}) {
|
|
382
454
|
const brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain');
|
|
@@ -389,8 +461,14 @@ export function turnRecordingStatus({ projectDir = process.cwd(), env = process.
|
|
|
389
461
|
try { return [JSON.parse(line)]; } catch { return []; }
|
|
390
462
|
}).filter((r) => r.kind === 'store' && (r.storeIdentity === crypto.createHash('sha256').update(target.db).digest('hex') || r.db === target.db) && now - Date.parse(r.at) < 7 * 86_400_000).slice(-20);
|
|
391
463
|
} catch { /* first run */ }
|
|
392
|
-
|
|
464
|
+
rows = [...new Map(rows.map((row) => [row.key || row.at, row])).values()];
|
|
465
|
+
let pending = 0;
|
|
466
|
+
try { pending = pendingTurnFiles(target.db, 25).length; } catch { return { state: 'warn', line: 'turn recording durable queue unsafe or unreadable' }; }
|
|
467
|
+
const historical = rows.filter((r) => r.status === 0 && r.verified === undefined);
|
|
468
|
+
const failed = rows.filter((r) => r.status !== 0 || r.verified === false);
|
|
393
469
|
if (failed.length) return { state: 'warn', line: `turn recording failing ${failed.length}/${rows.length} — ${redactText(failed.at(-1).error || 'write has no exact readback evidence').slice(0, 300)}` };
|
|
470
|
+
if (historical.length) return { state: 'unknown', line: `turn recording unverified historical (${historical.length} receipts without exact readback evidence)` };
|
|
471
|
+
if (pending) return { state: 'unknown', line: `turn recording pending durable replay (${pending} entries); not yet proven` };
|
|
394
472
|
return rows.length ? { state: 'ok', line: `turn recording ✓ (${rows.length}/${rows.length} exact readbacks)` }
|
|
395
473
|
: { state: 'unknown', line: 'turn recording not yet proven — no exact readback receipt' };
|
|
396
474
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
export const digest = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
|
6
|
+
export const turnQueueDirectory = (db) => path.join(path.dirname(db), 'turn-outbox');
|
|
7
|
+
function regular(file, directory = false) {
|
|
8
|
+
const stat = fs.lstatSync(file);
|
|
9
|
+
if (stat.isSymbolicLink() || (!directory && (!stat.isFile() || stat.nlink !== 1))
|
|
10
|
+
|| (directory && (!stat.isDirectory() || fs.realpathSync.native(file) !== file))) throw new Error('unsafe turn journal path');
|
|
11
|
+
}
|
|
12
|
+
// Node's Windows directory handles do not consistently support fsync. Never weaken
|
|
13
|
+
// file fsync or hide unexpected I/O errors; a missing namespace flush is reported.
|
|
14
|
+
export function syncTurnDirectory(dir, { platform = process.platform, io = fs } = {}) {
|
|
15
|
+
let fd;
|
|
16
|
+
try {
|
|
17
|
+
fd = io.openSync(dir, 'r'); io.fsyncSync(fd);
|
|
18
|
+
return { platform, directoryFsync: 'completed' };
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (platform !== 'win32' || !['EISDIR', 'EPERM', 'EACCES', 'EINVAL', 'ENOTSUP', 'EBADF'].includes(error.code)) throw error;
|
|
21
|
+
return { platform, directoryFsync: 'unavailable', reason: error.code,
|
|
22
|
+
limitation: 'file fsync remains required; directory-entry persistence across power loss is unproven' };
|
|
23
|
+
} finally { if (fd !== undefined) io.closeSync(fd); }
|
|
24
|
+
}
|
|
25
|
+
// Legacy recipes are data extraction inputs only, never executable commands.
|
|
26
|
+
export function storeData(step) {
|
|
27
|
+
if (step?.kind !== 'store' || !Array.isArray(step.args) || step.args[0] !== 'memory' || step.args[1] !== 'store') throw new Error('invalid turn store operation');
|
|
28
|
+
const fields = {}; const aliases = { '-k': 'key', '--key': 'key', '--value': 'value', '-n': 'namespace', '--namespace': 'namespace', '--path': 'db', '--tags': 'tags', '--provenance': 'provenance' };
|
|
29
|
+
let strict = false;
|
|
30
|
+
for (let i = 2; i < step.args.length; i++) {
|
|
31
|
+
const flag = step.args[i];
|
|
32
|
+
if (flag === '--no-upsert') { if (strict) throw new Error('duplicate turn store flag'); strict = true; continue; }
|
|
33
|
+
const field = aliases[flag];
|
|
34
|
+
if (!field || Object.hasOwn(fields, field) || typeof step.args[i + 1] !== 'string') throw new Error('unknown or duplicate turn store flag');
|
|
35
|
+
fields[field] = step.args[++i];
|
|
36
|
+
}
|
|
37
|
+
if (!fields.key || typeof fields.value !== 'string' || fields.namespace !== 'turns' || !path.isAbsolute(fields.db || '')) throw new Error('invalid turn store namespace or data');
|
|
38
|
+
return { key: fields.key, value: fields.value, db: fields.db };
|
|
39
|
+
}
|
|
40
|
+
const bindingOf = (step) => ({ projectRoot: step.projectRoot, projectDir: step.projectDir, rootIdentity: step.rootIdentity });
|
|
41
|
+
function validateBinding(binding) {
|
|
42
|
+
if (!binding || Object.keys(binding).some((key) => !['projectRoot', 'projectDir', 'rootIdentity'].includes(key))
|
|
43
|
+
|| !path.isAbsolute(binding.projectRoot || '') || !path.isAbsolute(binding.projectDir || '')
|
|
44
|
+
|| typeof binding.rootIdentity !== 'string' || !/^\d+:\d+$/.test(binding.rootIdentity)) throw new Error('invalid turn canonical binding');
|
|
45
|
+
}
|
|
46
|
+
export function journalTurn(step, db, key, { platform = process.platform, io = fs, onDurability } = {}) {
|
|
47
|
+
const dir = turnQueueDirectory(db);
|
|
48
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); regular(dir, true);
|
|
49
|
+
const file = path.join(dir, `${digest(key)}.json`);
|
|
50
|
+
const data = storeData(step);
|
|
51
|
+
if (data.db !== db || data.key !== key) throw new Error('turn journal identity mismatch');
|
|
52
|
+
const binding = bindingOf(step); validateBinding(binding);
|
|
53
|
+
const record = { schemaVersion: 2, key, value: data.value, contentDigest: digest(data.value), consentScope: 'canonical-project-and-path', binding };
|
|
54
|
+
let created = false;
|
|
55
|
+
try {
|
|
56
|
+
const fd = io.openSync(file, 'wx', 0o600);
|
|
57
|
+
created = true;
|
|
58
|
+
try { io.writeFileSync(fd, JSON.stringify(record)); io.fsyncSync(fd); } finally { io.closeSync(fd); }
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error.code !== 'EEXIST') throw error;
|
|
61
|
+
const previous = readJournal(file, db);
|
|
62
|
+
if (previous.key !== key || previous.contentDigest !== record.contentDigest) throw new Error('turn journal identity collision');
|
|
63
|
+
// Reflush an existing complete entry: its creator may have died before fsync.
|
|
64
|
+
const fd = io.openSync(file, 'r+');
|
|
65
|
+
try { io.fsyncSync(fd); } finally { io.closeSync(fd); }
|
|
66
|
+
}
|
|
67
|
+
const evidence = { fileFsync: 'completed', entry: created ? 'created-exclusively' : 'existing-exact-match-reflushed', ...syncTurnDirectory(dir, { platform, io }) };
|
|
68
|
+
if (onDurability) onDurability(evidence);
|
|
69
|
+
return file;
|
|
70
|
+
}
|
|
71
|
+
export function readJournal(file, db) {
|
|
72
|
+
const dir = turnQueueDirectory(db); regular(dir, true);
|
|
73
|
+
if (path.dirname(file) !== dir) throw new Error('foreign turn journal rejected');
|
|
74
|
+
regular(file);
|
|
75
|
+
const record = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
76
|
+
let normalized = record;
|
|
77
|
+
if (record.schemaVersion === 1) {
|
|
78
|
+
const data = storeData(record.step);
|
|
79
|
+
if (!record.step.args.includes('--no-upsert')) throw new Error('legacy turn journal requires strict insert');
|
|
80
|
+
if (data.db !== db || data.key !== record.key) throw new Error('legacy turn journal identity mismatch');
|
|
81
|
+
// Legacy job-selected consent settings are never consumed; caller policy governs replay.
|
|
82
|
+
normalized = { schemaVersion: 2, key: data.key, value: data.value, contentDigest: record.contentDigest,
|
|
83
|
+
consentScope: record.consentScope, binding: bindingOf(record.step), legacyBrainHome: record.step.brainHome };
|
|
84
|
+
} else if (Object.keys(record).some((key) => !['schemaVersion', 'key', 'value', 'contentDigest', 'consentScope', 'binding'].includes(key))) throw new Error('unknown turn journal field');
|
|
85
|
+
validateBinding(normalized.binding);
|
|
86
|
+
if (normalized.schemaVersion !== 2 || normalized.consentScope !== 'canonical-project-and-path'
|
|
87
|
+
|| typeof normalized.key !== 'string' || !normalized.key.startsWith('turn-') || normalized.key.length > 250
|
|
88
|
+
|| typeof normalized.value !== 'string' || normalized.value.length > 4000
|
|
89
|
+
|| digest(normalized.value) !== normalized.contentDigest
|
|
90
|
+
|| path.basename(file) !== `${digest(normalized.key)}.json`) throw new Error('invalid turn journal');
|
|
91
|
+
return normalized;
|
|
92
|
+
}
|
|
93
|
+
export function pendingTurnFiles(db, limit = 10) {
|
|
94
|
+
const dir = turnQueueDirectory(db);
|
|
95
|
+
try { regular(dir, true); } catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
96
|
+
return fs.readdirSync(dir).filter((name) => /^[a-f0-9]{64}\.json$/.test(name)).sort().slice(0, Math.min(25, Math.max(0, limit))).map((name) => path.join(dir, name));
|
|
97
|
+
}
|
|
98
|
+
export function acknowledgeJournal(file, db, options = {}) {
|
|
99
|
+
readJournal(file, db); fs.unlinkSync(file); return syncTurnDirectory(path.dirname(file), options);
|
|
100
|
+
}
|
|
101
|
+
export function appendReceipt(file, row) {
|
|
102
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
103
|
+
if (fs.existsSync(file)) regular(file);
|
|
104
|
+
const fd = fs.openSync(file, 'a', 0o600);
|
|
105
|
+
try { fs.writeFileSync(fd, `${JSON.stringify(row)}\n`); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
106
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// Metadata-only repair. releaseIdentity is an independently verified release input owned by the
|
|
2
|
+
// installer, NOT a hash/boolean synthesized from the installed file. No artifact discovery/inference.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
8
|
+
import { codexHookIdentities } from './codex-hook-trust.mjs';
|
|
9
|
+
|
|
10
|
+
/** Authenticate exact published package bytes before reading one archive member. No extraction/install. */
|
|
11
|
+
export async function obtainVerifiedPublishedHookIdentity({ version, fetch: fetcher = globalThis.fetch,
|
|
12
|
+
timeoutMs = 10000, maxTarballBytes = 32 * 1024 * 1024, tarBinary = '/usr/bin/tar' } = {}) {
|
|
13
|
+
if (!/^\d+\.\d+\.\d+$/.test(version || '') || typeof fetcher !== 'function') return blocked('Exact released version and HTTPS fetch required');
|
|
14
|
+
const deadline = Date.now() + timeoutMs;
|
|
15
|
+
const get = async (url, limit) => {
|
|
16
|
+
const remaining = deadline - Date.now(); if (remaining <= 0) throw new Error('Published artifact fetch deadline exceeded');
|
|
17
|
+
const response = await fetcher(url, { redirect: 'error', signal: AbortSignal.timeout(remaining) });
|
|
18
|
+
if (!response.ok || !response.body) throw new Error('Published artifact HTTPS fetch failed');
|
|
19
|
+
if (Number(response.headers.get('content-length')) > limit) throw new Error('Published artifact exceeds size bound');
|
|
20
|
+
const reader = response.body.getReader(); const chunks = []; let length = 0;
|
|
21
|
+
try {
|
|
22
|
+
while (true) {
|
|
23
|
+
if (Date.now() >= deadline) throw new Error('Published artifact fetch deadline exceeded');
|
|
24
|
+
const { done, value } = await reader.read(); if (done) break;
|
|
25
|
+
length += value.byteLength; if (length > limit) throw new Error('Published artifact exceeds size bound');
|
|
26
|
+
chunks.push(Buffer.from(value));
|
|
27
|
+
}
|
|
28
|
+
} finally { await reader.cancel().catch(() => {}); }
|
|
29
|
+
return Buffer.concat(chunks);
|
|
30
|
+
};
|
|
31
|
+
try {
|
|
32
|
+
const metadataUrl = `https://registry.npmjs.org/ruvnet-brain/${version}`;
|
|
33
|
+
const metadata = JSON.parse((await get(metadataUrl, 2 * 1024 * 1024)).toString('utf8'));
|
|
34
|
+
const tarballUrl = `https://registry.npmjs.org/ruvnet-brain/-/ruvnet-brain-${version}.tgz`;
|
|
35
|
+
if (metadata.name !== 'ruvnet-brain' || metadata.version !== version || metadata.dist?.tarball !== tarballUrl
|
|
36
|
+
|| !/^sha512-[A-Za-z0-9+/]{86}==$/.test(metadata.dist?.integrity || '')) throw new Error('Published version/path/integrity mismatch');
|
|
37
|
+
const archive = await get(tarballUrl, maxTarballBytes);
|
|
38
|
+
const integrity = `sha512-${crypto.createHash('sha512').update(archive).digest('base64')}`;
|
|
39
|
+
if (integrity !== metadata.dist.integrity) throw new Error('Published archive integrity mismatch');
|
|
40
|
+
const remaining = deadline - Date.now(); if (remaining <= 0) throw new Error('Published artifact deadline exceeded');
|
|
41
|
+
// stdout-only exact member: no archive paths are ever written to disk or traversed.
|
|
42
|
+
const result = spawnSync(tarBinary, ['-xzOf', '-', 'package/plugin/hooks/codex-hooks.json'],
|
|
43
|
+
{ input: archive, timeout: Math.min(remaining, 3000), maxBuffer: 256 * 1024 });
|
|
44
|
+
if (result.error || result.status !== 0 || !result.stdout?.length) throw new Error('Platform tar cannot read exact published hooks entry');
|
|
45
|
+
if (!codexHookIdentities(JSON.parse(result.stdout)).size) throw new Error('Published native hooks are invalid');
|
|
46
|
+
return { state: 'verified', releaseIdentity: { version, integrity, hooksSha256: digest(result.stdout) },
|
|
47
|
+
metadataUrl, tarballUrl, archiveBytes: archive.length };
|
|
48
|
+
} catch (error) { return blocked(error.message); }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const activeMetadataChildren = new Map();
|
|
52
|
+
async function shutdownMetadataChild(child) {
|
|
53
|
+
const entry = activeMetadataChildren.get(child); if (!entry) return;
|
|
54
|
+
if (entry.closing) return entry.closing;
|
|
55
|
+
entry.closing = (async () => {
|
|
56
|
+
const wait = async () => {
|
|
57
|
+
let timer;
|
|
58
|
+
try { await Promise.race([entry.exited, new Promise((resolve) => { timer = setTimeout(resolve, 350); })]); }
|
|
59
|
+
finally { clearTimeout(timer); }
|
|
60
|
+
};
|
|
61
|
+
if (child.pid && child.exitCode === null && child.signalCode === null) {
|
|
62
|
+
child.kill('SIGTERM'); await wait();
|
|
63
|
+
if (child.exitCode === null && child.signalCode === null) { child.kill('SIGKILL'); await wait(); }
|
|
64
|
+
}
|
|
65
|
+
if (child.pid && child.exitCode === null && child.signalCode === null) throw new Error('Native metadata child exit unverified');
|
|
66
|
+
activeMetadataChildren.delete(child);
|
|
67
|
+
})();
|
|
68
|
+
return entry.closing;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const OWNER = 'ruvnet-brain@ruvnet-brain';
|
|
72
|
+
const digest = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
|
|
73
|
+
const blocked = (reason) => ({ state: 'blocked', changed: false, reason });
|
|
74
|
+
function privateBackup(configPath, bytes, backupDir) {
|
|
75
|
+
fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
|
|
76
|
+
if (fs.lstatSync(backupDir).isSymbolicLink()) throw new Error('Backup directory symlink refused');
|
|
77
|
+
const file = path.join(backupDir, `rnb-hook-trust-${new Date().toISOString().replaceAll(':', '-')}-${crypto.randomUUID()}.toml`);
|
|
78
|
+
const fd = fs.openSync(file, 'wx', 0o600);
|
|
79
|
+
try { fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
80
|
+
const dir = fs.openSync(backupDir, 'r'); try { fs.fsyncSync(dir); } finally { fs.closeSync(dir); }
|
|
81
|
+
return file;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function resolveNativeBinary(binary, { env = process.env, platform = process.platform } = {}) {
|
|
85
|
+
if (typeof binary !== 'string' || !binary) throw new Error('Native executable required');
|
|
86
|
+
const candidates = path.isAbsolute(binary) ? [binary] : /^[A-Za-z0-9._-]+$/.test(binary)
|
|
87
|
+
? String(env.PATH || '').split(platform === 'win32' ? ';' : path.delimiter).filter(Boolean).flatMap((dir) => {
|
|
88
|
+
const suffixes = platform === 'win32' && !path.extname(binary) ? String(env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';') : [''];
|
|
89
|
+
return suffixes.map((suffix) => path.resolve(dir, binary + suffix));
|
|
90
|
+
}) : [];
|
|
91
|
+
for (const candidate of candidates) {
|
|
92
|
+
try {
|
|
93
|
+
fs.accessSync(candidate, platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
|
|
94
|
+
if (!fs.statSync(candidate).isFile()) continue;
|
|
95
|
+
const resolved = fs.realpathSync(candidate);
|
|
96
|
+
if (resolved === fs.realpathSync(fileURLToPath(import.meta.url))) throw new Error('Recursive native gateway refused');
|
|
97
|
+
return resolved;
|
|
98
|
+
} catch (error) { if (error.message === 'Recursive native gateway refused') throw error; }
|
|
99
|
+
}
|
|
100
|
+
throw new Error('Native executable unavailable on configured PATH');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Only initialize/config/read/config/batchWrite/hooks/list are ever sent by this client. */
|
|
104
|
+
async function nativeClient(binary, cwd, deadlineAt, configPath) {
|
|
105
|
+
binary = resolveNativeBinary(binary);
|
|
106
|
+
const env = { ...process.env, CODEX_HOME: path.dirname(configPath) };
|
|
107
|
+
for (const key of ['OPENAI_API_KEY', 'OPENAI_BASE_URL', 'ANTHROPIC_API_KEY', 'CODEX_API_KEY']) delete env[key];
|
|
108
|
+
const child = spawn(binary, ['app-server', '--listen', 'stdio://'], { cwd, env, stdio: ['pipe', 'pipe', 'ignore'] });
|
|
109
|
+
const exited = new Promise((resolve) => { child.once('exit', resolve); child.once('error', () => { if (!child.pid) resolve(); }); });
|
|
110
|
+
activeMetadataChildren.set(child, { exited });
|
|
111
|
+
let nextId = 1; let buffer = ''; const pending = new Map();
|
|
112
|
+
const rejectAll = (message) => { for (const item of pending.values()) { clearTimeout(item.timer); item.reject(new Error(message)); } pending.clear(); };
|
|
113
|
+
child.on('error', () => rejectAll('Native metadata process failed'));
|
|
114
|
+
child.stdin.on('error', () => rejectAll('Native metadata input failed'));
|
|
115
|
+
child.on('exit', () => rejectAll('Native metadata process exited'));
|
|
116
|
+
child.stdout.on('data', (chunk) => {
|
|
117
|
+
buffer += chunk;
|
|
118
|
+
if (Buffer.byteLength(buffer) > 8 * 1024 * 1024) { rejectAll('Native metadata output exceeds bound'); child.kill(); return; }
|
|
119
|
+
while (buffer.includes('\n')) {
|
|
120
|
+
const end = buffer.indexOf('\n'); const line = buffer.slice(0, end); buffer = buffer.slice(end + 1);
|
|
121
|
+
let row; try { row = JSON.parse(line); } catch { continue; }
|
|
122
|
+
const item = pending.get(row.id); if (!item) continue;
|
|
123
|
+
pending.delete(row.id); clearTimeout(item.timer);
|
|
124
|
+
// Do not include native error details: they may contain private config values.
|
|
125
|
+
if (row.error) item.reject(new Error(`Native ${item.method} rejected`)); else item.resolve(row.result);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
const rpc = (method, params) => new Promise((resolve, reject) => {
|
|
129
|
+
const remaining = deadlineAt - Date.now(); if (remaining <= 0) { reject(new Error('Native metadata deadline exceeded')); return; }
|
|
130
|
+
const id = nextId++; const timer = setTimeout(() => { pending.delete(id); reject(new Error(`Native ${method} timed out`)); }, remaining);
|
|
131
|
+
pending.set(id, { resolve, reject, timer, method });
|
|
132
|
+
child.stdin.write(`${JSON.stringify({ id, method, params })}\n`, (error) => { if (error) rejectAll('Native metadata input failed'); });
|
|
133
|
+
});
|
|
134
|
+
const close = async () => { rejectAll('Native metadata client closed'); child.stdin.destroy(); await shutdownMetadataChild(child); };
|
|
135
|
+
try {
|
|
136
|
+
await rpc('initialize', { clientInfo: { name: 'rnb_verified_hook_trust', version: '1' }, capabilities: { experimentalApi: true } });
|
|
137
|
+
child.stdin.write(`${JSON.stringify({ method: 'initialized' })}\n`);
|
|
138
|
+
return { rpc, close };
|
|
139
|
+
} catch (error) { await close(); throw error; }
|
|
140
|
+
}
|
|
141
|
+
const rowsFor = (response) => {
|
|
142
|
+
if (!Array.isArray(response?.data)) throw new Error('Native hook registry unavailable');
|
|
143
|
+
return response.data.flatMap((r) => r.hooks || []).filter((r) => r.pluginId === OWNER);
|
|
144
|
+
};
|
|
145
|
+
function assertRegistry(rows, identities) {
|
|
146
|
+
if (rows.length !== identities.size || new Set(rows.map((r) => r.key)).size !== identities.size
|
|
147
|
+
|| rows.some((row) => identities.get(row.key) !== row.currentHash)) throw new Error('Native registry differs from verified released artifact');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function otherSettings(config, keys) {
|
|
151
|
+
const clone = structuredClone(config);
|
|
152
|
+
for (const key of keys) {
|
|
153
|
+
const state = clone?.hooks?.state?.[key];
|
|
154
|
+
if (state && typeof state === 'object') {
|
|
155
|
+
delete state.trusted_hash;
|
|
156
|
+
if (!Object.keys(state).length) delete clone.hooks.state[key];
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (clone?.hooks?.state && !Object.keys(clone.hooks.state).length) delete clone.hooks.state;
|
|
160
|
+
if (clone?.hooks && !Object.keys(clone.hooks).length) delete clone.hooks;
|
|
161
|
+
const canonical = (v) => Array.isArray(v) ? v.map(canonical) : v && typeof v === 'object'
|
|
162
|
+
? Object.fromEntries(Object.keys(v).sort().map((k) => [k, canonical(v[k])])) : v;
|
|
163
|
+
return JSON.stringify(canonical(clone));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Caller must authenticate releaseIdentity against its verified tarball/protected-release receipt.
|
|
167
|
+
* The helper checks installed bytes/version and native identities; it cannot authenticate caller data.
|
|
168
|
+
* rpc injection owns its own client lifetime. Default client never starts a thread, turn or account call.
|
|
169
|
+
*/
|
|
170
|
+
export async function reconcileVerifiedCodexHookTrust({ releaseIdentity, installedVersion, hooksPath,
|
|
171
|
+
configPath, nativeBinary, cwd = process.cwd(), backupDir = path.join(path.dirname(configPath || ''), 'backups'),
|
|
172
|
+
rpc: suppliedRpc, timeoutMs = 10000 } = {}) {
|
|
173
|
+
let client; let wrote = false; let writeAttempted = false; let backupPath;
|
|
174
|
+
try {
|
|
175
|
+
if (!releaseIdentity || !/^\d+\.\d+\.\d+$/.test(releaseIdentity.version || '')
|
|
176
|
+
|| installedVersion !== releaseIdentity.version || !/^[a-f0-9]{64}$/.test(releaseIdentity.hooksSha256 || '')
|
|
177
|
+
|| !/^sha512-[A-Za-z0-9+/]{86}==$/.test(releaseIdentity.integrity || '')) return blocked('Verified released artifact identity required');
|
|
178
|
+
if (![hooksPath, configPath, backupDir].every((p) => typeof p === 'string' && path.isAbsolute(p))) return blocked('Absolute owned paths required');
|
|
179
|
+
if (fs.lstatSync(hooksPath).isSymbolicLink() || fs.lstatSync(configPath).isSymbolicLink()) return blocked('Source/config symlink refused');
|
|
180
|
+
const bytes = fs.readFileSync(hooksPath);
|
|
181
|
+
if (digest(bytes) !== releaseIdentity.hooksSha256) return blocked('Installed hooks differ from verified release');
|
|
182
|
+
const identities = codexHookIdentities(JSON.parse(bytes));
|
|
183
|
+
if (!identities.size) return blocked('Verified artifact has no native hooks');
|
|
184
|
+
if (!suppliedRpc) client = await nativeClient(nativeBinary, cwd, Date.now() + Math.min(timeoutMs, 20000), configPath);
|
|
185
|
+
const rpc = suppliedRpc || client.rpc;
|
|
186
|
+
const before = rowsFor(await rpc('hooks/list', { cwds: [cwd] })); assertRegistry(before, identities);
|
|
187
|
+
const disabled = before.filter((r) => r.enabled === false).map((r) => r.key).sort();
|
|
188
|
+
if (before.some((r) => !['trusted', 'modified', 'untrusted', 'managed'].includes(r.trustStatus))) return blocked('Unknown native trust state');
|
|
189
|
+
const pending = before.filter((r) => r.enabled === true && ['modified', 'untrusted'].includes(r.trustStatus));
|
|
190
|
+
if (!pending.length) return { state: 'unchanged', changed: false, disabledPreserved: disabled };
|
|
191
|
+
const read = await rpc('config/read', { includeLayers: true });
|
|
192
|
+
const users = (read.layers || []).filter((r) => r.name?.type === 'user' && r.name.file === configPath);
|
|
193
|
+
if (users.length !== 1 || typeof users[0].version !== 'string' || !users[0].version) return blocked('Current user config version unavailable');
|
|
194
|
+
// Config stays memory-only except the private backup. No logging of effective config values.
|
|
195
|
+
const prior = fs.readFileSync(configPath);
|
|
196
|
+
backupPath = privateBackup(configPath, prior, backupDir);
|
|
197
|
+
const edits = pending.map((r) => ({ keyPath: `hooks.state.${JSON.stringify(r.key)}.trusted_hash`,
|
|
198
|
+
value: identities.get(r.key), mergeStrategy: 'replace' }));
|
|
199
|
+
writeAttempted = true;
|
|
200
|
+
await rpc('config/batchWrite', { filePath: configPath, expectedVersion: users[0].version, reloadUserConfig: true, edits });
|
|
201
|
+
wrote = true;
|
|
202
|
+
const afterRead = await rpc('config/read', { includeLayers: true });
|
|
203
|
+
const afterUsers = (afterRead.layers || []).filter((r) => r.name?.type === 'user' && r.name.file === configPath);
|
|
204
|
+
if (afterUsers.length !== 1 || otherSettings(users[0].config, pending.map((r) => r.key))
|
|
205
|
+
!== otherSettings(afterUsers[0].config, pending.map((r) => r.key))) throw new Error('Unrelated config changed; no whole-file rollback attempted');
|
|
206
|
+
const after = rowsFor(await rpc('hooks/list', { cwds: [cwd] })); assertRegistry(after, identities);
|
|
207
|
+
if (JSON.stringify(after.filter((r) => r.enabled === false).map((r) => r.key).sort()) !== JSON.stringify(disabled)
|
|
208
|
+
|| after.some((r) => r.enabled === true && !['trusted', 'managed'].includes(r.trustStatus))) throw new Error('Native trust repair verification incomplete');
|
|
209
|
+
return { state: 'registry-verified', changed: true, editedTrustHashes: edits.length,
|
|
210
|
+
enabledBrainTrusted: after.filter((r) => r.enabled === true).length, disabledPreserved: disabled,
|
|
211
|
+
artifactHooksSha256: releaseIdentity.hooksSha256, backupPath, otherSemanticSettingsUnchanged: true,
|
|
212
|
+
priorConfigSha256: digest(prior), afterConfigSha256: digest(fs.readFileSync(configPath)), inferenceRequests: 0,
|
|
213
|
+
executionAcceptance: 'not-run' };
|
|
214
|
+
} catch (error) {
|
|
215
|
+
// Never restore an entire backup over concurrent writers. A successful write followed by failed
|
|
216
|
+
// verification is degraded, not "unchanged"; the installer must expose this outcome.
|
|
217
|
+
return { state: wrote || writeAttempted ? 'degraded' : 'blocked', changed: wrote ? true : writeAttempted ? 'unknown' : false, reason: error.message, ...(backupPath ? { backupPath } : {}) };
|
|
218
|
+
} finally { await client?.close(); }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Explicit CLI only; importing the helper never starts a process or reads stdin.
|
|
222
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
223
|
+
&& process.argv[2] === '--reconcile-installed') {
|
|
224
|
+
const started = Date.now(); let emitted = false; let reconcileStarted = false;
|
|
225
|
+
const emit = (receipt) => { if (!emitted) { emitted = true; process.stdout.write(JSON.stringify(receipt) + '\n'); } };
|
|
226
|
+
const guard = setTimeout(async () => {
|
|
227
|
+
await Promise.allSettled([...activeMetadataChildren.keys()].map(shutdownMetadataChild));
|
|
228
|
+
emit({ state: 'degraded', changed: 'unknown', reason: 'CLI deadline exceeded; verify native trust state', inferenceRequests: 0 });
|
|
229
|
+
process.exit(1);
|
|
230
|
+
}, 29000);
|
|
231
|
+
(async () => {
|
|
232
|
+
let input = ''; for await (const chunk of process.stdin) {
|
|
233
|
+
input += chunk; if (Buffer.byteLength(input) > 16384) throw new Error('CLI input exceeds bound');
|
|
234
|
+
}
|
|
235
|
+
const args = JSON.parse(input);
|
|
236
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)
|
|
237
|
+
|| Object.keys(args).some((k) => !['installedVersion', 'hooksPath', 'configPath', 'nativeBinary', 'cwd'].includes(k))) throw new Error('Invalid CLI options');
|
|
238
|
+
const proof = await obtainVerifiedPublishedHookIdentity({ version: args.installedVersion, timeoutMs: 10000 });
|
|
239
|
+
if (proof.state !== 'verified') { emit(proof); return; }
|
|
240
|
+
reconcileStarted = true;
|
|
241
|
+
const result = await reconcileVerifiedCodexHookTrust({ ...args, releaseIdentity: proof.releaseIdentity,
|
|
242
|
+
timeoutMs: Math.max(1, Math.min(20000, 28000 - (Date.now() - started))) });
|
|
243
|
+
emit(result);
|
|
244
|
+
})().catch(() => emit(reconcileStarted
|
|
245
|
+
? { state: 'degraded', changed: 'unknown', reason: 'Native reconciliation or shutdown failed; verify trust state', inferenceRequests: 0 }
|
|
246
|
+
: blocked('CLI request or published artifact proof failed'))).finally(() => clearTimeout(guard));
|
|
247
|
+
}
|