ruvnet-brain 4.3.8 → 4.3.9
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/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/scripts/public-verification-lane.mjs +49 -4
- package/scripts/publication-receipt.mjs +66 -23
- package/scripts/release-publication-handoff.mjs +101 -0
- package/scripts/release.mjs +10 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
# 🧠 RuvNet Brain
|
|
6
6
|
|
|
7
|
-
### 🧠 RuvNet Brain — [](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
|
|
8
8
|
|
|
9
9
|
**A portable, source-grounded brain over Reuven Cohen's (rUv's) RuvNet stack — delivered as a Claude Code plugin that makes Claude _use_ the stack instead of fighting it.**
|
|
10
10
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
|
-
"version": "4.3.
|
|
3
|
+
"version": "4.3.9",
|
|
4
4
|
"description": "One-command installer for RuvNet Brain — a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
3
|
"description": "RuvNet brain transplant for Claude Code — grounds every RuvNet decision in real source across 77 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships an enforced UserPromptSubmit retrieve-and-inject grounding hook that sharply reduces drift.",
|
|
4
|
-
"version": "4.3.
|
|
4
|
+
"version": "4.3.9",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Stuart Kerr"
|
|
7
7
|
},
|
|
@@ -24,6 +24,32 @@ const HEX40 = /^[a-f0-9]{40}$/;
|
|
|
24
24
|
const HEX64 = /^[a-f0-9]{64}$/;
|
|
25
25
|
const normalizedDigest = (value) => String(value || '').replace(/^sha256:/, '');
|
|
26
26
|
|
|
27
|
+
function failedCanaryCases(retrieval) {
|
|
28
|
+
return retrieval.cases.filter(({ cohort, status, retrievalHit, citationResolved }) =>
|
|
29
|
+
status !== 'COMPLETED' || retrievalHit !== true || (cohort === 'delta' && citationResolved !== true));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function publicVerificationFailure({ os, identity, failures, completedLeaves }) {
|
|
33
|
+
const payload = {
|
|
34
|
+
schemaVersion: 1,
|
|
35
|
+
kind: 'ruvnet-brain-public-verification-failure',
|
|
36
|
+
os,
|
|
37
|
+
sourceSha: identity.candidateSha,
|
|
38
|
+
artifactSha256: identity.packageSha256,
|
|
39
|
+
bundleSha256: identity.bundleSha256,
|
|
40
|
+
failures: failures.map(({ mode, reason, retrieval }) => ({
|
|
41
|
+
mode,
|
|
42
|
+
reason,
|
|
43
|
+
metrics: retrieval.metrics,
|
|
44
|
+
failedCaseIds: failedCanaryCases(retrieval).map(({ id }) => id),
|
|
45
|
+
failedCases: failedCanaryCases(retrieval),
|
|
46
|
+
retrievalReceiptSha256: retrieval.receiptSha256,
|
|
47
|
+
})),
|
|
48
|
+
completedLeaves: completedLeaves.map(({ mode, leafSha256 }) => ({ mode, leafSha256 })),
|
|
49
|
+
};
|
|
50
|
+
return { ...payload, failureSha256: digest(payload) };
|
|
51
|
+
}
|
|
52
|
+
|
|
27
53
|
function validateReleaseIdentity(identity) {
|
|
28
54
|
if (identity?.repository !== 'stuinfla/ruvnet-brain' || identity?.package !== 'ruvnet-brain'
|
|
29
55
|
|| !HEX40.test(String(identity.candidateSha || '')) || !HEX64.test(String(identity.payloadId || ''))
|
|
@@ -118,6 +144,7 @@ export async function createPublicVerificationLane({
|
|
|
118
144
|
releaseTransactionId: transactionIdFor(identity),
|
|
119
145
|
};
|
|
120
146
|
const leaves = [];
|
|
147
|
+
const failures = [];
|
|
121
148
|
for (const mode of PUBLIC_VERIFICATION_MODES) {
|
|
122
149
|
const installed = publication.installed?.[RECEIPT_MODE_NAMES[mode]];
|
|
123
150
|
if (installed?.status !== 'PASS' || installed.doctorExit !== 0 || installed.version !== identity.version
|
|
@@ -132,10 +159,16 @@ export async function createPublicVerificationLane({
|
|
|
132
159
|
search: ({ query, k }) => adapter.searchInstalled({ mode, query, k }),
|
|
133
160
|
citationResolver: (matched, expected) => adapter.resolveInstalledCitation({ mode, matched, expected }),
|
|
134
161
|
});
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
162
|
+
let verifiedRetrieval;
|
|
163
|
+
try {
|
|
164
|
+
verifiedRetrieval = validateRetrievalCanaryReceipt(
|
|
165
|
+
retrieval,
|
|
166
|
+
{ plan: retrievalPlan, requireAcceptance: true },
|
|
167
|
+
);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
failures.push({ mode, reason: error.message, retrieval });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
139
172
|
leaves.push(createPublicVerificationLeaf({
|
|
140
173
|
...common,
|
|
141
174
|
os,
|
|
@@ -152,6 +185,11 @@ export async function createPublicVerificationLane({
|
|
|
152
185
|
unknown: 0,
|
|
153
186
|
}));
|
|
154
187
|
}
|
|
188
|
+
if (failures.length) {
|
|
189
|
+
const error = new Error(`retrieval canary acceptance failed for ${failures.map(({ mode }) => mode).join(', ')}`);
|
|
190
|
+
error.publicVerificationFailure = publicVerificationFailure({ os, identity, failures, completedLeaves: leaves });
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
155
193
|
if (canonicalJson(leaves.map(({ mode }) => mode)) !== canonicalJson(PUBLIC_VERIFICATION_MODES)) {
|
|
156
194
|
throw new Error('public verification lane modes are incomplete');
|
|
157
195
|
}
|
|
@@ -211,6 +249,13 @@ export async function generatePublicVerificationLane({
|
|
|
211
249
|
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
212
250
|
fs.writeFileSync(output, `${JSON.stringify(receipt, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
213
251
|
return receipt;
|
|
252
|
+
} catch (error) {
|
|
253
|
+
if (error.publicVerificationFailure && !fs.existsSync(output)) {
|
|
254
|
+
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
255
|
+
fs.writeFileSync(output, `${JSON.stringify(error.publicVerificationFailure, null, 2)}\n`,
|
|
256
|
+
{ flag: 'wx', mode: 0o600 });
|
|
257
|
+
}
|
|
258
|
+
throw error;
|
|
214
259
|
} finally {
|
|
215
260
|
await adapter.dispose?.();
|
|
216
261
|
fs.rmSync(temp, { recursive: true, force: true });
|
|
@@ -29,30 +29,62 @@ const DEADLINE_MS = 30_000;
|
|
|
29
29
|
const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
30
30
|
const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
31
31
|
const receiptDigest = (candidate) => String(candidate?.artifact?.sha256 || '').replace(/^sha256:/, '');
|
|
32
|
-
|
|
32
|
+
export function commandInvocation(name, args, { platform = process.platform, env = process.env } = {}) {
|
|
33
|
+
if (platform === 'win32' && /^(?:npm|npx)$/i.test(name)) return {
|
|
34
|
+
executable: env.ComSpec || 'cmd.exe', args: ['/d', '/c', `${name}.cmd`, ...args],
|
|
35
|
+
};
|
|
36
|
+
return { executable: name, args };
|
|
37
|
+
}
|
|
33
38
|
function command(name, args, options = {}) {
|
|
34
|
-
const
|
|
39
|
+
const { platform = process.platform, ...spawnOptions } = options;
|
|
40
|
+
const invocation = commandInvocation(name, args, { platform, env: spawnOptions.env || process.env });
|
|
41
|
+
const result = spawnSync(invocation.executable, invocation.args, { encoding: 'utf8', ...spawnOptions });
|
|
35
42
|
if (result.error || result.status !== 0) {
|
|
36
43
|
const detail = String(result.stderr || result.stdout || result.error?.message || '').trim();
|
|
37
44
|
throw new Error(`${name} ${args.join(' ')} failed: ${detail || `exit ${result.status}`}`);
|
|
38
45
|
}
|
|
39
46
|
return String(result.stdout || '').trim();
|
|
40
47
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
try {
|
|
48
|
+
function locate(name, { platform = process.platform } = {}) {
|
|
49
|
+
const query = platform === 'win32' ? (name === 'node' ? 'node.exe' : `${name}.cmd`) : name;
|
|
50
|
+
try {
|
|
51
|
+
return command(platform === 'win32' ? 'where.exe' : 'which', [query], { platform })
|
|
52
|
+
.split(/\r?\n/).find(Boolean) || null;
|
|
53
|
+
} catch { return null; }
|
|
44
54
|
}
|
|
45
|
-
|
|
46
|
-
|
|
55
|
+
export function createIsolatedPath(mode, temp, { platform = process.platform, env = process.env,
|
|
56
|
+
resolve = (name) => locate(name, { platform }) } = {}) {
|
|
47
57
|
const bin = path.join(temp, `bin-${mode}`);
|
|
48
58
|
fs.mkdirSync(bin);
|
|
49
59
|
const hosts = mode === 'claudeOnly' ? ['claude'] : mode === 'codexOnly' ? ['codex'] : ['claude', 'codex'];
|
|
50
|
-
|
|
51
|
-
|
|
60
|
+
const names = platform === 'win32' ? ['npm', ...hosts] : ['node', 'npm', ...hosts];
|
|
61
|
+
for (const name of names) {
|
|
62
|
+
const target = resolve(name);
|
|
52
63
|
if (!target) throw new Error(`${name} CLI unavailable for ${mode} public host fixture`);
|
|
53
|
-
|
|
64
|
+
if (platform === 'win32') {
|
|
65
|
+
fs.writeFileSync(path.join(bin, `${name}.cmd`), `@call "${target}" %*\r\n`, { flag: 'wx' });
|
|
66
|
+
} else {
|
|
67
|
+
fs.symlinkSync(target, path.join(bin, name));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (platform !== 'win32') return `${bin}:/usr/bin:/bin`;
|
|
71
|
+
const node = resolve('node');
|
|
72
|
+
if (!node) throw new Error(`node CLI unavailable for ${mode} public host fixture`);
|
|
73
|
+
return [bin, path.dirname(node), path.join(env.SystemRoot || 'C:\\Windows', 'System32'),
|
|
74
|
+
path.join(env.ProgramFiles || 'C:\\Program Files', 'Git', 'usr', 'bin')].join(path.win32.delimiter);
|
|
75
|
+
}
|
|
76
|
+
export function stageVerifiedBundle({ bundlePath, bundleSha256, packageRoot }) {
|
|
77
|
+
const source = path.resolve(bundlePath || '');
|
|
78
|
+
let stat;
|
|
79
|
+
try { stat = fs.lstatSync(source); } catch { throw new Error('verified public bundle is missing'); }
|
|
80
|
+
if (!stat.isFile() || stat.isSymbolicLink() || sha256(source) !== bundleSha256) {
|
|
81
|
+
throw new Error('verified bundle digest mismatch before installation');
|
|
54
82
|
}
|
|
55
|
-
|
|
83
|
+
const destination = path.join(packageRoot, 'dist', 'ruvnet-brain.zip');
|
|
84
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
85
|
+
fs.copyFileSync(source, destination, fs.constants.COPYFILE_EXCL);
|
|
86
|
+
if (sha256(destination) !== bundleSha256) throw new Error('staged verified bundle digest mismatch');
|
|
87
|
+
return destination;
|
|
56
88
|
}
|
|
57
89
|
|
|
58
90
|
async function download(url, destination, headers = {}) {
|
|
@@ -134,7 +166,8 @@ export function assertInstalledPayload(sourceRoot, installedRoot) {
|
|
|
134
166
|
return checked;
|
|
135
167
|
}
|
|
136
168
|
|
|
137
|
-
function rpcSearch(server, env, query, timeoutMs = DEADLINE_MS) {
|
|
169
|
+
export function rpcSearch(server, env, query, k = 5, timeoutMs = DEADLINE_MS) {
|
|
170
|
+
if (!Number.isSafeInteger(k) || k < 1 || k > 50) throw new Error(`invalid search result count: ${k}`);
|
|
138
171
|
return new Promise((resolve, reject) => {
|
|
139
172
|
const child = spawn(process.execPath, [server], { env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
140
173
|
let stdout = '';
|
|
@@ -175,7 +208,7 @@ function rpcSearch(server, env, query, timeoutMs = DEADLINE_MS) {
|
|
|
175
208
|
const listed = await call(2, 'tools/list');
|
|
176
209
|
if (!listed.result?.tools?.some((tool) => tool.name === 'search_ruvnet')) throw new Error('installed MCP does not advertise search_ruvnet');
|
|
177
210
|
const started = performance.now();
|
|
178
|
-
const searched = await call(3, 'tools/call', { name: 'search_ruvnet', arguments: { query, k
|
|
211
|
+
const searched = await call(3, 'tools/call', { name: 'search_ruvnet', arguments: { query, k } });
|
|
179
212
|
const broadMs = Math.round(performance.now() - started);
|
|
180
213
|
const text = (searched.result?.content || []).map((item) => item.text || '').join('\n');
|
|
181
214
|
if (searched.error || searched.result?.isError || /search_ruvnet error:/i.test(text)) throw new Error(`installed Brain search failed: ${text.slice(0, 400)}`);
|
|
@@ -213,14 +246,17 @@ export function livePublicationAdapter({ root = process.cwd() } = {}) {
|
|
|
213
246
|
return { path: destination, tag: release.tag_name, sha: tagCommit(root, tag) };
|
|
214
247
|
},
|
|
215
248
|
|
|
216
|
-
async installHosts({ artifactPath, artifactSha256, version }) {
|
|
249
|
+
async installHosts({ artifactPath, artifactSha256, bundlePath, bundleSha256, version }) {
|
|
217
250
|
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-public-install-'));
|
|
218
251
|
installTemp = temp;
|
|
219
252
|
const packageRoot = path.join(temp, 'package');
|
|
220
253
|
command('tar', ['-xzf', artifactPath, '-C', temp]);
|
|
254
|
+
stageVerifiedBundle({ bundlePath, bundleSha256, packageRoot });
|
|
221
255
|
const sealedPlugin = path.join(packageRoot, 'plugin');
|
|
222
256
|
const results = {};
|
|
223
257
|
let bundle = null;
|
|
258
|
+
const sharedModelCache = path.join(temp, 'models-cache');
|
|
259
|
+
fs.mkdirSync(sharedModelCache, { recursive: true });
|
|
224
260
|
// Derived from HOST_MODES, so a fourth host shape is added in ONE place and this loop
|
|
225
261
|
// cannot fall behind the staged-side check the way it did.
|
|
226
262
|
for (const mode of HOST_MODES.map((m) => RECEIPT_MODE_NAMES[m])) {
|
|
@@ -233,26 +269,27 @@ export function livePublicationAdapter({ root = process.cwd() } = {}) {
|
|
|
233
269
|
const env = {
|
|
234
270
|
...process.env,
|
|
235
271
|
HOME: home,
|
|
272
|
+
...(process.platform === 'win32' ? { USERPROFILE: home } : {}),
|
|
236
273
|
CODEX_HOME: codexHome,
|
|
237
274
|
RUVNET_BRAIN_HOME: brainHome,
|
|
238
275
|
RUVNET_BRAIN_KB: kb,
|
|
276
|
+
KB_MODEL_CACHE: sharedModelCache,
|
|
239
277
|
// Env comes from the ONE variant table (host-install-matrix VARIANTS.published), not a
|
|
240
278
|
// second hand-written copy — this file's own header promises "ONE doctor rule and ONE mode
|
|
241
279
|
// vocabulary, shared with the staged-side check", and the copy had already drifted: it
|
|
242
280
|
// omitted the Codex hook-trust bypass the staged side carries, which failed the seal on
|
|
243
281
|
// every release.
|
|
244
282
|
...VARIANTS.published.env({ packageRoot }),
|
|
283
|
+
RUVNET_STRICT_INSTALL: '0',
|
|
245
284
|
CI: 'true',
|
|
246
|
-
PATH:
|
|
285
|
+
PATH: createIsolatedPath(mode, temp),
|
|
247
286
|
};
|
|
248
287
|
const installer = path.join(packageRoot, 'bin', 'install.mjs');
|
|
249
288
|
command(process.execPath, [
|
|
250
289
|
installer, '--yes', '--force', '--version', `v${version}`,
|
|
251
290
|
'--no-nightly-prompt', '--no-telemetry', '--no-stack', '--no-enhance', '--no-statusline',
|
|
291
|
+
'--no-selfcheck', '--no-verify',
|
|
252
292
|
], { env, cwd: packageRoot, timeout: 1_200_000, maxBuffer: 32 * 1024 * 1024 });
|
|
253
|
-
command(process.execPath, [installer, '--doctor', '--hooks'], {
|
|
254
|
-
env, cwd: packageRoot, timeout: 300_000, maxBuffer: 32 * 1024 * 1024,
|
|
255
|
-
});
|
|
256
293
|
|
|
257
294
|
const verified = {};
|
|
258
295
|
if (mode !== 'codexOnly') {
|
|
@@ -270,7 +307,11 @@ export function livePublicationAdapter({ root = process.cwd() } = {}) {
|
|
|
270
307
|
throw new Error(`${mode} installed public Brain bundle version mismatch`);
|
|
271
308
|
}
|
|
272
309
|
bundle ||= { brainVersion: source.brainVersion, releaseTag: source.releaseTag };
|
|
273
|
-
const searched = await rpcSearch(findMcpServer(home), env,
|
|
310
|
+
const searched = await rpcSearch(findMcpServer(home), env,
|
|
311
|
+
'How does RuvNet Brain prove a public release artifact?', 5, DEADLINE_MS);
|
|
312
|
+
command(process.execPath, [installer, '--doctor', '--hooks'], {
|
|
313
|
+
env, cwd: packageRoot, timeout: 300_000, maxBuffer: 32 * 1024 * 1024,
|
|
314
|
+
});
|
|
274
315
|
results[mode] = {
|
|
275
316
|
status: 'PASS', doctorExit: 0, version, artifactSha256,
|
|
276
317
|
functionalSearch: true, searchMs: searched.broadMs, hostsOnPath: mode,
|
|
@@ -291,16 +332,17 @@ export function livePublicationAdapter({ root = process.cwd() } = {}) {
|
|
|
291
332
|
if (rvfs.length === 0) throw new Error('installed public Brain has no ruvnet-brain self RVF');
|
|
292
333
|
const server = path.join(installContext.home, '.claude', 'ruvnet-brain', 'mcp', 'server.mjs');
|
|
293
334
|
if (!fs.existsSync(server)) throw new Error('installed persistent MCP server is missing');
|
|
294
|
-
const result = await rpcSearch(server, installContext.env,
|
|
335
|
+
const result = await rpcSearch(server, installContext.env,
|
|
336
|
+
'How does RuvNet Brain prove a public release artifact?', 5, DEADLINE_MS);
|
|
295
337
|
const readiness = readJson(path.join(installContext.brainHome, 'mcp-readiness.json'));
|
|
296
338
|
if (readiness.state !== 'ready') throw new Error(`installed Brain readiness is ${readiness.state || 'missing'}`);
|
|
297
339
|
return { status: 'PASS', selfStore: true, broadMs: result.broadMs, deadlineMs: DEADLINE_MS };
|
|
298
340
|
},
|
|
299
341
|
|
|
300
|
-
async searchInstalled({ mode, query }) {
|
|
342
|
+
async searchInstalled({ mode, query, k }) {
|
|
301
343
|
const context = installContexts.get(mode);
|
|
302
344
|
if (!context) throw new Error(`${mode} public host is not installed`);
|
|
303
|
-
const result = await rpcSearch(findMcpServer(context.home), context.env, query, DEADLINE_MS);
|
|
345
|
+
const result = await rpcSearch(findMcpServer(context.home), context.env, query, k, DEADLINE_MS);
|
|
304
346
|
return parseCitations(result.text).map((citation) => ({
|
|
305
347
|
repo: citation.repo.toLowerCase(),
|
|
306
348
|
path: citation.docPath,
|
|
@@ -400,7 +442,8 @@ export async function generatePublicationReceipt({
|
|
|
400
442
|
if (npm.version !== candidate.version || (npm.sha && npm.sha !== candidate.sha)) throw new Error('npm public identity mismatch');
|
|
401
443
|
if (github.tag !== candidate.tag || github.sha !== candidate.sha) throw new Error('GitHub public identity mismatch');
|
|
402
444
|
|
|
403
|
-
const installed = await adapter.installHosts({ artifactPath: npm.path, artifactSha256: digest,
|
|
445
|
+
const installed = await adapter.installHosts({ artifactPath: npm.path, artifactSha256: digest,
|
|
446
|
+
bundlePath: githubBundle.path, bundleSha256: githubBundleDigest, version: candidate.version });
|
|
404
447
|
const brain = await adapter.probeBrain({ sha: candidate.sha, artifactSha256: digest, version: candidate.version });
|
|
405
448
|
const surface = await adapter.probePublishedSurface({ sha: candidate.sha, artifactSha256: digest, version: candidate.version });
|
|
406
449
|
const publication = {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { canonicalJson, transactionIdFor, verifyReceipt } from './release-transaction.mjs';
|
|
5
|
+
|
|
6
|
+
const jsonBytes = (value) => Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
|
|
7
|
+
|
|
8
|
+
function trustedEvidenceDirectory(root) {
|
|
9
|
+
const evidenceRoot = path.join(path.resolve(root), 'release-evidence');
|
|
10
|
+
let stat;
|
|
11
|
+
try { stat = fs.lstatSync(evidenceRoot); } catch { throw new Error('release-evidence directory is missing'); }
|
|
12
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
13
|
+
throw new Error('release-evidence must be a trusted directory');
|
|
14
|
+
}
|
|
15
|
+
return evidenceRoot;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function resolvePublicationHandoffPaths({ root, identityPath, receiptPath } = {}) {
|
|
19
|
+
const evidenceRoot = trustedEvidenceDirectory(root);
|
|
20
|
+
const resolveTarget = (configured, label) => {
|
|
21
|
+
if (typeof configured !== 'string' || !configured.trim()) throw new Error(`${label} output is required`);
|
|
22
|
+
const target = path.resolve(root, configured);
|
|
23
|
+
if (path.dirname(target) !== evidenceRoot || path.extname(target) !== '.json') {
|
|
24
|
+
throw new Error(`${label} output must be a direct JSON file inside release-evidence`);
|
|
25
|
+
}
|
|
26
|
+
if (fs.existsSync(target)) throw new Error(`${label} output must be a new file`);
|
|
27
|
+
return target;
|
|
28
|
+
};
|
|
29
|
+
const identity = resolveTarget(identityPath, 'release identity');
|
|
30
|
+
const receipt = resolveTarget(receiptPath, 'channel receipt');
|
|
31
|
+
if (identity === receipt) throw new Error('release identity and channel receipt outputs must differ');
|
|
32
|
+
return { evidenceRoot, identity, receipt };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function validatePublicationHandoff({ identity, receipt, publicKey } = {}) {
|
|
36
|
+
verifyReceipt(receipt, publicKey);
|
|
37
|
+
if (receipt.schemaVersion !== 3 || receipt.state !== 'channels-converged'
|
|
38
|
+
|| receipt.observation?.verdict !== 'PUBLISHED_NOT_VERIFIED') {
|
|
39
|
+
throw new Error('channel receipt is not signed PUBLISHED_NOT_VERIFIED convergence evidence');
|
|
40
|
+
}
|
|
41
|
+
if (receipt.transactionId !== transactionIdFor(identity)
|
|
42
|
+
|| canonicalJson(receipt.identity) !== canonicalJson(identity)) {
|
|
43
|
+
throw new Error('channel receipt identity differs from the release identity');
|
|
44
|
+
}
|
|
45
|
+
return receipt;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function writePrivateTemp(directory, label, bytes) {
|
|
49
|
+
const temp = path.join(directory, `.${label}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
50
|
+
const fd = fs.openSync(temp, 'wx', 0o600);
|
|
51
|
+
let closed = false;
|
|
52
|
+
try {
|
|
53
|
+
fs.writeFileSync(fd, bytes);
|
|
54
|
+
fs.fsyncSync(fd);
|
|
55
|
+
fs.closeSync(fd);
|
|
56
|
+
closed = true;
|
|
57
|
+
return temp;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (!closed) {
|
|
60
|
+
try { fs.closeSync(fd); } catch { /* preserve the write failure */ }
|
|
61
|
+
}
|
|
62
|
+
fs.rmSync(temp, { force: true });
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function removeOwnedLink(destination, temporary) {
|
|
68
|
+
try {
|
|
69
|
+
const linked = fs.lstatSync(destination);
|
|
70
|
+
const source = fs.lstatSync(temporary);
|
|
71
|
+
if (linked.dev === source.dev && linked.ino === source.ino) fs.unlinkSync(destination);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (error.code !== 'ENOENT') throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function materializePublicationHandoff({ paths, identity, receipt, publicKey } = {}) {
|
|
78
|
+
if (!paths?.evidenceRoot || path.dirname(paths.identity || '') !== paths.evidenceRoot
|
|
79
|
+
|| path.dirname(paths.receipt || '') !== paths.evidenceRoot || paths.identity === paths.receipt) {
|
|
80
|
+
throw new Error('publication handoff paths were not validated');
|
|
81
|
+
}
|
|
82
|
+
validatePublicationHandoff({ identity, receipt, publicKey });
|
|
83
|
+
const temporary = [];
|
|
84
|
+
let identityLinked = false;
|
|
85
|
+
let receiptLinked = false;
|
|
86
|
+
try {
|
|
87
|
+
temporary.push(writePrivateTemp(paths.evidenceRoot, 'release-identity', jsonBytes(identity)));
|
|
88
|
+
temporary.push(writePrivateTemp(paths.evidenceRoot, 'channels-converged', jsonBytes(receipt)));
|
|
89
|
+
fs.linkSync(temporary[0], paths.identity);
|
|
90
|
+
identityLinked = true;
|
|
91
|
+
fs.linkSync(temporary[1], paths.receipt);
|
|
92
|
+
receiptLinked = true;
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (receiptLinked) removeOwnedLink(paths.receipt, temporary[1]);
|
|
95
|
+
if (identityLinked) removeOwnedLink(paths.identity, temporary[0]);
|
|
96
|
+
throw new Error(`publication handoff materialization failed: ${error.message}`);
|
|
97
|
+
} finally {
|
|
98
|
+
for (const temp of temporary) fs.rmSync(temp, { force: true });
|
|
99
|
+
}
|
|
100
|
+
return { identityPath: paths.identity, receiptPath: paths.receipt };
|
|
101
|
+
}
|
package/scripts/release.mjs
CHANGED
|
@@ -31,6 +31,7 @@ import fs from 'node:fs';
|
|
|
31
31
|
import crypto from 'node:crypto';
|
|
32
32
|
import { validateProtectedPublishEnvironment, validateProtectedPublishInvocation } from './protected-release-invocation.mjs';
|
|
33
33
|
import { runReleaseTransaction } from './release-transaction.mjs';
|
|
34
|
+
import { materializePublicationHandoff, resolvePublicationHandoffPaths } from './release-publication-handoff.mjs';
|
|
34
35
|
import { liveReleaseProvider } from './release-transaction-provider.mjs';
|
|
35
36
|
import { stagedHostVerifier } from './staged-host-verifier.mjs';
|
|
36
37
|
import { verifyPayload } from './release-payload.mjs';
|
|
@@ -44,6 +45,7 @@ let publicationReceiptPath = null;
|
|
|
44
45
|
let protectedReleaseMode = 'strict';
|
|
45
46
|
let verifiedPayload = null;
|
|
46
47
|
let aggregateEnvelope = null;
|
|
48
|
+
let publicationHandoffPaths = null;
|
|
47
49
|
const c = { g: (s) => `\x1b[32m${s}\x1b[0m`, r: (s) => `\x1b[31m${s}\x1b[0m`, y: (s) => `\x1b[33m${s}\x1b[0m`, b: (s) => `\x1b[1m${s}\x1b[0m`, dim: (s) => `\x1b[2m${s}\x1b[0m` };
|
|
48
50
|
const V = () => JSON.parse(fs.readFileSync(path.join(ROOT, 'plugin/.claude-plugin/plugin.json'), 'utf8')).version;
|
|
49
51
|
|
|
@@ -222,6 +224,11 @@ if (PUBLISH) {
|
|
|
222
224
|
console.error(`\n${c.r('✗ PROTECTED RELEASE GATE FAILED')}\n publication receipt output must be a new file inside release-evidence\n`);
|
|
223
225
|
process.exit(1);
|
|
224
226
|
}
|
|
227
|
+
publicationHandoffPaths = resolvePublicationHandoffPaths({
|
|
228
|
+
root: ROOT,
|
|
229
|
+
identityPath: process.env.RUVNET_RELEASE_IDENTITY,
|
|
230
|
+
receiptPath: process.env.RUVNET_CHANNEL_RECEIPT,
|
|
231
|
+
});
|
|
225
232
|
const payloadManifestPath = path.resolve(ROOT, process.env.RUVNET_CANDIDATE_PAYLOAD || '');
|
|
226
233
|
const payloadSignaturePath = path.resolve(ROOT, process.env.RUVNET_CANDIDATE_PAYLOAD_SIGNATURE || '');
|
|
227
234
|
const aggregatePath = path.resolve(ROOT, process.env.RUVNET_AGGREGATE_ENVELOPE || '');
|
|
@@ -375,6 +382,7 @@ if (PUBLISH) {
|
|
|
375
382
|
};
|
|
376
383
|
const privatePem = process.env.RUVNET_SIGNING_KEY;
|
|
377
384
|
if (!privatePem) throw new Error('RUVNET_SIGNING_KEY is required for signed transaction receipts');
|
|
385
|
+
const publicKey = crypto.createPublicKey(fs.readFileSync(path.join(ROOT, 'keys/ruvnet-brain-signing.pub.pem'), 'utf8'));
|
|
378
386
|
const finalReceipt = await runReleaseTransaction({
|
|
379
387
|
identity, assets, adapter: liveReleaseProvider({
|
|
380
388
|
root: ROOT,
|
|
@@ -382,10 +390,11 @@ if (PUBLISH) {
|
|
|
382
390
|
publicationReceipt: process.env.RUVNET_PUBLICATION_RECEIPT,
|
|
383
391
|
}),
|
|
384
392
|
privateKey: crypto.createPrivateKey(privatePem),
|
|
385
|
-
publicKey
|
|
393
|
+
publicKey,
|
|
386
394
|
hostVerifier: stagedHostVerifier({ assets, identity }),
|
|
387
395
|
});
|
|
388
396
|
if (finalReceipt.state !== 'channels-converged') throw new Error(`release transaction stopped at ${finalReceipt.state}`);
|
|
397
|
+
materializePublicationHandoff({ paths: publicationHandoffPaths, identity, receipt: finalReceipt, publicKey });
|
|
389
398
|
} else {
|
|
390
399
|
step('D', 'remote staged release transaction — SKIPPED (check-only; pass --publish to publish)');
|
|
391
400
|
}
|