ruvnet-brain 4.0.7 → 4.0.12
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 +115 -44
- package/console/app.js +32 -11
- package/console/style.css +5 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +17 -17
- package/plugin/scripts/codex-hook-wrapper.mjs +72 -1
- package/plugin/scripts/continuation-gate.mjs +41 -1
- package/plugin/scripts/learn-flush.mjs +37 -2
- package/plugin/scripts/project-identity.mjs +89 -0
- package/plugin/scripts/ruflo-bin.mjs +81 -0
- package/plugin/scripts/session-snapshot-hook.mjs +4 -1
- package/plugin/scripts/session-start-core.mjs +19 -2
- package/plugin/skills/release-proof/scripts/release-proof.mjs +12 -5
- package/scripts/candidate-host-evidence.mjs +49 -0
- package/scripts/ci/learning-replay-codex-adapter.mjs +13 -1
- package/scripts/console-runtime-identity.mjs +74 -0
- package/scripts/distill-project.mjs +9 -1
- package/scripts/doc-currency.mjs +15 -2
- package/scripts/health-repair.mjs +88 -11
- package/scripts/host-install-matrix.mjs +155 -0
- package/scripts/learning-replay-contract.mjs +14 -4
- package/scripts/learning-replay-execution.mjs +4 -3
- package/scripts/learning-replay-fixture.mjs +23 -2
- package/scripts/memory-doctor.mjs +26 -23
- package/scripts/model-router-catalog.mjs +34 -0
- package/scripts/onboarding-console.mjs +34 -12
- package/scripts/publication-receipt.mjs +135 -34
- package/scripts/release-evidence-aggregate.mjs +55 -0
- package/scripts/release-payload.mjs +100 -0
- package/scripts/release-transaction-provider.mjs +260 -55
- package/scripts/release-transaction.mjs +295 -105
- package/scripts/release.mjs +41 -12
- package/scripts/rvf-generation.mjs +17 -0
- package/scripts/selfcheck.mjs +20 -5
- package/scripts/staged-host-verifier.mjs +27 -57
- package/scripts/sync-version.mjs +10 -10
|
@@ -32,6 +32,7 @@ import { buildStackRecommendations, buildWiringRecommendations, summarizeWiring,
|
|
|
32
32
|
import { planFor } from './remedy-registry.mjs';
|
|
33
33
|
import { auditAll as capabilityAuditAll } from './capability-registry.mjs';
|
|
34
34
|
import { getVersion } from './version.mjs';
|
|
35
|
+
import { consoleRuntimeDigest } from './console-runtime-identity.mjs';
|
|
35
36
|
// L5 (ADR-028): the audit is the one place that observes live capability state, so it is where an
|
|
36
37
|
// OFFERED-then-now-`on` transition becomes an APPLIED — the numerator of the precision metric that
|
|
37
38
|
// tells the owner whether advocacy is landing or nagging. Both are pure reads/appends and never throw.
|
|
@@ -67,6 +68,9 @@ import {
|
|
|
67
68
|
saveOpenRouterCredential,
|
|
68
69
|
} from '../plugin/scripts/runtime-preferences.mjs';
|
|
69
70
|
import { applyNightlyChoice, nightlyStatus } from './nightly-controller.mjs';
|
|
71
|
+
// One canonical answer to "which directory is this, and have I counted it already?" — shared with
|
|
72
|
+
// the PreCompact snapshot producer (#85) and with memory-doctor's root scan (#107).
|
|
73
|
+
import { canonicalPath, pathIdentity, projectDirectory } from '../plugin/scripts/project-identity.mjs';
|
|
70
74
|
|
|
71
75
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
72
76
|
const REPO = path.dirname(__dirname);
|
|
@@ -103,14 +107,19 @@ const RUNTIME_PRODUCT = 'ruvnet-brain-console';
|
|
|
103
107
|
const RUNTIME_SCHEMA = 1;
|
|
104
108
|
const RUNTIME_API_CONTRACT = 1;
|
|
105
109
|
const RUNTIME_SCRIPT = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
106
|
-
|
|
110
|
+
// The generation this process IS, derived from every byte it executes and serves — not from this one
|
|
111
|
+
// file. #79: a Console whose entrypoint was unchanged but whose imported modules and frontend had been
|
|
112
|
+
// replaced reported the same identity as the candidate that replaced it, so the launcher reused a
|
|
113
|
+
// pre-update router behind a post-update page. Same function, same surface, same list as the installer
|
|
114
|
+
// stages (scripts/console-runtime-identity.mjs), so the two halves of this fact cannot drift.
|
|
115
|
+
const RUNTIME_SOURCE_SHA256 = consoleRuntimeDigest(REPO);
|
|
107
116
|
|
|
108
117
|
function consoleCandidateRoots() {
|
|
109
118
|
return candidateRoots({ home: CONSOLE_ROOT, configPath: CONFIG_PATH });
|
|
110
119
|
}
|
|
111
120
|
|
|
112
121
|
function canonicalScope(cwd = process.cwd()) {
|
|
113
|
-
|
|
122
|
+
return canonicalPath(cwd) ?? path.resolve(cwd);
|
|
114
123
|
}
|
|
115
124
|
|
|
116
125
|
function runtimeReceiptPath(cwd = process.cwd()) {
|
|
@@ -381,7 +390,7 @@ function wiringSurvey() {
|
|
|
381
390
|
const projects = [];
|
|
382
391
|
for (const root of consoleCandidateRoots()) {
|
|
383
392
|
for (const proj of findProjects(root)) {
|
|
384
|
-
const resolved = path.resolve(proj);
|
|
393
|
+
const resolved = pathIdentity(proj) ?? path.resolve(proj);
|
|
385
394
|
if (seenProjects.has(resolved)) continue;
|
|
386
395
|
seenProjects.add(resolved);
|
|
387
396
|
projects.push({ proj, root });
|
|
@@ -494,8 +503,12 @@ function scanFleet() {
|
|
|
494
503
|
return fleet;
|
|
495
504
|
}
|
|
496
505
|
function gatherMemory(cwd, { fleet = true } = {}) {
|
|
497
|
-
// health = for the project the console was launched from (fall back to this repo)
|
|
498
|
-
|
|
506
|
+
// health = for the project the console was launched from (fall back to this repo). The scope is
|
|
507
|
+
// resolved through projectDirectory() — the same call the PreCompact producer makes — so a console
|
|
508
|
+
// launched from a subdirectory probes the project root the hook actually wrote to, instead of
|
|
509
|
+
// warning that a snapshot it can see on disk does not exist (#85).
|
|
510
|
+
const scope = projectDirectory({ cwd });
|
|
511
|
+
const project = fs.existsSync(path.join(scope, '.swarm/memory.db')) ? scope : REPO;
|
|
499
512
|
const projName = project.replace(CONSOLE_ROOT + '/Code/', '').replace(CONSOLE_ROOT + '/', '~/');
|
|
500
513
|
const health = scoreMemoryHealth({ project: projName, probes: probeMemory(project) });
|
|
501
514
|
return { fleet: fleet ? scanFleet() : null, health };
|
|
@@ -1588,8 +1601,11 @@ function refreshFleetCache() {
|
|
|
1588
1601
|
// machine whose projects live under ~/source instead of ~/Code.
|
|
1589
1602
|
for (const root of consoleCandidateRoots()) {
|
|
1590
1603
|
for (const s of findMemoryStores(root)) {
|
|
1591
|
-
|
|
1592
|
-
|
|
1604
|
+
// pathIdentity, not path.resolve: resolve() normalises `.`/`..` and nothing else, so a project
|
|
1605
|
+
// reached through a symlink OR through the other capitalisation of a case-insensitive volume
|
|
1606
|
+
// was two distinct strings for one directory, and its memories were summed twice (#107).
|
|
1607
|
+
const resolved = pathIdentity(s.project) ?? path.resolve(s.project);
|
|
1608
|
+
if (seen.has(resolved)) continue; // a project visible under two roots counts once
|
|
1593
1609
|
seen.add(resolved);
|
|
1594
1610
|
const n = Number(robustRead(s.db, "SELECT COUNT(*) FROM memory_entries WHERE status='active'").value || 0);
|
|
1595
1611
|
if (n > 0) {
|
|
@@ -1726,7 +1742,12 @@ function gatherRouterEngine() {
|
|
|
1726
1742
|
const subscriptions = detectSubscriptions();
|
|
1727
1743
|
let house;
|
|
1728
1744
|
let providerKeys = providerAvailability(null, subscriptions);
|
|
1729
|
-
|
|
1745
|
+
// `keysVerified` is the machine-readable half of `status`, and it is the field every consumer must
|
|
1746
|
+
// consult BEFORE presenting `keys` as a fact about the user's machine (issue #86). `status` alone
|
|
1747
|
+
// was published and then ignored: the Console rendered a confident "✗ no API key found" per
|
|
1748
|
+
// provider straight off `keys`, so a missing catalog asset — an internal packaging failure — was
|
|
1749
|
+
// shown to the user as a verified finding about their credentials. Unknown outranks off.
|
|
1750
|
+
let providerCatalog = { status: 'degraded', keysVerified: false, detail: 'provider catalog was not loaded; native boolean detections are shown' };
|
|
1730
1751
|
try {
|
|
1731
1752
|
const hcat = loadCatalog();
|
|
1732
1753
|
house = detectProvider(hcat, { provider: cfg.provider });
|
|
@@ -1736,10 +1757,10 @@ function gatherRouterEngine() {
|
|
|
1736
1757
|
// run-context markers (which are not credentials), exactly as detectProvider() itself filters them —
|
|
1737
1758
|
// so the UI's "key found / not found" is now true instead of decorative.
|
|
1738
1759
|
providerKeys = providerAvailability(hcat, subscriptions);
|
|
1739
|
-
providerCatalog = { status: 'ok', detail: 'verified provider catalog loaded' };
|
|
1760
|
+
providerCatalog = { status: 'ok', keysVerified: true, detail: 'verified provider catalog loaded' };
|
|
1740
1761
|
} catch (error) {
|
|
1741
1762
|
house = { provider: cfg.provider && cfg.provider !== 'auto' ? cfg.provider : 'anthropic', source: 'default' };
|
|
1742
|
-
providerCatalog = { status: 'degraded', detail: `provider catalog unavailable: ${String(error?.message || error)}` };
|
|
1763
|
+
providerCatalog = { status: 'degraded', keysVerified: false, detail: `provider catalog unavailable: ${String(error?.message || error)}` };
|
|
1743
1764
|
}
|
|
1744
1765
|
return {
|
|
1745
1766
|
engine: {
|
|
@@ -2211,7 +2232,7 @@ function currentValidIds(onlyId = null) {
|
|
|
2211
2232
|
// offering nothing to do about it — detection without a remedy, which ADR-027 prohibits.
|
|
2212
2233
|
if (validateAll || healthOnly) {
|
|
2213
2234
|
try {
|
|
2214
|
-
const project =
|
|
2235
|
+
const project = projectDirectory();
|
|
2215
2236
|
const health = scoreMemoryHealth({ project: path.basename(project), probes: probeMemory(project) });
|
|
2216
2237
|
for (const r of buildHealthRecommendations({ memory: health, learning: observeLearning() })) ids.add(r.id);
|
|
2217
2238
|
} catch { /* an advisory surface must never break the apply path */ }
|
|
@@ -2923,7 +2944,8 @@ if (process.argv[1] && path.resolve(process.argv[1]).endsWith('onboarding-consol
|
|
|
2923
2944
|
const fleet = scanFleet();
|
|
2924
2945
|
let recommendations = [];
|
|
2925
2946
|
try {
|
|
2926
|
-
const
|
|
2947
|
+
const project = projectDirectory();
|
|
2948
|
+
const health = scoreMemoryHealth({ project: path.basename(project), probes: probeMemory(project) });
|
|
2927
2949
|
recommendations = buildHealthRecommendations({ memory: health, learning: { ...observeLearning(), fleet } });
|
|
2928
2950
|
} catch { /* advisory only */ }
|
|
2929
2951
|
writeCache(MEMORY_CACHE, new Date().toISOString(), { fleet, recommendations }, process.cwd());
|
|
@@ -8,8 +8,16 @@ import path from 'node:path';
|
|
|
8
8
|
import { pipeline } from 'node:stream/promises';
|
|
9
9
|
import { Readable } from 'node:stream';
|
|
10
10
|
import { spawn, spawnSync } from 'node:child_process';
|
|
11
|
+
// ONE doctor rule and ONE mode vocabulary, shared with the staged-side check in
|
|
12
|
+
// scripts/staged-host-verifier.mjs. This file kept its own copies, spelled claudeOnly/
|
|
13
|
+
// codexOnly/dual against the other's claude/codex/dual, so `hosts.claudeOnly` and
|
|
14
|
+
// `fixtures.claude` described the same fixture and nothing could tell. The richer
|
|
15
|
+
// post-publication proofs below (payload assertions, MCP wiring, SOURCE.json, rpcSearch)
|
|
16
|
+
// stay here — they are this side's job, not duplication.
|
|
17
|
+
import { HOST_MODES, RECEIPT_MODE_NAMES, MODE_FROM_RECEIPT_NAME, classifyDoctor } from './host-install-matrix.mjs';
|
|
11
18
|
import { pathToFileURL } from 'node:url';
|
|
12
19
|
import { evaluateCandidateReceipt, evaluatePublicationReceipt } from './release-proof.mjs';
|
|
20
|
+
import { verifyPayload } from './release-payload.mjs';
|
|
13
21
|
|
|
14
22
|
const REPO = 'stuinfla/ruvnet-brain';
|
|
15
23
|
const PACKAGE = 'ruvnet-brain';
|
|
@@ -28,6 +36,22 @@ function command(name, args, options = {}) {
|
|
|
28
36
|
return String(result.stdout || '').trim();
|
|
29
37
|
}
|
|
30
38
|
|
|
39
|
+
function locate(name) {
|
|
40
|
+
try { return command('which', [name]); } catch { return null; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isolatedPath(mode, temp) {
|
|
44
|
+
const bin = path.join(temp, `bin-${mode}`);
|
|
45
|
+
fs.mkdirSync(bin);
|
|
46
|
+
const hosts = mode === 'claudeOnly' ? ['claude'] : mode === 'codexOnly' ? ['codex'] : ['claude', 'codex'];
|
|
47
|
+
for (const name of ['node', 'npm', ...hosts]) {
|
|
48
|
+
const target = locate(name);
|
|
49
|
+
if (!target) throw new Error(`${name} CLI unavailable for ${mode} public host fixture`);
|
|
50
|
+
fs.symlinkSync(target, path.join(bin, name));
|
|
51
|
+
}
|
|
52
|
+
return `${bin}:/usr/bin:/bin`;
|
|
53
|
+
}
|
|
54
|
+
|
|
31
55
|
async function download(url, destination, headers = {}) {
|
|
32
56
|
const response = await fetch(url, {
|
|
33
57
|
redirect: 'follow',
|
|
@@ -66,6 +90,23 @@ function findManifest(root, relative, version) {
|
|
|
66
90
|
return match;
|
|
67
91
|
}
|
|
68
92
|
|
|
93
|
+
function findMcpServer(home) {
|
|
94
|
+
const matches = [];
|
|
95
|
+
const visit = (directory, depth = 0) => {
|
|
96
|
+
if (depth > 12 || !fs.existsSync(directory)) return;
|
|
97
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
98
|
+
const child = path.join(directory, entry.name);
|
|
99
|
+
if (entry.isSymbolicLink()) continue;
|
|
100
|
+
if (entry.isDirectory()) visit(child, depth + 1);
|
|
101
|
+
else if (entry.isFile() && entry.name === 'server.mjs' && child.includes(`${path.sep}mcp${path.sep}`)) matches.push(child);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
visit(home);
|
|
105
|
+
const server = matches.find((file) => file.includes('ruvnet-brain'));
|
|
106
|
+
if (!server) throw new Error('installed host registration has no ruvnet-brain MCP server');
|
|
107
|
+
return server;
|
|
108
|
+
}
|
|
109
|
+
|
|
69
110
|
export function assertInstalledPayload(sourceRoot, installedRoot) {
|
|
70
111
|
let checked = 0;
|
|
71
112
|
const visit = (source, relative = '') => {
|
|
@@ -143,6 +184,7 @@ function rpcSearch(server, env, query, timeoutMs = DEADLINE_MS) {
|
|
|
143
184
|
|
|
144
185
|
export function livePublicationAdapter({ root = process.cwd() } = {}) {
|
|
145
186
|
let installContext = null;
|
|
187
|
+
let installTemp = null;
|
|
146
188
|
return {
|
|
147
189
|
async downloadNpm({ version, destination }) {
|
|
148
190
|
const metadata = JSON.parse(command('npm', ['view', `${PACKAGE}@${version}`, '--json']));
|
|
@@ -169,42 +211,68 @@ export function livePublicationAdapter({ root = process.cwd() } = {}) {
|
|
|
169
211
|
|
|
170
212
|
async installHosts({ artifactPath, artifactSha256, version }) {
|
|
171
213
|
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-public-install-'));
|
|
214
|
+
installTemp = temp;
|
|
172
215
|
const packageRoot = path.join(temp, 'package');
|
|
173
216
|
command('tar', ['-xzf', artifactPath, '-C', temp]);
|
|
174
|
-
const home = path.join(temp, 'home');
|
|
175
|
-
const codexHome = path.join(home, '.codex');
|
|
176
|
-
const brainHome = path.join(home, '.cache', 'ruvnet-brain');
|
|
177
|
-
const kb = path.join(brainHome, 'kb');
|
|
178
|
-
fs.mkdirSync(codexHome, { recursive: true });
|
|
179
|
-
const env = {
|
|
180
|
-
...process.env,
|
|
181
|
-
HOME: home,
|
|
182
|
-
CODEX_HOME: codexHome,
|
|
183
|
-
RUVNET_BRAIN_HOME: brainHome,
|
|
184
|
-
RUVNET_BRAIN_KB: kb,
|
|
185
|
-
RUVNET_STRICT_INSTALL: '1',
|
|
186
|
-
RUVNET_BRAIN_PROFILE: 'complete',
|
|
187
|
-
CI: 'true',
|
|
188
|
-
};
|
|
189
|
-
command(process.execPath, [
|
|
190
|
-
path.join(packageRoot, 'bin', 'install.mjs'), '--yes', '--force', '--version', `v${version}`,
|
|
191
|
-
'--no-nightly-prompt', '--no-telemetry', '--no-stack', '--no-enhance', '--no-statusline',
|
|
192
|
-
], { env, cwd: packageRoot, timeout: 1_200_000, maxBuffer: 32 * 1024 * 1024 });
|
|
193
|
-
|
|
194
|
-
const claudeManifest = findManifest(path.join(home, '.claude'), path.join('.claude-plugin', 'plugin.json'), version);
|
|
195
|
-
const codexManifest = findManifest(path.join(home, '.codex'), path.join('.codex-plugin', 'plugin.json'), version);
|
|
196
217
|
const sealedPlugin = path.join(packageRoot, 'plugin');
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
218
|
+
const results = {};
|
|
219
|
+
let bundle = null;
|
|
220
|
+
// Derived from HOST_MODES, so a fourth host shape is added in ONE place and this loop
|
|
221
|
+
// cannot fall behind the staged-side check the way it did.
|
|
222
|
+
for (const mode of HOST_MODES.map((m) => RECEIPT_MODE_NAMES[m])) {
|
|
223
|
+
const home = path.join(temp, `home-${mode}`);
|
|
224
|
+
const codexHome = path.join(home, '.codex');
|
|
225
|
+
const brainHome = path.join(home, '.cache', 'ruvnet-brain');
|
|
226
|
+
const kb = path.join(brainHome, 'kb');
|
|
227
|
+
fs.mkdirSync(path.join(home, '.claude'), { recursive: true });
|
|
228
|
+
if (mode !== 'claudeOnly') fs.mkdirSync(codexHome, { recursive: true });
|
|
229
|
+
const env = {
|
|
230
|
+
...process.env,
|
|
231
|
+
HOME: home,
|
|
232
|
+
CODEX_HOME: codexHome,
|
|
233
|
+
RUVNET_BRAIN_HOME: brainHome,
|
|
234
|
+
RUVNET_BRAIN_KB: kb,
|
|
235
|
+
RUVNET_STRICT_INSTALL: '1',
|
|
236
|
+
RUVNET_BRAIN_PROFILE: 'complete',
|
|
237
|
+
CI: 'true',
|
|
238
|
+
PATH: isolatedPath(mode, temp),
|
|
239
|
+
};
|
|
240
|
+
const installer = path.join(packageRoot, 'bin', 'install.mjs');
|
|
241
|
+
command(process.execPath, [
|
|
242
|
+
installer, '--yes', '--force', '--version', `v${version}`,
|
|
243
|
+
'--no-nightly-prompt', '--no-telemetry', '--no-stack', '--no-enhance', '--no-statusline',
|
|
244
|
+
], { env, cwd: packageRoot, timeout: 1_200_000, maxBuffer: 32 * 1024 * 1024 });
|
|
245
|
+
command(process.execPath, [installer, '--doctor', '--hooks'], {
|
|
246
|
+
env, cwd: packageRoot, timeout: 300_000, maxBuffer: 32 * 1024 * 1024,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const verified = {};
|
|
250
|
+
if (mode !== 'codexOnly') {
|
|
251
|
+
const manifest = findManifest(path.join(home, '.claude'), path.join('.claude-plugin', 'plugin.json'), version);
|
|
252
|
+
verified.claudeFiles = assertInstalledPayload(sealedPlugin, path.dirname(path.dirname(manifest)));
|
|
253
|
+
}
|
|
254
|
+
if (mode !== 'claudeOnly') {
|
|
255
|
+
const manifest = findManifest(codexHome, path.join('.codex-plugin', 'plugin.json'), version);
|
|
256
|
+
verified.codexFiles = assertInstalledPayload(sealedPlugin, path.dirname(path.dirname(manifest)));
|
|
257
|
+
const config = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
|
258
|
+
if (!/\[mcp_servers\.ruvnet-brain\]/.test(config)) throw new Error(`${mode} virgin Codex home is not wired to ruvnet-brain`);
|
|
259
|
+
}
|
|
260
|
+
const source = readJson(path.join(kb, 'SOURCE.json'));
|
|
261
|
+
if (source.brainVersion !== version || source.releaseTag !== `v${version}`) {
|
|
262
|
+
throw new Error(`${mode} installed public Brain bundle version mismatch`);
|
|
263
|
+
}
|
|
264
|
+
bundle ||= { brainVersion: source.brainVersion, releaseTag: source.releaseTag };
|
|
265
|
+
const searched = await rpcSearch(findMcpServer(home), env, 'How does RuvNet Brain prove a public release artifact?', DEADLINE_MS);
|
|
266
|
+
results[mode] = {
|
|
267
|
+
status: 'PASS', doctorExit: 0, version, artifactSha256,
|
|
268
|
+
functionalSearch: true, searchMs: searched.broadMs, hostsOnPath: mode,
|
|
269
|
+
...verified,
|
|
270
|
+
};
|
|
271
|
+
if (mode === 'dual') installContext = { temp, home, codexHome, brainHome, kb, env, packageRoot };
|
|
272
|
+
}
|
|
204
273
|
return {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
bundle: { brainVersion: source.brainVersion, releaseTag: source.releaseTag },
|
|
274
|
+
...results,
|
|
275
|
+
bundle,
|
|
208
276
|
};
|
|
209
277
|
},
|
|
210
278
|
|
|
@@ -231,6 +299,12 @@ export function livePublicationAdapter({ root = process.cwd() } = {}) {
|
|
|
231
299
|
if (result.status !== 0 || parsed.verdict !== 'PASS') throw new Error(`published-surface-probe is ${parsed.verdict || `exit ${result.status}`}`);
|
|
232
300
|
return { name: 'published-surface-probe', status: 'completed', conclusion: 'success', sha };
|
|
233
301
|
},
|
|
302
|
+
|
|
303
|
+
async dispose() {
|
|
304
|
+
if (installTemp) fs.rmSync(installTemp, { recursive: true, force: true });
|
|
305
|
+
installTemp = null;
|
|
306
|
+
installContext = null;
|
|
307
|
+
},
|
|
234
308
|
};
|
|
235
309
|
}
|
|
236
310
|
|
|
@@ -246,15 +320,35 @@ export async function generatePublicationReceipt({
|
|
|
246
320
|
const candidateResult = evaluateCandidateReceipt(candidate);
|
|
247
321
|
if (candidateResult.verdict !== 'PASS') throw new Error(`candidate seal failed: ${candidateResult.failures.map(({ code }) => code).join(',')}`);
|
|
248
322
|
const digest = receiptDigest(candidate);
|
|
323
|
+
const evidenceRoot = path.dirname(path.resolve(candidatePath));
|
|
324
|
+
const payloadManifestPath = path.join(evidenceRoot, 'payload-manifest.json');
|
|
325
|
+
const payloadSignaturePath = path.join(evidenceRoot, 'payload-manifest.sig');
|
|
326
|
+
const payloadManifest = readJson(payloadManifestPath);
|
|
327
|
+
const payloadProof = verifyPayload({
|
|
328
|
+
manifest: payloadManifest,
|
|
329
|
+
signature: fs.readFileSync(payloadSignaturePath, 'utf8'),
|
|
330
|
+
publicKey: crypto.createPublicKey(fs.readFileSync(path.join(root, 'keys', 'ruvnet-brain-signing.pub.pem'), 'utf8')),
|
|
331
|
+
root: evidenceRoot,
|
|
332
|
+
});
|
|
333
|
+
if (payloadManifest.version !== candidate.version || payloadManifest.tag !== candidate.tag
|
|
334
|
+
|| payloadManifest.candidateSha !== candidate.sha) throw new Error('signed payload identity differs from candidate seal');
|
|
335
|
+
const npmMember = payloadManifest.members.find(({ role }) => role === 'npm');
|
|
336
|
+
const bundleMember = payloadManifest.members.find(({ role }) => role === 'bundle');
|
|
337
|
+
if (!npmMember || !bundleMember || npmMember.sha256 !== digest) throw new Error('signed payload member set differs from candidate artifact');
|
|
249
338
|
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-public-evidence-'));
|
|
250
339
|
try {
|
|
251
340
|
const assetName = path.basename(candidate.artifact.path);
|
|
252
341
|
const npm = await adapter.downloadNpm({ version: candidate.version, destination: path.join(temp, `npm-${assetName}`) });
|
|
253
342
|
const github = await adapter.downloadGithub({ tag: candidate.tag, assetName, destination: path.join(temp, `github-${assetName}`) });
|
|
343
|
+
const githubBundle = await adapter.downloadGithub({
|
|
344
|
+
tag: candidate.tag, assetName: bundleMember.name, destination: path.join(temp, `github-${bundleMember.name}`),
|
|
345
|
+
});
|
|
254
346
|
const npmDigest = sha256(npm.path);
|
|
255
347
|
const githubDigest = sha256(github.path);
|
|
256
348
|
if (npmDigest !== digest) throw new Error(`npm public byte mismatch: ${npmDigest} != ${digest}`);
|
|
257
349
|
if (githubDigest !== digest) throw new Error(`GitHub public byte mismatch: ${githubDigest} != ${digest}`);
|
|
350
|
+
const githubBundleDigest = sha256(githubBundle.path);
|
|
351
|
+
if (githubBundleDigest !== bundleMember.sha256) throw new Error(`GitHub public bundle byte mismatch: ${githubBundleDigest} != ${bundleMember.sha256}`);
|
|
258
352
|
if (npm.version !== candidate.version || (npm.sha && npm.sha !== candidate.sha)) throw new Error('npm public identity mismatch');
|
|
259
353
|
if (github.tag !== candidate.tag || github.sha !== candidate.sha) throw new Error('GitHub public identity mismatch');
|
|
260
354
|
|
|
@@ -262,17 +356,23 @@ export async function generatePublicationReceipt({
|
|
|
262
356
|
const brain = await adapter.probeBrain({ sha: candidate.sha, artifactSha256: digest, version: candidate.version });
|
|
263
357
|
const surface = await adapter.probePublishedSurface({ sha: candidate.sha, artifactSha256: digest, version: candidate.version });
|
|
264
358
|
const publication = {
|
|
265
|
-
schemaVersion:
|
|
359
|
+
schemaVersion: 2,
|
|
266
360
|
phase: 'publication',
|
|
267
361
|
sha: candidate.sha,
|
|
268
362
|
artifactSha256: digest,
|
|
269
363
|
version: candidate.version,
|
|
364
|
+
payloadId: payloadProof.payloadId,
|
|
365
|
+
bundleArtifactSha256: githubBundleDigest,
|
|
270
366
|
// npm does not guarantee gitHead metadata. Exact public-byte equality with the candidate's
|
|
271
367
|
// SHA-bound sealed artifact is the identity proof; a present but conflicting gitHead is red.
|
|
272
368
|
npm: { version: npm.version, sha: candidate.sha, artifactSha256: npmDigest },
|
|
273
369
|
githubRelease: { tag: github.tag, sha: github.sha, artifactSha256: githubDigest },
|
|
274
370
|
bundle: installed.bundle,
|
|
275
|
-
installed: {
|
|
371
|
+
installed: {
|
|
372
|
+
claudeOnly: installed.claudeOnly,
|
|
373
|
+
codexOnly: installed.codexOnly,
|
|
374
|
+
dual: installed.dual,
|
|
375
|
+
},
|
|
276
376
|
brain,
|
|
277
377
|
postPublicationChecks: [surface],
|
|
278
378
|
};
|
|
@@ -282,6 +382,7 @@ export async function generatePublicationReceipt({
|
|
|
282
382
|
fs.writeFileSync(outPath, `${JSON.stringify(publication, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
283
383
|
return result;
|
|
284
384
|
} finally {
|
|
385
|
+
await adapter.dispose?.();
|
|
285
386
|
fs.rmSync(temp, { recursive: true, force: true });
|
|
286
387
|
}
|
|
287
388
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { canonicalJson } from './release-transaction.mjs';
|
|
7
|
+
|
|
8
|
+
export const REQUIRED_RELEASE_LEAVES = Object.freeze([
|
|
9
|
+
'source-quality',
|
|
10
|
+
'ux-qe',
|
|
11
|
+
'release-qe',
|
|
12
|
+
'integration-linux',
|
|
13
|
+
'stranger-linux',
|
|
14
|
+
'stranger-macos',
|
|
15
|
+
'stranger-windows',
|
|
16
|
+
'claude-only',
|
|
17
|
+
'codex-only',
|
|
18
|
+
'dual-host',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
export function aggregateEvidence({ sha, payloadId, leaves }) {
|
|
22
|
+
if (!/^[a-f0-9]{40}$/.test(sha || '') || !/^[a-f0-9]{64}$/.test(payloadId || '')) {
|
|
23
|
+
throw new Error('aggregate identity is malformed');
|
|
24
|
+
}
|
|
25
|
+
const byName = new Map();
|
|
26
|
+
for (const leaf of leaves || []) {
|
|
27
|
+
if (!leaf?.name || byName.has(leaf.name)) throw new Error(`duplicate or unnamed evidence leaf: ${leaf?.name || '(missing)'}`);
|
|
28
|
+
if (leaf.sha !== sha || leaf.payloadId !== payloadId) throw new Error(`evidence identity mismatch: ${leaf.name}`);
|
|
29
|
+
if (leaf.status !== 'completed' || leaf.conclusion !== 'success' || leaf.verdict !== 'PASS') {
|
|
30
|
+
throw new Error(`evidence leaf is not fail-closed PASS: ${leaf.name}`);
|
|
31
|
+
}
|
|
32
|
+
byName.set(leaf.name, leaf);
|
|
33
|
+
}
|
|
34
|
+
const missing = REQUIRED_RELEASE_LEAVES.filter((name) => !byName.has(name));
|
|
35
|
+
if (missing.length) throw new Error(`required evidence leaves missing: ${missing.join(', ')}`);
|
|
36
|
+
const normalized = [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
37
|
+
const envelope = { schemaVersion: 1, sha, payloadId, verdict: 'PASS', leaves: normalized };
|
|
38
|
+
return { ...envelope, evidenceDigest: crypto.createHash('sha256').update(canonicalJson(envelope)).digest('hex') };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const isCli = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
42
|
+
if (isCli) {
|
|
43
|
+
const arg = (name) => {
|
|
44
|
+
const index = process.argv.indexOf(name);
|
|
45
|
+
return index >= 0 ? process.argv[index + 1] : null;
|
|
46
|
+
};
|
|
47
|
+
const out = path.resolve(arg('--out') || 'release-evidence/aggregate-envelope.json');
|
|
48
|
+
const envelope = aggregateEvidence({
|
|
49
|
+
sha: arg('--sha'),
|
|
50
|
+
payloadId: arg('--payload-id'),
|
|
51
|
+
leaves: JSON.parse(fs.readFileSync(path.resolve(arg('--leaves')), 'utf8')),
|
|
52
|
+
});
|
|
53
|
+
fs.writeFileSync(out, `${canonicalJson(envelope)}\n`, { flag: 'wx', mode: 0o600 });
|
|
54
|
+
process.stdout.write(`${JSON.stringify({ verdict: 'PASS', evidenceDigest: envelope.evidenceDigest, out })}\n`);
|
|
55
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { canonicalJson } from './release-transaction.mjs';
|
|
7
|
+
|
|
8
|
+
const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
|
|
9
|
+
const digestFile = (file) => sha256(fs.readFileSync(file));
|
|
10
|
+
|
|
11
|
+
export function createPayloadManifest({ version, tag, candidateSha, producer, members }) {
|
|
12
|
+
const normalized = members.map(({ name, file, role }) => {
|
|
13
|
+
const stat = fs.statSync(file);
|
|
14
|
+
if (!stat.isFile() || stat.size === 0) throw new Error(`payload member is absent or empty: ${file}`);
|
|
15
|
+
return { name, role, size: stat.size, sha256: digestFile(file) };
|
|
16
|
+
}).sort((a, b) => a.name.localeCompare(b.name));
|
|
17
|
+
const names = new Set(normalized.map(({ name }) => name));
|
|
18
|
+
if (names.size !== normalized.length) throw new Error('payload member names must be unique');
|
|
19
|
+
return { schemaVersion: 1, version, tag, candidateSha, producer, members: normalized };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const payloadIdFor = (manifest) => sha256(Buffer.from(canonicalJson(manifest)));
|
|
23
|
+
|
|
24
|
+
export function signPayloadManifest(manifest, privateKey) {
|
|
25
|
+
return crypto.sign(null, Buffer.from(canonicalJson(manifest)), privateKey).toString('base64');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function verifyPayloadMembers({ manifest, root }) {
|
|
29
|
+
if (!manifest || manifest.schemaVersion !== 1 || !Array.isArray(manifest.members)) {
|
|
30
|
+
throw new Error('invalid payload manifest');
|
|
31
|
+
}
|
|
32
|
+
for (const member of manifest.members) {
|
|
33
|
+
if (!member.name || path.basename(member.name) !== member.name) throw new Error(`unsafe payload member name: ${member.name}`);
|
|
34
|
+
const file = path.join(root, member.name);
|
|
35
|
+
const stat = fs.statSync(file);
|
|
36
|
+
if (stat.size !== member.size || digestFile(file) !== member.sha256) {
|
|
37
|
+
throw new Error(`payload member mismatch: ${member.name}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { payloadId: payloadIdFor(manifest), manifest, root };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function verifyPayload({ manifest, signature, publicKey, root }) {
|
|
44
|
+
if (!crypto.verify(null, Buffer.from(canonicalJson(manifest)), publicKey, Buffer.from(signature.trim(), 'base64'))) {
|
|
45
|
+
throw new Error('payload manifest signature mismatch');
|
|
46
|
+
}
|
|
47
|
+
return verifyPayloadMembers({ manifest, root });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const arg = (name) => {
|
|
51
|
+
const index = process.argv.indexOf(name);
|
|
52
|
+
return index >= 0 ? process.argv[index + 1] : null;
|
|
53
|
+
};
|
|
54
|
+
const command = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
55
|
+
? process.argv[2]
|
|
56
|
+
: null;
|
|
57
|
+
|
|
58
|
+
if (command === 'create') {
|
|
59
|
+
const out = path.resolve(arg('--out') || 'release-evidence/payload-manifest.json');
|
|
60
|
+
const memberArgs = process.argv.filter((value, index) => process.argv[index - 1] === '--member');
|
|
61
|
+
const members = memberArgs.map((entry) => {
|
|
62
|
+
const [role, file] = entry.split('=', 2);
|
|
63
|
+
if (!role || !file) throw new Error(`invalid --member ${entry}; expected role=path`);
|
|
64
|
+
return { role, file: path.resolve(file), name: path.basename(file) };
|
|
65
|
+
});
|
|
66
|
+
const manifest = createPayloadManifest({
|
|
67
|
+
version: arg('--version'),
|
|
68
|
+
tag: arg('--tag'),
|
|
69
|
+
candidateSha: arg('--sha'),
|
|
70
|
+
producer: {
|
|
71
|
+
workflow: process.env.GITHUB_WORKFLOW || 'local',
|
|
72
|
+
runId: process.env.GITHUB_RUN_ID || 'local',
|
|
73
|
+
runAttempt: process.env.GITHUB_RUN_ATTEMPT || '1',
|
|
74
|
+
},
|
|
75
|
+
members,
|
|
76
|
+
});
|
|
77
|
+
fs.writeFileSync(out, `${canonicalJson(manifest)}\n`, { flag: 'wx', mode: 0o600 });
|
|
78
|
+
process.stdout.write(`${JSON.stringify({ payloadId: payloadIdFor(manifest), manifest: out })}\n`);
|
|
79
|
+
} else if (command === 'sign') {
|
|
80
|
+
const manifestPath = path.resolve(arg('--manifest'));
|
|
81
|
+
const out = path.resolve(arg('--out'));
|
|
82
|
+
if (fs.existsSync(out)) throw new Error(`refusing to overwrite persisted payload signature: ${out}`);
|
|
83
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
84
|
+
const pem = process.env.RUVNET_SIGNING_KEY;
|
|
85
|
+
if (!pem) throw new Error('RUVNET_SIGNING_KEY is required');
|
|
86
|
+
fs.writeFileSync(out, `${signPayloadManifest(manifest, crypto.createPrivateKey(pem))}\n`, { flag: 'wx', mode: 0o600 });
|
|
87
|
+
process.stdout.write(`${JSON.stringify({ payloadId: payloadIdFor(manifest), signature: out })}\n`);
|
|
88
|
+
} else if (command === 'verify' || command === 'verify-members') {
|
|
89
|
+
const manifestPath = path.resolve(arg('--manifest'));
|
|
90
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
91
|
+
const result = command === 'verify'
|
|
92
|
+
? verifyPayload({
|
|
93
|
+
manifest,
|
|
94
|
+
signature: fs.readFileSync(path.resolve(arg('--signature')), 'utf8'),
|
|
95
|
+
publicKey: crypto.createPublicKey(fs.readFileSync(path.resolve(arg('--public-key')), 'utf8')),
|
|
96
|
+
root: path.dirname(manifestPath),
|
|
97
|
+
})
|
|
98
|
+
: verifyPayloadMembers({ manifest, root: path.dirname(manifestPath) });
|
|
99
|
+
process.stdout.write(`${JSON.stringify({ verdict: 'PASS', payloadId: result.payloadId })}\n`);
|
|
100
|
+
}
|