ruvnet-brain 4.5.7 → 4.5.8
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 +1 -1
- package/bin/install.mjs +50 -1
- package/package.json +5 -3
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +31 -1
- package/plugin/hooks/hook-contracts.json +81 -1
- package/plugin/hooks/hooks.json +31 -1
- package/plugin/scripts/agentdb-recall.mjs +38 -10
- package/plugin/scripts/continuity-hook-policy.mjs +3 -0
- package/plugin/scripts/hook-shim.mjs +1 -1
- package/plugin/scripts/kb-copy-proof.mjs +80 -19
- package/plugin/scripts/learn-capture.mjs +44 -0
- package/plugin/scripts/learn-capture.sh +2 -241
- package/plugin/scripts/learn-flush.mjs +86 -191
- package/plugin/scripts/learning-observation.mjs +51 -0
- package/plugin/scripts/learning-queue.mjs +135 -0
- package/plugin/scripts/learning-store.mjs +105 -0
- package/plugin/scripts/learning-worker-supervisor.mjs +79 -0
- package/plugin/scripts/project-progression-contract.mjs +58 -4
- package/plugin/scripts/runtime-preferences.mjs +45 -4
- package/plugin/scripts/session-start-core.mjs +8 -0
- package/plugin/scripts/session-start-proof.mjs +49 -0
- package/scripts/codex-fresh-host-proof.mjs +179 -0
- package/scripts/codex-host-execution-proof.mjs +246 -0
- package/scripts/codex-host-proof-runtime.mjs +104 -0
- package/scripts/console-engine.mjs +29 -16
- package/scripts/health-repair.mjs +47 -68
- package/scripts/onboarding-console.mjs +25 -54
- package/scripts/qa/progression-validation-benchmark.mjs +66 -0
- package/scripts/release-qualification-contract.mjs +49 -3
- package/scripts/remedy-registry.mjs +9 -2
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// One finite owner retires its separate worker tree before surrendering the queue fence.
|
|
2
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { ownsQueueLock, releaseQueueLock, writeExclusive, writeAtomic, readSafe } from './learning-queue.mjs';
|
|
6
|
+
import { learningContext } from './runtime-preferences.mjs';
|
|
7
|
+
|
|
8
|
+
const aliveGroup = pid => {
|
|
9
|
+
try { process.kill(-pid, 0); return true; } catch (error) { return error.code !== 'ESRCH'; }
|
|
10
|
+
};
|
|
11
|
+
const killTree = pid => {
|
|
12
|
+
if (process.platform === 'win32') {
|
|
13
|
+
const r = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { timeout: 1000, windowsHide: true, stdio: 'ignore' });
|
|
14
|
+
return !r.error && r.status === 0;
|
|
15
|
+
}
|
|
16
|
+
try { process.kill(-pid, 'SIGKILL'); return true; } catch (error) { return error.code === 'ESRCH'; }
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export async function superviseLearning(context, token, deadline, { report = false, spawnEngine = spawn,
|
|
20
|
+
killOwned = killTree, groupAlive = aliveGroup, platform = process.platform, env = process.env,
|
|
21
|
+
persistFence = writeAtomic, writeDiagnostic = writeExclusive } = {}) {
|
|
22
|
+
// A supervisor crash or later read-only filesystem must never permit expired-lease takeover.
|
|
23
|
+
// Persist and verify the retirement requirement before any worker can exist.
|
|
24
|
+
const lockFile = path.join(context.queueDir, '.worker-lock');
|
|
25
|
+
try {
|
|
26
|
+
if (!ownsQueueLock(context, token)) throw new Error('queue ownership lost');
|
|
27
|
+
const lock = JSON.parse(readSafe(lockFile));
|
|
28
|
+
persistFence(lockFile, JSON.stringify({ ...lock, retirementRequired: true }));
|
|
29
|
+
const verified = JSON.parse(readSafe(lockFile));
|
|
30
|
+
if (verified.token !== token || verified.retirementRequired !== true) throw new Error('retirement requirement unverified');
|
|
31
|
+
} catch {
|
|
32
|
+
return { retirementConfirmed: true, fencePersisted: false, launched: false };
|
|
33
|
+
}
|
|
34
|
+
const child = spawnEngine(process.execPath, [fileURLToPath(new URL('./learn-flush.mjs', import.meta.url)), '--worker', ...(report ? ['--report'] : [])], {
|
|
35
|
+
cwd: context.projectDir, detached: true, stdio: ['ignore', report ? 'inherit' : 'ignore', report ? 'inherit' : 'ignore', 'ipc'], windowsHide: true,
|
|
36
|
+
env: { ...env, RUVNET_LEARN_WORKER_TOKEN: token, RUVNET_LEARN_WORKER_EXPIRES: String(deadline), RUFLO_DAEMON_AUTOSTART: '0' },
|
|
37
|
+
});
|
|
38
|
+
return await new Promise(resolve => {
|
|
39
|
+
let retiring = false; let exited = false;
|
|
40
|
+
const timer = setTimeout(() => void retire(true), Math.max(1, deadline - Date.now()));
|
|
41
|
+
const retire = async failed => {
|
|
42
|
+
if (retiring) return; retiring = true; clearTimeout(timer);
|
|
43
|
+
let killed = false;
|
|
44
|
+
try { killed = !child.pid || killOwned(child.pid); } catch { /* bounded refusal below */ }
|
|
45
|
+
const until = Date.now() + 600; let confirmed = !child.pid;
|
|
46
|
+
while (!confirmed && Date.now() < until) {
|
|
47
|
+
try { confirmed = platform === 'win32' ? killed && exited : !groupAlive(child.pid); } catch { confirmed = false; }
|
|
48
|
+
if (!confirmed) await new Promise(r => setTimeout(r, 25));
|
|
49
|
+
}
|
|
50
|
+
// Mandatory fencing is independent of optional diagnostics (including ENOSPC).
|
|
51
|
+
let fencePersisted = confirmed;
|
|
52
|
+
if (!confirmed) {
|
|
53
|
+
try {
|
|
54
|
+
if (ownsQueueLock(context, token)) {
|
|
55
|
+
const lock = JSON.parse(readSafe(lockFile));
|
|
56
|
+
persistFence(lockFile, JSON.stringify({ ...lock, retirementUnconfirmed: true }));
|
|
57
|
+
fencePersisted = JSON.parse(readSafe(lockFile)).retirementUnconfirmed === true;
|
|
58
|
+
}
|
|
59
|
+
} catch { /* Report failed durability without claiming the queue is fenced. */ }
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const current = learningContext({ env, cwd: context.projectDir });
|
|
63
|
+
if (current.enabled && current.queueDir === context.queueDir) {
|
|
64
|
+
if (failed || !confirmed) writeDiagnostic(path.join(context.queueDir, `.run-${Date.now()}-${process.pid}.json`), JSON.stringify({
|
|
65
|
+
schemaVersion: 1, failed: 1, fed: 0, acknowledged: 0, workerPid: child.pid, retirementConfirmed: confirmed, fencePersisted,
|
|
66
|
+
reason: confirmed ? 'owned worker exceeded deadline or crashed; retired; originals retained' : fencePersisted
|
|
67
|
+
? 'owned tree retirement unconfirmed; queue fenced; originals retained'
|
|
68
|
+
: 'owned tree retirement unconfirmed; fence persistence failed; manual recovery required; originals retained',
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
} catch { /* Diagnostic failure never consumes original evidence. */ }
|
|
72
|
+
if (confirmed) releaseQueueLock(context, token);
|
|
73
|
+
if (child.connected) child.disconnect(); child.unref?.(); resolve({ retirementConfirmed: confirmed, fencePersisted });
|
|
74
|
+
};
|
|
75
|
+
child.once('message', message => { if (message?.type === 'learning-worker-complete') void retire(false); });
|
|
76
|
+
child.once('error', () => void retire(true));
|
|
77
|
+
child.once('exit', () => { exited = true; if (!retiring) void retire(true); });
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -14,7 +14,32 @@ function canonicalize(value) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export function digestCanonical(value) {
|
|
17
|
-
return crypto.createHash('sha256').update(JSON.stringify(canonicalize(value))).digest('hex');
|
|
17
|
+
return crypto.createHash('sha256').update(JSON.stringify(isCanonicalJson(value) ? value : canonicalize(value))).digest('hex');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Stored snapshots are already canonical. Verify that fact without constructing a second
|
|
21
|
+
// complete object graph; arbitrary callers still receive the original sorting/validation path.
|
|
22
|
+
function isCanonicalJson(value) {
|
|
23
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return true;
|
|
24
|
+
if (typeof value === 'number') return Number.isFinite(value);
|
|
25
|
+
if (Array.isArray(value)) {
|
|
26
|
+
if (Object.getPrototypeOf(value) !== Array.prototype || ['toJSON', 'map', 'constructor'].some(key => Object.hasOwn(value, key))) return false;
|
|
27
|
+
for (let index = 0; index < value.length; index++) {
|
|
28
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, index);
|
|
29
|
+
if (!descriptor || !Object.hasOwn(descriptor, 'value') || !isCanonicalJson(descriptor.value)) return false;
|
|
30
|
+
}
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
if (!value || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype) return false;
|
|
34
|
+
if (Object.hasOwn(value, 'toJSON')) return false;
|
|
35
|
+
let previous;
|
|
36
|
+
for (const key of Object.keys(value)) {
|
|
37
|
+
if (previous !== undefined && previous > key) return false;
|
|
38
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
39
|
+
if (!Object.hasOwn(descriptor, 'value') || !isCanonicalJson(descriptor.value)) return false;
|
|
40
|
+
previous = key;
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
18
43
|
}
|
|
19
44
|
|
|
20
45
|
function requireRecord(value, label) {
|
|
@@ -115,6 +140,35 @@ const INLINE_SECRETS = Object.freeze([
|
|
|
115
140
|
},
|
|
116
141
|
]);
|
|
117
142
|
|
|
143
|
+
// Validation needs a verdict, not a rebuilt redacted payload. Keep exactly the writer's
|
|
144
|
+
// key and replacement semantics, including already-redacted markers and nested values.
|
|
145
|
+
function containsUnredactedSecret(value) {
|
|
146
|
+
const kinds = new Map();
|
|
147
|
+
const visit = (current) => {
|
|
148
|
+
if (typeof current === 'string') {
|
|
149
|
+
if (!/PRIVATE KEY|Bearer|(?:sk|api-key|ghp|github_pat)[-_]|password|passwd|token|secret|credential/i.test(current)) return false;
|
|
150
|
+
for (const rule of INLINE_SECRETS) {
|
|
151
|
+
rule.pattern.lastIndex = 0;
|
|
152
|
+
if (current.replace(rule.pattern, rule.replace) !== current) return true;
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
if (Array.isArray(current)) {
|
|
157
|
+
for (const item of current) if (visit(item)) return true;
|
|
158
|
+
} else if (current && typeof current === 'object' && Object.getPrototypeOf(current) === Object.prototype) {
|
|
159
|
+
for (const key of Object.keys(current)) {
|
|
160
|
+
if (!kinds.has(key)) kinds.set(key, secretKind(key));
|
|
161
|
+
const kind = kinds.get(key);
|
|
162
|
+
if (kind && current[key] !== null && current[key] !== undefined) {
|
|
163
|
+
if (current[key] !== `[REDACTED:${kind}]`) return true;
|
|
164
|
+
} else if (visit(current[key])) return true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return false;
|
|
168
|
+
};
|
|
169
|
+
return visit(value);
|
|
170
|
+
}
|
|
171
|
+
|
|
118
172
|
export function redactProgression(value) {
|
|
119
173
|
const redactions = [];
|
|
120
174
|
const mark = (path, kind) => {
|
|
@@ -239,15 +293,15 @@ export function validateProgressionSnapshot(snapshot, { expectedProjectIdentity
|
|
|
239
293
|
} catch { errors.push('event key is unverifiable'); }
|
|
240
294
|
}
|
|
241
295
|
try {
|
|
242
|
-
|
|
243
|
-
|
|
296
|
+
if (Object.getPrototypeOf(snapshot) !== Object.prototype) throw new TypeError('snapshot must be a plain JSON object');
|
|
297
|
+
const { payloadDigest: _digest, ...body } = snapshot;
|
|
244
298
|
if (snapshot.payloadDigest !== digestCanonical(body)) errors.push('payload digest mismatch');
|
|
245
299
|
} catch { errors.push('payload is not canonical JSON'); }
|
|
246
300
|
if (Array.isArray(snapshot.redactions)
|
|
247
301
|
&& snapshot.redactions.some((row) => !row || typeof row.path !== 'string' || typeof row.kind !== 'string'
|
|
248
302
|
|| Object.keys(row).sort().join(',') !== 'kind,path')) errors.push('invalid redaction marker');
|
|
249
303
|
try {
|
|
250
|
-
if (
|
|
304
|
+
if (containsUnredactedSecret(snapshot)) errors.push('unredacted secret material');
|
|
251
305
|
} catch { /* canonical validation already reports the malformed payload */ }
|
|
252
306
|
|
|
253
307
|
return { ok: errors.length === 0, errors: [...new Set(errors)] };
|
|
@@ -10,6 +10,7 @@ import fs from 'node:fs';
|
|
|
10
10
|
import os from 'node:os';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { spawnSync } from 'node:child_process';
|
|
13
|
+
import { projectDirectory } from './project-identity.mjs';
|
|
13
14
|
|
|
14
15
|
const DEFAULT_HOME = os.homedir();
|
|
15
16
|
const PROJECT_FILE = '.swarm/ruvnet-brain-settings.json';
|
|
@@ -306,7 +307,7 @@ if (process.argv.includes('--learning-scope')) {
|
|
|
306
307
|
*/
|
|
307
308
|
export function learningScope(options = {}) {
|
|
308
309
|
const env = options.env ?? process.env;
|
|
309
|
-
const cwd = options.cwd ??
|
|
310
|
+
const cwd = projectDirectory({ env, cwd: options.cwd ?? env.RUVNET_BRAIN_PROJECT_DIR ?? process.cwd() });
|
|
310
311
|
const configured = env.RUVNET_LEARNING_SCOPE
|
|
311
312
|
|| loadRuntimePreferences({ ...options, cwd }).values.learningScope;
|
|
312
313
|
return ['off', 'project', 'user'].includes(configured) ? configured : 'project';
|
|
@@ -321,7 +322,47 @@ export function learningScope(options = {}) {
|
|
|
321
322
|
* quiet" about the one training every few seconds.
|
|
322
323
|
*/
|
|
323
324
|
export function learnerCwd(options = {}) {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
325
|
+
return learningContext(options).learnerCwd;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** One scope snapshot for Console observation, remedies and the learning queue. Never creates paths. */
|
|
329
|
+
export function learningContext(options = {}) {
|
|
330
|
+
const env = options.env ?? process.env;
|
|
331
|
+
const requestedHome = options.home ?? env.HOME ?? os.homedir();
|
|
332
|
+
let home; try { home = fs.realpathSync.native(requestedHome); } catch { home = path.resolve(requestedHome); }
|
|
333
|
+
const projectDir = projectDirectory({ env, cwd: options.cwd ?? env.RUVNET_BRAIN_PROJECT_DIR ?? process.cwd() });
|
|
334
|
+
const scope = learningScope({ ...options, env, cwd: projectDir });
|
|
335
|
+
const learner = scope === 'user' ? home : projectDir;
|
|
336
|
+
let persistedOff = false;
|
|
337
|
+
let consentUnknown = !!env.RUVNET_LEARNING_SCOPE && !['off', 'project', 'user'].includes(env.RUVNET_LEARNING_SCOPE);
|
|
338
|
+
for (const file of Object.values(preferencePaths({ ...options, env, cwd: projectDir })).slice(0, 2)
|
|
339
|
+
.concat(preferencePaths({ ...options, env, cwd: projectDir }).project)) {
|
|
340
|
+
try {
|
|
341
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
342
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) consentUnknown = true;
|
|
343
|
+
for (const name of ['values', 'settings']) if (raw && Object.hasOwn(raw, name)) {
|
|
344
|
+
const inner = raw[name];
|
|
345
|
+
if (!inner || typeof inner !== 'object' || Array.isArray(inner)) consentUnknown = true;
|
|
346
|
+
}
|
|
347
|
+
const value = (raw.values || raw.settings || raw).learningScope;
|
|
348
|
+
persistedOff ||= value === 'off';
|
|
349
|
+
if (value !== undefined && !['off', 'project', 'user'].includes(value)) consentUnknown = true;
|
|
350
|
+
} catch (error) { if (!['ENOENT', 'ENOTDIR'].includes(error.code)) consentUnknown = true; }
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const state = env.RUVNET_BRAIN_STATE_DIR || path.join(home, '.config', 'ruvnet-brain');
|
|
354
|
+
let brainOff = env.RUVNET_BRAIN_OFF === '1';
|
|
355
|
+
try { fs.statSync(path.join(state, 'brain-off')); brainOff = true; }
|
|
356
|
+
catch (error) { if (!['ENOENT', 'ENOTDIR'].includes(error.code)) brainOff = true; }
|
|
357
|
+
let adopted = scope === 'user' || env.RUVNET_LEARNING_SCOPE === 'project';
|
|
358
|
+
try {
|
|
359
|
+
const swarm = path.join(projectDir, '.swarm');
|
|
360
|
+
adopted ||= fs.lstatSync(swarm).isDirectory() && fs.realpathSync.native(swarm) === swarm;
|
|
361
|
+
} catch { /* No implicit adoption or directory creation. */ }
|
|
362
|
+
return {
|
|
363
|
+
scope, projectDir, home, learnerCwd: learner, adopted, enabled: scope !== 'off' && !brainOff && !consentUnknown && !persistedOff && adopted,
|
|
364
|
+
disabledReason: consentUnknown ? 'unknown-consent' : brainOff ? 'brain-off' : scope === 'off' || persistedOff ? 'learning-off' : !adopted ? 'unadopted' : null,
|
|
365
|
+
queueDir: scope === 'user' ? path.join(home, '.cache', 'ruvnet-brain', 'learn')
|
|
366
|
+
: path.join(projectDir, '.swarm', 'ruvnet-brain-learn'),
|
|
367
|
+
};
|
|
327
368
|
}
|
|
@@ -38,6 +38,7 @@ import { stableSpine, heartbeat, knowledgeAutoUpdate, footprintCheck } from './s
|
|
|
38
38
|
import { FOOTPRINT_LINE_PREFIX } from './brain-confirmation.mjs';
|
|
39
39
|
import { describeLifecycleHooks, readHookContracts } from './session-start-hook-description.mjs';
|
|
40
40
|
import { createStageTracer } from './session-start-trace.mjs';
|
|
41
|
+
import { sessionStartProofRecorder } from './session-start-proof.mjs';
|
|
41
42
|
|
|
42
43
|
// Re-exported for callers/tests that import the entitlement check directly from this file's own
|
|
43
44
|
// long-standing public surface (tests/unit/session-start-core-parity.test.mjs).
|
|
@@ -170,6 +171,7 @@ export async function runSessionStart({
|
|
|
170
171
|
const activeVersion = typeof env.RUVNET_BRAIN_ACTIVE_VERSION === 'string'
|
|
171
172
|
? env.RUVNET_BRAIN_ACTIVE_VERSION : '';
|
|
172
173
|
const effectiveVersion = activeVersion || running;
|
|
174
|
+
const recordProof = sessionStartProofRecorder({ env, sourcePath: fileURLToPath(import.meta.url), cwd, version: effectiveVersion });
|
|
173
175
|
const updated = typeof manifest?.updated === 'string' ? manifest.updated : '';
|
|
174
176
|
const trace = (stage) => {
|
|
175
177
|
if (env.RUVNET_SESSION_TRACE === '1') {
|
|
@@ -178,13 +180,16 @@ export async function runSessionStart({
|
|
|
178
180
|
};
|
|
179
181
|
|
|
180
182
|
const restoreStart = Date.now();
|
|
183
|
+
let restoreFailed = false;
|
|
181
184
|
try {
|
|
182
185
|
const continuity = await restoreContinuity({ env, cwd });
|
|
183
186
|
if (continuity?.context) emit(continuity.context);
|
|
184
187
|
} catch {
|
|
188
|
+
restoreFailed = true;
|
|
185
189
|
emit('[RuvNet Brain — PROJECT CONTINUITY UNKNOWN]');
|
|
186
190
|
emit('The SessionStart restore boundary failed unexpectedly. Do not claim project state was restored; verify the canonical store before relying on remembered state.');
|
|
187
191
|
}
|
|
192
|
+
const restoreProof = { name: 'restore', ms: Date.now() - restoreStart, failed: restoreFailed };
|
|
188
193
|
// Turn health remains visible even when the separate progression restore fails.
|
|
189
194
|
const turnStatus = turnRecordingStatus({ projectDir: cwd, env, home });
|
|
190
195
|
if (turnStatus.state === 'warn') emit(`[RuvNet Brain — TURN RECORDING] ${turnStatus.line}`);
|
|
@@ -202,6 +207,7 @@ export async function runSessionStart({
|
|
|
202
207
|
write: (chunk) => stderr.write(chunk),
|
|
203
208
|
});
|
|
204
209
|
let bannerEmitted = false;
|
|
210
|
+
let bodyFailed = false;
|
|
205
211
|
|
|
206
212
|
try {
|
|
207
213
|
trace('body-start');
|
|
@@ -387,6 +393,7 @@ export async function runSessionStart({
|
|
|
387
393
|
}
|
|
388
394
|
});
|
|
389
395
|
} catch (error) {
|
|
396
|
+
bodyFailed = true;
|
|
390
397
|
if (env.RUVNET_SESSION_TRACE === '1') stderr.write(`SESSION_TRACE native-fail-open ${error?.message || error}\n`);
|
|
391
398
|
}
|
|
392
399
|
|
|
@@ -401,6 +408,7 @@ export async function runSessionStart({
|
|
|
401
408
|
meter({ env, cwd, stateDir, output });
|
|
402
409
|
stdout.write(output);
|
|
403
410
|
trace('body-finished');
|
|
411
|
+
recordProof({ stages: tracer.table(), restore: restoreProof, bodyFailed, bannerFallback: !bannerEmitted });
|
|
404
412
|
return { ok: true, outputBytes: Buffer.byteLength(output, 'utf8'), platform };
|
|
405
413
|
}
|
|
406
414
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Optional execution evidence, never a health verdict. Only overwrite a caller-owned nonce file.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
const digest = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
|
|
7
|
+
export function sessionStartProofRecorder({ env, sourcePath, cwd, version }) {
|
|
8
|
+
const file = env.RUVNET_BRAIN_HOST_PROOF_PATH;
|
|
9
|
+
const nonce = env.RUVNET_BRAIN_HOST_PROOF_NONCE;
|
|
10
|
+
if (!file && !nonce) return () => false;
|
|
11
|
+
let fd;
|
|
12
|
+
try {
|
|
13
|
+
if (process.platform === 'win32' || !path.isAbsolute(file || '') || !/^[a-f0-9]{64}$/.test(nonce || '')) return () => false;
|
|
14
|
+
const parent = path.dirname(file); const dir = fs.lstatSync(parent);
|
|
15
|
+
if (!dir.isDirectory() || dir.isSymbolicLink() || fs.realpathSync(parent) !== parent
|
|
16
|
+
|| dir.uid !== process.getuid() || (dir.mode & 0o777) !== 0o700) return () => false;
|
|
17
|
+
fd = fs.openSync(file, fs.constants.O_RDWR | fs.constants.O_NOFOLLOW);
|
|
18
|
+
const stat = fs.fstatSync(fd);
|
|
19
|
+
if (!stat.isFile() || stat.uid !== process.getuid() || (stat.mode & 0o777) !== 0o600 || stat.nlink !== 1 || stat.size > 1024) throw new Error('Unsafe proof file');
|
|
20
|
+
const seed = JSON.parse(fs.readFileSync(fd, 'utf8'));
|
|
21
|
+
if (seed.schema !== 1 || seed.state !== 'pending' || seed.nonce !== nonce || Object.keys(seed).length !== 3) throw new Error('Proof seed mismatch');
|
|
22
|
+
const sourceSha256 = digest(fs.readFileSync(sourcePath)); const startedAt = Date.now();
|
|
23
|
+
fs.closeSync(fd); fd = undefined;
|
|
24
|
+
return ({ stages, restore, bodyFailed, bannerFallback }) => {
|
|
25
|
+
let receiptFd;
|
|
26
|
+
try {
|
|
27
|
+
const current = fs.lstatSync(file);
|
|
28
|
+
const parentNow = fs.lstatSync(parent);
|
|
29
|
+
if (current.ino !== stat.ino || current.dev !== stat.dev || current.isSymbolicLink()
|
|
30
|
+
|| current.uid !== stat.uid || (current.mode & 0o777) !== 0o600 || current.nlink !== 1
|
|
31
|
+
|| parentNow.uid !== dir.uid || parentNow.ino !== dir.ino || parentNow.dev !== dir.dev || (parentNow.mode & 0o777) !== 0o700
|
|
32
|
+
|| fs.realpathSync(parent) !== parent || digest(fs.readFileSync(sourcePath)) !== sourceSha256) return false;
|
|
33
|
+
receiptFd = fs.openSync(file, fs.constants.O_RDWR | fs.constants.O_NOFOLLOW);
|
|
34
|
+
const opened = fs.fstatSync(receiptFd);
|
|
35
|
+
if (opened.ino !== stat.ino || opened.dev !== stat.dev) return false;
|
|
36
|
+
if (opened.size > 1024 || JSON.stringify(JSON.parse(fs.readFileSync(receiptFd, 'utf8'))) !== JSON.stringify(seed)) return false;
|
|
37
|
+
const receipt = { schema: 1, state: 'body-executed', nonce, pid: process.pid,
|
|
38
|
+
sourcePath, sourceSha256, cwd, version, startedAt, finishedAt: Date.now(),
|
|
39
|
+
restore, stages, bodyFailed, bannerFallback, scope: 'stage-return-milestones',
|
|
40
|
+
health: 'unknown', detachedMaintenance: 'possible' };
|
|
41
|
+
const bytes = `${JSON.stringify(receipt)}\n`;
|
|
42
|
+
fs.writeSync(receiptFd, bytes, 0, 'utf8');
|
|
43
|
+
fs.ftruncateSync(receiptFd, Buffer.byteLength(bytes));
|
|
44
|
+
fs.fsyncSync(receiptFd); return true;
|
|
45
|
+
} catch { return false; }
|
|
46
|
+
finally { if (receiptFd !== undefined) fs.closeSync(receiptFd); }
|
|
47
|
+
};
|
|
48
|
+
} catch { if (fd !== undefined) fs.closeSync(fd); return () => false; }
|
|
49
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Evidence from a fresh native metadata process; Codex may refresh its own marketplace cache.
|
|
2
|
+
// No configuration write or hook-body execution is requested by this probe. It does not
|
|
3
|
+
// prove MCP readiness or declarations frozen in existing windows.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { codexHookIdentities, codexHookHash } from './codex-hook-trust.mjs';
|
|
9
|
+
import { obtainVerifiedPublishedHookIdentity } from './codex-hook-trust-reconcile.mjs';
|
|
10
|
+
|
|
11
|
+
const OWNER = 'ruvnet-brain@ruvnet-brain';
|
|
12
|
+
const FILES = ['.codex-plugin/plugin.json', 'hooks/codex-hooks.json'];
|
|
13
|
+
const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
|
|
14
|
+
const failed = (reason) => ({ ok: false, state: 'fresh-declarations-unproven', scope: 'fresh-codex-hook-declarations', reason,
|
|
15
|
+
hookBodiesExecuted: false, mcpReadiness: 'unknown', existingWindows: 'unproven', inferenceRequests: 0, nativeCacheRefresh: 'possible' });
|
|
16
|
+
|
|
17
|
+
function readSurface(root) {
|
|
18
|
+
const bytes = FILES.map((file) => {
|
|
19
|
+
const target = path.join(root, file); const stat = fs.lstatSync(target);
|
|
20
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024) throw new Error('Plugin source is unavailable or unsafe');
|
|
21
|
+
return fs.readFileSync(target);
|
|
22
|
+
});
|
|
23
|
+
return { manifest: JSON.parse(bytes[0]), hooks: JSON.parse(bytes[1]), hashes: Object.fromEntries(FILES.map((file, i) => [file, sha256(bytes[i])])) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function classifyFreshCodexDeclarations({ plugin, listed, target, expectedVersion, codexHome, cwd }) {
|
|
27
|
+
if (!plugin?.installed || plugin.enabled !== true || plugin.version !== expectedVersion) return failed('Current Codex plugin is missing, disabled, or stale');
|
|
28
|
+
if (target?.manifest?.name !== 'ruvnet-brain' || target.manifest.version !== expectedVersion) return failed('Released target version is unavailable');
|
|
29
|
+
let root;
|
|
30
|
+
try {
|
|
31
|
+
root = fs.realpathSync(path.join(codexHome, 'plugins', 'cache', 'ruvnet-brain', 'ruvnet-brain', expectedVersion));
|
|
32
|
+
if (plugin.installPath && fs.realpathSync(plugin.installPath) !== root) return failed('Native plugin source differs from the managed version');
|
|
33
|
+
const cache = fs.realpathSync(path.join(codexHome, 'plugins', 'cache', 'ruvnet-brain', 'ruvnet-brain'));
|
|
34
|
+
if (!root.startsWith(`${cache}${path.sep}`)) return failed('Native plugin source is outside the managed cache');
|
|
35
|
+
if (JSON.stringify(readSurface(root).hashes) !== JSON.stringify(target.hashes)) return failed('Installed declarations differ from the independent released target');
|
|
36
|
+
} catch { return failed('Native plugin source cannot be verified'); }
|
|
37
|
+
const groups = listed?.data;
|
|
38
|
+
if (!Array.isArray(groups) || groups.length !== 1 || groups[0]?.cwd !== cwd
|
|
39
|
+
|| !Array.isArray(groups[0]?.hooks) || !Array.isArray(groups[0]?.errors) || !Array.isArray(groups[0]?.warnings)) return failed('Native registry shape or project scope is unproven');
|
|
40
|
+
if (groups[0].errors.length || groups[0].warnings.length) return failed('Native registry reports errors or warnings');
|
|
41
|
+
const rows = groups[0].hooks.filter((row) => row?.pluginId === OWNER);
|
|
42
|
+
const identities = codexHookIdentities(target.hooks);
|
|
43
|
+
if (!identities.size || rows.length !== identities.size || new Set(rows.map((row) => row.key)).size !== identities.size) return failed('Brain hook declarations are missing, duplicated, or unexpected');
|
|
44
|
+
for (const row of rows) {
|
|
45
|
+
if (row.source !== 'plugin' || row.sourcePath !== path.join(root, FILES[1]) || row.enabled !== true
|
|
46
|
+
|| !['trusted', 'managed'].includes(row.trustStatus)) return failed('Brain hook source, enabled state, or trust is unproven');
|
|
47
|
+
// Verify the metadata itself, as well as the native currentHash. Foreign owner hooks are untouched.
|
|
48
|
+
const event = Object.keys(target.hooks.hooks).find((name) => `${name[0].toLowerCase()}${name.slice(1)}` === row.eventName);
|
|
49
|
+
const metadataHash = codexHookHash(event, { matcher: row.matcher }, {
|
|
50
|
+
type: row.handlerType, command: row.command, timeout: row.timeoutSec, async: row.async,
|
|
51
|
+
statusMessage: row.statusMessage, additionalContextLimit: row.additionalContextLimit,
|
|
52
|
+
});
|
|
53
|
+
if (!identities.has(row.key) || identities.get(row.key) !== row.currentHash || metadataHash !== row.currentHash) return failed('Brain hook metadata differs from released declarations');
|
|
54
|
+
}
|
|
55
|
+
return { ok: true, state: 'fresh-declarations-ready', scope: 'fresh-codex-hook-declarations', expectedVersion,
|
|
56
|
+
pluginRoot: root, sourceHashes: target.hashes, hookCount: rows.length, hookBodiesExecuted: false,
|
|
57
|
+
mcpReadiness: 'unknown', existingWindows: 'unproven', inferenceRequests: 0 };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function nativeBinary(binary, env) {
|
|
61
|
+
const names = path.isAbsolute(binary) ? [binary] : String(env.PATH || '').split(path.delimiter).map((dir) => path.join(dir, binary));
|
|
62
|
+
for (const name of names) {
|
|
63
|
+
try {
|
|
64
|
+
const real = fs.realpathSync(name); const stat = fs.statSync(real);
|
|
65
|
+
if (!stat.isFile() || stat.size > 256 * 1024 * 1024) continue;
|
|
66
|
+
fs.accessSync(real, fs.constants.X_OK);
|
|
67
|
+
const bytes = fs.readFileSync(real); const magic = bytes.subarray(0, 4).toString('hex');
|
|
68
|
+
if (!['cffaedfe', 'feedfacf', 'cefaedfe', 'feedface', 'cafebabe', 'bebafeca', '7f454c46'].includes(magic) && bytes.subarray(0, 2).toString() !== 'MZ') continue;
|
|
69
|
+
return { path: real, sha256: sha256(bytes) };
|
|
70
|
+
} catch { /* try the next executable */ }
|
|
71
|
+
}
|
|
72
|
+
throw new Error('Actual native Codex binary is unavailable');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One deadline and cumulative stdout/stderr budget across every owned native process. No API turns. */
|
|
76
|
+
export async function probeFreshCodexDeclarations({ binary = 'codex', codexHome, cwd, releasedPluginRoot,
|
|
77
|
+
expectedVersion, timeoutMs = 12_000, publishedIdentity, verifyPublished = obtainVerifiedPublishedHookIdentity, maxBytes = 2 * 1024 * 1024, env = process.env, spawnChild = spawn } = {}) {
|
|
78
|
+
const children = new Map(); let totalBytes = 0; let deadline; let identity; let target;
|
|
79
|
+
const nativeEnv = { ...env };
|
|
80
|
+
for (const key of ['OPENAI_API_KEY', 'OPENAI_BASE_URL', 'ANTHROPIC_API_KEY', 'CODEX_API_KEY']) delete nativeEnv[key];
|
|
81
|
+
const remaining = () => { const ms = deadline - Date.now(); if (ms <= 0) throw new Error('Fresh declaration proof deadline exceeded'); return ms; };
|
|
82
|
+
const consume = (chunk) => { totalBytes += chunk.length; if (totalBytes > maxBytes) throw new Error('Fresh declaration proof output exceeds bound'); };
|
|
83
|
+
const own = (args) => {
|
|
84
|
+
remaining();
|
|
85
|
+
const child = spawnChild(identity.path, args, { cwd, env: { ...nativeEnv, CODEX_HOME: codexHome }, stdio: ['pipe', 'pipe', 'pipe'], shell: false });
|
|
86
|
+
const exited = new Promise((resolve) => { child.once('exit', resolve); child.once('error', () => { if (!child.pid) resolve(); }); });
|
|
87
|
+
children.set(child, exited); child.stdin.on('error', () => {}); return child;
|
|
88
|
+
};
|
|
89
|
+
const command = (args) => new Promise((resolve, reject) => {
|
|
90
|
+
const child = own(args); let stdout = ''; let done = false;
|
|
91
|
+
const finish = (error, value) => { if (done) return; done = true; clearTimeout(timer); error ? reject(error) : resolve(value); };
|
|
92
|
+
const timer = setTimeout(() => finish(new Error('Fresh declaration proof deadline exceeded')), remaining());
|
|
93
|
+
child.on('error', () => finish(new Error('Native metadata command failed')));
|
|
94
|
+
child.stdout.on('data', (chunk) => { try { consume(chunk); stdout += chunk; } catch (error) { finish(error); } });
|
|
95
|
+
child.stderr.on('data', (chunk) => { try { consume(chunk); } catch (error) { finish(error); } });
|
|
96
|
+
child.once('exit', (code) => finish(code === 0 ? null : new Error('Native metadata command refused'), stdout)); child.stdin.end();
|
|
97
|
+
});
|
|
98
|
+
try {
|
|
99
|
+
if (![codexHome, cwd, releasedPluginRoot].every((value) => typeof value === 'string' && path.isAbsolute(value))
|
|
100
|
+
|| !Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 12_000
|
|
101
|
+
|| !Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 8 * 1024 * 1024) throw new Error('Fresh declaration proof boundaries are invalid');
|
|
102
|
+
deadline = Date.now() + timeoutMs;
|
|
103
|
+
codexHome = fs.realpathSync(codexHome); cwd = fs.realpathSync(cwd); releasedPluginRoot = fs.realpathSync(releasedPluginRoot);
|
|
104
|
+
target = readSurface(releasedPluginRoot); identity = nativeBinary(binary, env); remaining();
|
|
105
|
+
const published = publishedIdentity || await verifyPublished({ version: expectedVersion, timeoutMs: remaining() });
|
|
106
|
+
const anchor = published.releaseIdentity;
|
|
107
|
+
if (published.state !== 'verified' || anchor?.version !== expectedVersion
|
|
108
|
+
|| anchor.hooksSha256 !== target.hashes['hooks/codex-hooks.json']) throw new Error('Released hook declarations are not independently verified');
|
|
109
|
+
const managedRoot = path.join(codexHome, 'plugins', 'cache', 'ruvnet-brain', 'ruvnet-brain', expectedVersion);
|
|
110
|
+
const nativeVersion = (await command(['--version'])).trim();
|
|
111
|
+
if (!/^codex-cli \S+$/.test(nativeVersion)) throw new Error('Native Codex version is unavailable');
|
|
112
|
+
const inventory = async () => {
|
|
113
|
+
const listed = JSON.parse(await command(['plugin', 'list', '--json']));
|
|
114
|
+
if (!Array.isArray(listed?.installed)) throw new Error('Native plugin inventory is unavailable');
|
|
115
|
+
const rows = listed.installed.filter((row) => row?.pluginId === OWNER);
|
|
116
|
+
if (rows.length !== 1) throw new Error('Brain plugin inventory is missing or duplicated');
|
|
117
|
+
return rows[0];
|
|
118
|
+
};
|
|
119
|
+
const before = await inventory();
|
|
120
|
+
const beforeSurface = readSurface(fs.realpathSync(managedRoot));
|
|
121
|
+
if (JSON.stringify(beforeSurface.hashes) !== JSON.stringify(target.hashes)) throw new Error('Installed declarations differ from the independent released target');
|
|
122
|
+
const listed = await new Promise((resolve, reject) => {
|
|
123
|
+
const child = own(['app-server', '--listen', 'stdio://']); let buffer = ''; let done = false; let phase = 1; let observed;
|
|
124
|
+
const finish = (error, value) => { if (done) return; done = true; clearTimeout(timer); error ? reject(error) : resolve(value); };
|
|
125
|
+
const timer = setTimeout(() => finish(new Error('Fresh declaration proof deadline exceeded')), remaining());
|
|
126
|
+
const send = (message) => child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
127
|
+
child.on('error', () => finish(new Error('Fresh native metadata process failed')));
|
|
128
|
+
child.once('exit', () => finish(new Error('Fresh native metadata process exited before proof')));
|
|
129
|
+
child.stderr.on('data', (chunk) => { try { consume(chunk); } catch (error) { finish(error); } });
|
|
130
|
+
child.stdout.on('data', (chunk) => {
|
|
131
|
+
try {
|
|
132
|
+
consume(chunk); buffer += chunk;
|
|
133
|
+
while (!done && buffer.includes('\n')) {
|
|
134
|
+
const end = buffer.indexOf('\n'); const line = buffer.slice(0, end); buffer = buffer.slice(end + 1);
|
|
135
|
+
const row = JSON.parse(line); if (row.id !== phase) continue;
|
|
136
|
+
if (row.error) throw new Error('Native metadata proof was refused');
|
|
137
|
+
if (phase === 1) { phase = 2; send({ method: 'initialized' }); send({ id: 2, method: 'hooks/list', params: { cwds: [cwd] } }); }
|
|
138
|
+
else if (phase === 2) { observed = row.result; phase = 3; send({ id: 3, method: 'hooks/list', params: { cwds: [cwd] } }); }
|
|
139
|
+
else if (JSON.stringify(observed) !== JSON.stringify(row.result)) throw new Error('Native registry changed during proof');
|
|
140
|
+
else finish(null, row.result);
|
|
141
|
+
}
|
|
142
|
+
} catch (error) { finish(error); }
|
|
143
|
+
});
|
|
144
|
+
send({ id: 1, method: 'initialize', params: { clientInfo: { name: 'rnb_fresh_declarations_doctor', version: '1' }, capabilities: { experimentalApi: true } } });
|
|
145
|
+
});
|
|
146
|
+
const after = await inventory();
|
|
147
|
+
if (JSON.stringify(before) !== JSON.stringify(after) || JSON.stringify(readSurface(managedRoot).hashes) !== JSON.stringify(beforeSurface.hashes)
|
|
148
|
+
|| JSON.stringify(readSurface(releasedPluginRoot).hashes) !== JSON.stringify(target.hashes)
|
|
149
|
+
|| nativeBinary(identity.path, env).sha256 !== identity.sha256 || (await command(['--version'])).trim() !== nativeVersion) throw new Error('Plugin, native binary, or released source changed during proof');
|
|
150
|
+
remaining();
|
|
151
|
+
return { ...classifyFreshCodexDeclarations({ plugin: after, listed, target, expectedVersion, codexHome, cwd }),
|
|
152
|
+
publishedRelease: anchor, nativeBinary: identity.path, nativeBinarySha256: identity.sha256, nativeVersion, codexHome, cwd, totalOutputBytes: totalBytes };
|
|
153
|
+
} catch (error) { return failed(error instanceof SyntaxError ? 'Native metadata or released source is malformed' : error.message); }
|
|
154
|
+
finally {
|
|
155
|
+
// Never contact or terminate a shared daemon. Retire only children created by this proof.
|
|
156
|
+
const retired = await Promise.all([...children].map(async ([child, exited]) => {
|
|
157
|
+
const stopped = () => child.exitCode !== null || child.signalCode !== null;
|
|
158
|
+
if (stopped()) return true;
|
|
159
|
+
const wait = async () => {
|
|
160
|
+
let timer;
|
|
161
|
+
try { await Promise.race([exited, new Promise((resolve) => {
|
|
162
|
+
timer = setTimeout(resolve, Math.max(0, Math.min(250, deadline - Date.now())));
|
|
163
|
+
})]); } finally { clearTimeout(timer); }
|
|
164
|
+
};
|
|
165
|
+
child.stdin.destroy();
|
|
166
|
+
try { child.kill('SIGTERM'); } catch { /* bounded second attempt below */ }
|
|
167
|
+
await wait();
|
|
168
|
+
if (!stopped()) { try { child.kill('SIGKILL'); } catch { /* report unverified retirement */ } await wait(); }
|
|
169
|
+
if (!stopped()) {
|
|
170
|
+
// A referenced ChildProcess or pipe can keep the doctor CLI alive after this promise returns.
|
|
171
|
+
child.stdout.destroy(); child.stderr.destroy(); child.unref?.();
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
return true;
|
|
175
|
+
}));
|
|
176
|
+
if (retired.includes(false)) return { ...failed('Owned native proof child retirement is unverified'),
|
|
177
|
+
unverifiedOwnedChildPids: [...children.keys()].filter((_child, index) => retired[index] === false).map((child) => child.pid).filter(Number.isSafeInteger) };
|
|
178
|
+
}
|
|
179
|
+
}
|