ruvnet-brain 4.5.6 → 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/kb/forge-update.mjs +50 -9
- package/package.json +5 -3
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/configure.md +13 -1
- package/plugin/hooks/codex-hooks.json +31 -1
- package/plugin/hooks/hook-contracts.json +82 -2
- package/plugin/hooks/hooks.json +31 -1
- package/plugin/mcp/managed-cli-generation.mjs +91 -0
- package/plugin/mcp/managed-cli-interface.mjs +36 -12
- package/plugin/mcp/server.mjs +3 -2
- package/plugin/scripts/agentdb-recall.mjs +38 -10
- package/plugin/scripts/continuation-gate.mjs +3 -1
- package/plugin/scripts/continuity-hook-policy.mjs +3 -0
- package/plugin/scripts/continuity-journal.mjs +11 -11
- package/plugin/scripts/hook-shim.mjs +3 -2
- package/plugin/scripts/kb-copy-proof.mjs +84 -22
- 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/nightly-scheduler.mjs +6 -2
- package/plugin/scripts/project-capture-queue.mjs +16 -7
- package/plugin/scripts/project-progression-contract.mjs +58 -4
- package/plugin/scripts/project-progression-outbox.mjs +22 -6
- package/plugin/scripts/project-progression-session-start.mjs +10 -0
- package/plugin/scripts/project-progression-suspension.mjs +60 -0
- package/plugin/scripts/project-transition-hook.mjs +25 -8
- package/plugin/scripts/runtime-preferences.mjs +45 -4
- package/plugin/scripts/session-snapshot-hook.mjs +14 -7
- package/plugin/scripts/session-start-core.mjs +8 -0
- package/plugin/scripts/session-start-proof.mjs +49 -0
- package/plugin/scripts/turn-outcome-capture.mjs +7 -3
- 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 +76 -7
- package/scripts/remedy-registry.mjs +9 -2
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Operator containment only (#390 B). No store, checkpoint, event, turn or search is disabled here.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
|
|
8
|
+
const suspendedResults = new WeakSet();
|
|
9
|
+
export function progressionSuspensionFile(env = process.env) {
|
|
10
|
+
return path.join(env.RUVNET_BRAIN_STATE_DIR || path.join(env.HOME || os.homedir(), '.config', 'ruvnet-brain'), 'brain-progression-suspension');
|
|
11
|
+
}
|
|
12
|
+
export function operatorProgressionSuspension(env = process.env) {
|
|
13
|
+
if (env.RUVNET_BRAIN_PROGRESSION_SUSPENDED === '1') return { source: 'environment' };
|
|
14
|
+
if (env.RUVNET_BRAIN_PROGRESSION_SUSPENDED && env.RUVNET_BRAIN_PROGRESSION_SUSPENDED !== '0') {
|
|
15
|
+
throw new Error('invalid automatic progression suspension environment setting');
|
|
16
|
+
}
|
|
17
|
+
let value;
|
|
18
|
+
try { value = JSON.parse(fs.readFileSync(progressionSuspensionFile(env), 'utf8')); }
|
|
19
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw new Error('automatic progression suspension policy unreadable or invalid'); }
|
|
20
|
+
if (value?.schemaVersion !== 1 || typeof value.suspended !== 'boolean') {
|
|
21
|
+
throw new Error('automatic progression suspension policy unreadable or invalid');
|
|
22
|
+
}
|
|
23
|
+
return value.suspended ? { source: 'operator-state' } : null;
|
|
24
|
+
}
|
|
25
|
+
export function automaticProgressionSuspensionResult(env = process.env, fields = {}) {
|
|
26
|
+
const control = operatorProgressionSuspension(env);
|
|
27
|
+
if (!control) return null;
|
|
28
|
+
const result = Object.freeze({ ...fields, progressionSuspended: true, progressionCaptured: false,
|
|
29
|
+
receipt: null, skipped: 'operator suspended automatic project progression', suspensionSource: control.source });
|
|
30
|
+
suspendedResults.add(result);
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
// A look-alike public flag is never authority to bypass an unexpected capture failure.
|
|
34
|
+
export const isAutomaticProgressionSuspension = (result) => Boolean(result && suspendedResults.has(result));
|
|
35
|
+
|
|
36
|
+
/** Explicit operator configuration: replacing this control file never touches evidence or queues. */
|
|
37
|
+
export function setOperatorProgressionSuspension(suspended, { env = process.env } = {}) {
|
|
38
|
+
if (typeof suspended !== 'boolean') throw new TypeError('suspended must be a boolean');
|
|
39
|
+
const file = progressionSuspensionFile(env);
|
|
40
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
41
|
+
const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
42
|
+
try {
|
|
43
|
+
fs.writeFileSync(temporary, JSON.stringify({ schemaVersion: 1, suspended }), { flag: 'wx', mode: 0o600 });
|
|
44
|
+
fs.renameSync(temporary, file);
|
|
45
|
+
} finally { try { fs.unlinkSync(temporary); } catch { /* renamed or not created */ } }
|
|
46
|
+
return operatorProgressionSuspension(env);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
50
|
+
try {
|
|
51
|
+
const argv = process.argv.slice(2);
|
|
52
|
+
if (argv.length !== 1 || !['--suspend', '--resume', '--status'].includes(argv[0])) {
|
|
53
|
+
throw new Error('usage: project-progression-suspension.mjs --suspend | --resume | --status');
|
|
54
|
+
}
|
|
55
|
+
if (argv[0] !== '--status') setOperatorProgressionSuspension(argv[0] === '--suspend');
|
|
56
|
+
const control = operatorProgressionSuspension();
|
|
57
|
+
console.log(JSON.stringify({ automaticProgression: control ? 'operator-suspended' : 'enabled', source: control?.source || null,
|
|
58
|
+
evidencePreserved: true, explicitCheckpointsAvailable: true }));
|
|
59
|
+
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
|
60
|
+
}
|
|
@@ -5,9 +5,11 @@ import crypto from 'node:crypto';
|
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import { developmentHooksSuspended } from './development-maintenance.mjs';
|
|
7
7
|
import { resolveTurnDb } from './turn-outcome-capture.mjs';
|
|
8
|
+
import { automaticProgressionSuspensionResult } from './project-progression-suspension.mjs';
|
|
8
9
|
import { execFileSync } from 'node:child_process';
|
|
9
10
|
import { fileURLToPath } from 'node:url';
|
|
10
11
|
import { redactText } from './continuity-events.mjs';
|
|
12
|
+
import { conditionNotice } from './continuity-journal.mjs';
|
|
11
13
|
import { normalizeHostEvent } from './hook-input.mjs';
|
|
12
14
|
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
13
15
|
import { withProgressionReader } from './project-progression-reader.mjs';
|
|
@@ -148,7 +150,9 @@ export function observeTransitionSource(resolution, projectDir = resolution.chec
|
|
|
148
150
|
|
|
149
151
|
/** Called only by the fenced queue drainer; current heads merge, original observations stay fixed. */
|
|
150
152
|
export function captureNormalizedTransition(job, { readHistory = readTransitionHistory, capture = runSessionSnapshotHook,
|
|
151
|
-
budgetMs = 6500, makeStoreFactory, now = Date.now } = {}) {
|
|
153
|
+
budgetMs = 6500, makeStoreFactory, now = Date.now, env = process.env } = {}) {
|
|
154
|
+
const suspended = automaticProgressionSuspensionResult(env);
|
|
155
|
+
if (suspended) return suspended;
|
|
152
156
|
const deadlineAt = now() + budgetMs;
|
|
153
157
|
const resolution = resolveProjectStore({ projectDir: job.originProjectDir, gitTimeoutMs: Math.max(1, Math.min(500, budgetMs)) });
|
|
154
158
|
const normalized = job.payload.normalizedTransition;
|
|
@@ -173,7 +177,7 @@ export function captureNormalizedTransition(job, { readHistory = readTransitionH
|
|
|
173
177
|
const progression = buildRestoredTransitionProgression({ resolution, observation: normalized.observation,
|
|
174
178
|
snapshots, sessionIdentity: job.payload.session_id, host: job.host,
|
|
175
179
|
sourceIdentity: normalized.sourceIdentity }, restored);
|
|
176
|
-
const result = capture(job.originProjectDir, job.event, { host: job.host, budgetMs: Math.max(0, deadlineAt - now()),
|
|
180
|
+
const result = capture(job.originProjectDir, job.event, { host: job.host, env, budgetMs: Math.max(0, deadlineAt - now()),
|
|
177
181
|
...(makeStoreFactory ? { makeStoreFactory } : {}), writeMetadata: false,
|
|
178
182
|
rawInput: JSON.stringify({ session_id: job.payload.session_id, hook_event_name: job.event, projectProgression: progression }),
|
|
179
183
|
captureTurn: () => ({ recorded: false, skipped: 'transition boundary' }),
|
|
@@ -184,6 +188,8 @@ export function captureNormalizedTransition(job, { readHistory = readTransitionH
|
|
|
184
188
|
export function runProjectTransitionHook(projectDir, event, { payload = {}, host = process.env.RUVNET_HOOK_HOST || 'claude',
|
|
185
189
|
readHistory = readTransitionHistory, capture = runSessionSnapshotHook, env = process.env } = {}) {
|
|
186
190
|
if (developmentHooksSuspended(projectDir)) return { state: 'skipped', reason: 'development hooks suspended' };
|
|
191
|
+
const suspended = automaticProgressionSuspensionResult(env, { state: 'suspended', reason: 'automatic project progression is operator-suspended' });
|
|
192
|
+
if (suspended) return suspended;
|
|
187
193
|
const observation = normalizeTransition(payload, event, { host });
|
|
188
194
|
if (observation.skipped) return { state: 'skipped', reason: observation.skipped };
|
|
189
195
|
const brainHome = env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
|
|
@@ -194,22 +200,33 @@ export function runProjectTransitionHook(projectDir, event, { payload = {}, host
|
|
|
194
200
|
const transportEvent = event === 'PostToolUseFailure' ? 'PostToolUse' : event;
|
|
195
201
|
// Fsync the selected observation BEFORE any history enumeration/merge. A deadline, corruption,
|
|
196
202
|
// or long-lived project must leave it pending rather than erase it or fabricate root ancestry.
|
|
197
|
-
const queued = queueCapture({ projectDir: resolution.projectRoot, originProjectDir: projectDir, event: transportEvent, host,
|
|
203
|
+
const queued = queueCapture({ projectDir: resolution.projectRoot, originProjectDir: projectDir, env, event: transportEvent, host,
|
|
198
204
|
payload: { session_id: payload.session_id, hook_event_name: transportEvent,
|
|
199
205
|
normalizedTransition: { observation, sourceIdentity: observeTransitionSource(resolution, projectDir) } } });
|
|
200
206
|
if (!queued) return { state: 'degraded', reason: 'normalized observation queue unwritable', eventId: observation.id };
|
|
201
207
|
let result = null;
|
|
202
|
-
runOutboxReplay({ projectDir: resolution.projectRoot, budgetMs: Math.min(6500, effectiveBudgetMs(env)),
|
|
203
|
-
captureNormalized: (job, options) => captureNormalizedTransition(job, { ...options, readHistory, capture }),
|
|
208
|
+
runOutboxReplay({ projectDir: resolution.projectRoot, env, budgetMs: Math.min(6500, effectiveBudgetMs(env)),
|
|
209
|
+
captureNormalized: (job, options) => captureNormalizedTransition(job, { ...options, env, readHistory, capture }),
|
|
204
210
|
onCaptured: (captured) => { if (captured?.eventId === observation.id) result = captured; } });
|
|
205
|
-
if (!result?.receipt) replayOutboxDetached({ projectDir: resolution.projectRoot });
|
|
211
|
+
if (!result?.receipt) replayOutboxDetached({ projectDir: resolution.projectRoot, env });
|
|
206
212
|
return { state: result?.progressionCaptured && result.receipt ? 'committed' : 'pending', eventId: observation.id, result };
|
|
207
213
|
}
|
|
208
214
|
|
|
215
|
+
/** One pending-readback condition across prompt/tool boundaries and both CLI entrypoints. */
|
|
216
|
+
export function transitionPendingNotice(projectDir, payload, message) {
|
|
217
|
+
const { projectRoot } = resolveProjectStore({ projectDir, gitTimeoutMs: 500 });
|
|
218
|
+
return conditionNotice({ swarm: path.join(projectRoot, '.swarm'),
|
|
219
|
+
session: normalizeHostEvent(payload)?.session_id, condition: 'project-transition-pending-readback', message });
|
|
220
|
+
}
|
|
221
|
+
|
|
209
222
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
210
223
|
try {
|
|
211
224
|
const payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}');
|
|
212
|
-
const
|
|
213
|
-
|
|
225
|
+
const projectDir = payload.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
226
|
+
const result = runProjectTransitionHook(projectDir, process.argv[2], { payload });
|
|
227
|
+
if (result.state === 'pending') {
|
|
228
|
+
const message = transitionPendingNotice(projectDir, payload, 'Project memory transition is pending; exact AgentDB readback was not verified at this boundary.');
|
|
229
|
+
if (message) process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: process.argv[2], additionalContext: message } }));
|
|
230
|
+
}
|
|
214
231
|
} catch { process.stdout.write(JSON.stringify({ systemMessage: 'Project memory transition capture degraded; exact readback was not verified.' })); }
|
|
215
232
|
}
|
|
@@ -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
|
}
|
|
@@ -17,6 +17,7 @@ import { ProjectProgressionStore } from './project-progression-store.mjs';
|
|
|
17
17
|
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
18
18
|
import { resolveTurnDb, captureTurnOutcome } from './turn-outcome-capture.mjs';
|
|
19
19
|
import { captureContinuityEvents, stopNotice } from './continuity-journal.mjs';
|
|
20
|
+
import { automaticProgressionSuspensionResult } from './project-progression-suspension.mjs';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* The capture boundary's whole budget. hooks.json declares 10s; this keeps the internal work well
|
|
@@ -158,6 +159,8 @@ export function runSessionSnapshotHook(projectDir, event, {
|
|
|
158
159
|
continuity = { recorded: 0, skipped: `continuity capture failed: ${error.message}` };
|
|
159
160
|
}
|
|
160
161
|
const idle = { metadataWritten, progressionCaptured: false, receipt: null, turn, continuity };
|
|
162
|
+
const operatorSuspended = automaticProgressionSuspensionResult(env, idle);
|
|
163
|
+
if (operatorSuspended) return operatorSuspended;
|
|
161
164
|
|
|
162
165
|
if (hasProjectProgression(payload)) {
|
|
163
166
|
if (payload.hook_event_name !== event) {
|
|
@@ -206,7 +209,7 @@ export function runSessionSnapshotHook(projectDir, event, {
|
|
|
206
209
|
try { frozen = produce({ resolution, projectDir, payload, host, trigger: event }); } catch { frozen = null; }
|
|
207
210
|
const queued = frozen?.projectProgression ? queueCapture({ projectDir: root, originProjectDir: projectDir, env, event, host,
|
|
208
211
|
payload: { session_id: payload.session_id, hook_event_name: event, projectProgression: frozen.projectProgression } }) : null;
|
|
209
|
-
const handed = queued ? spawnReplay({ projectDir: root, token }) : false;
|
|
212
|
+
const handed = queued ? spawnReplay({ projectDir: root, token, env }) : false;
|
|
210
213
|
if (!handed && token && token !== ordered) releaseReplayLock(root, token);
|
|
211
214
|
return { ...idle, replayed: 0, progressionCaptured: false, deferredToReplayer: Boolean(queued),
|
|
212
215
|
replaySkipped: `${why}; this capture ${queued ? 'queued behind it' : 'NOT queued (queue unwritable)'}`
|
|
@@ -262,7 +265,7 @@ export function runSessionSnapshotHook(projectDir, event, {
|
|
|
262
265
|
// NOT LOST — DEFERRED. capture() fsyncs the snapshot to the durable outbox BEFORE it writes to
|
|
263
266
|
// the store, so a budget overrun leaves the evidence on disk. On a short budget nothing later in
|
|
264
267
|
// this process can settle it, so the lock goes straight to a detached worker.
|
|
265
|
-
handedLock = !ordered && budgetMs < REPLAY_MIN_BUDGET_MS && pendingCount() > 0 && spawnReplay({ projectDir: root, token });
|
|
268
|
+
handedLock = !ordered && budgetMs < REPLAY_MIN_BUDGET_MS && pendingCount() > 0 && spawnReplay({ projectDir: root, token, env });
|
|
266
269
|
return { ...idle, replayed, skipped: `capture deferred: ${error.message}`,
|
|
267
270
|
...(handedLock ? { replaySkipped: 'deferred capture handed to a detached worker' } : {}) };
|
|
268
271
|
}
|
|
@@ -281,9 +284,9 @@ export function runSessionSnapshotHook(projectDir, event, {
|
|
|
281
284
|
// simultaneous SessionEnds lost the second one's final state (4.4.1). So: queued work → hand THIS
|
|
282
285
|
// lock to a worker; and re-check after releasing, for a boundary that queued in between.
|
|
283
286
|
if (!ordered && !handedLock) {
|
|
284
|
-
if (!(queuedWork(root) && spawnReplay({ projectDir: root, token }))) {
|
|
287
|
+
if (!(queuedWork(root) && spawnReplay({ projectDir: root, token, env }))) {
|
|
285
288
|
releaseReplayLock(root, token);
|
|
286
|
-
if (queuedWork(root)) spawnReplay({ projectDir: root });
|
|
289
|
+
if (queuedWork(root)) spawnReplay({ projectDir: root, env });
|
|
287
290
|
}
|
|
288
291
|
}
|
|
289
292
|
}
|
|
@@ -297,10 +300,14 @@ if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-
|
|
|
297
300
|
// Finish evaluating this module before importing its transition consumer.
|
|
298
301
|
void (async () => {
|
|
299
302
|
try {
|
|
300
|
-
const { runProjectTransitionHook } = await import('./project-transition-hook.mjs');
|
|
303
|
+
const { runProjectTransitionHook, transitionPendingNotice } = await import('./project-transition-hook.mjs');
|
|
301
304
|
const payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}');
|
|
302
|
-
const
|
|
303
|
-
|
|
305
|
+
const projectDir = payload.cwd || projectDirectory();
|
|
306
|
+
const result = runProjectTransitionHook(projectDir, process.argv[2], { payload });
|
|
307
|
+
if (result.state === 'pending') {
|
|
308
|
+
const message = transitionPendingNotice(projectDir, payload, 'Project memory transition remains pending; exact readback was not verified.');
|
|
309
|
+
if (message) process.stdout.write(JSON.stringify({ systemMessage: message }));
|
|
310
|
+
}
|
|
304
311
|
} catch { process.stdout.write(JSON.stringify({ systemMessage: 'Project memory transition capture degraded; exact readback was not verified.' })); }
|
|
305
312
|
})();
|
|
306
313
|
} else if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-hook.mjs')) {
|
|
@@ -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
|
+
}
|
|
@@ -110,10 +110,12 @@ export function claudeTurn(lines) {
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
function readTail(file, bytes = TRANSCRIPT_TAIL_BYTES) {
|
|
113
|
-
const size = fs.statSync(file).size;
|
|
114
|
-
const offset = Math.max(0, size - bytes);
|
|
115
113
|
const handle = fs.openSync(file, 'r');
|
|
116
114
|
try {
|
|
115
|
+
const stat = fs.fstatSync(handle);
|
|
116
|
+
if (!stat.isFile()) throw new Error('Transcript must be a regular file');
|
|
117
|
+
const size = stat.size;
|
|
118
|
+
const offset = Math.max(0, size - bytes);
|
|
117
119
|
const buf = Buffer.alloc(size - offset);
|
|
118
120
|
fs.readSync(handle, buf, 0, buf.length, offset);
|
|
119
121
|
const lines = buf.toString('utf8').split(/\r?\n/);
|
|
@@ -128,7 +130,9 @@ export function readSettledTranscript(file, { stableMs = 400, maxMs = 2000, slee
|
|
|
128
130
|
const deadline = Date.now() + Math.max(0, maxMs);
|
|
129
131
|
let size = -1;
|
|
130
132
|
for (;;) {
|
|
131
|
-
const
|
|
133
|
+
const stat = fs.statSync(file);
|
|
134
|
+
if (!stat.isFile()) throw new Error('Transcript must be a regular file');
|
|
135
|
+
const now = stat.size;
|
|
132
136
|
if (now === size || Date.now() >= deadline) break;
|
|
133
137
|
size = now;
|
|
134
138
|
pause(Math.min(stableMs, Math.max(0, deadline - Date.now())));
|
|
@@ -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
|
+
}
|