@rightkit/release 0.4.1 → 0.5.1

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.
@@ -0,0 +1,319 @@
1
+ // Local runtime-budget proof of ONE runtime owner on THIS host (Adrian 2026-10-22: runtime proofs no longer need GitHub CI).
2
+ // Called by local-qualify.mjs after the host's units are qualified, so the machine is not busy with them. For every trusted
3
+ // workload of the crate it runs the pinned Cargo command through the RustRight broker, repeatedly, each time in a quiet window
4
+ // whose evidence it records, and returns the entry that goes into this host's `local-verify` receipt as `runtime.crates[crate]`.
5
+ //
6
+ // Trust and measurement, mirroring the Actions path (scripts/ci/runtime-qualify.mjs):
7
+ // - WHICH workloads run comes from the trusted table in runtime-qualify.mjs (`workloadsFor`), never from the candidate. The
8
+ // candidate's crates/<crate>/benches/workloads.json must equal that table exactly (`readSourcePrescription`) or the run
9
+ // refuses. The harness code is loaded from `root`, a clean checkout whose HEAD is on origin/main (local-qualify.mjs
10
+ // checkoutState) and equals the candidate commit; admission later requires every helper of the harness to equal the
11
+ // promotion checkout's (`harness.helpers` below), exactly as CI requires the producer's blobs to.
12
+ // - WHAT is measured is the candidate's source at `commit`: the checkout is verified clean and at `commit` before and
13
+ // after every run, so the bench cannot have built anything else. Every file the measurement depends on is hashed from
14
+ // the git blobs of `commit` into `bindings`, as CI does (bindSource).
15
+ // - HOW it is measured: the same `RESULT budget` line the bench prints, parsed and recorded by the owner's own
16
+ // `recordMeasurement`, checked by the owner's own `measurementProblems` against the frozen budget (profile, ceilings,
17
+ // required scale). The submitted record is the WORST of `repeats` runs, every run must pass, and each run holds a window
18
+ // that local-runtime-proof.mjs `windowProblems` calls quiet. A disturbed window refuses; it is never retried silently.
19
+ // The broker has no client-held exclusive lease (docs/CI.md), so quiet is DETECTED: health snapshots before, during and after,
20
+ // the broker's job listing for overlapping root jobs, and host CPU idle before and after.
21
+ import crypto from "node:crypto";
22
+ import fs from "node:fs";
23
+ import os from "node:os";
24
+ import path from "node:path";
25
+ import { pathToFileURL } from "node:url";
26
+ import { spawnSync } from "./background-process.mjs";
27
+ import { spawnCollect } from "./managed-cargo.mjs";
28
+ import { discoverBrokerSocket, managedControlCandidates } from "./managed-roots.mjs";
29
+
30
+ const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
31
+ const sleepFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
32
+ const round3 = (value) => Math.round(value * 1000) / 1000;
33
+ const tail = (text, lines = 30) => String(text ?? "").split(/\r?\n/).slice(-lines).join("\n");
34
+ const loadFromRoot = (root, ...segments) => import(pathToFileURL(path.join(root, ...segments)).href);
35
+ const CREDENTIAL_LIKE = /TOKEN|PASSWORD|SECRET|CREDENTIAL|^ACTIONS_ID_TOKEN/i;
36
+ const BROKER_ENV = ["RIGHTKIT_BUILD_BROKER_SOCKET", "RIGHTKIT_CONTROL_ROOT", "RIGHTKIT_DATA_ROOT", "RIGHTKIT_MANAGED_ROOT"];
37
+ const BENCH_TIMEOUT_MS = 40 * 60 * 1000;
38
+
39
+ // ---- host evidence -------------------------------------------------------------------------------
40
+
41
+ /** Candidate locations of the broker's health.json on this host, most specific first. */
42
+ export function brokerHealthFiles({ env = process.env, platform = process.platform, home = os.homedir() } = {}) {
43
+ const join = platform === "win32" ? path.win32.join : path.posix.join;
44
+ const files = [env.RK_BROKER_HEALTH_FILE, ...managedControlCandidates({ env, platform }).map((root) => join(root, "health.json"))];
45
+ if (platform === "darwin") files.push(path.posix.join(home, "Library", "Application Support", "RustRight", "health.json"));
46
+ return [...new Set(files.filter(Boolean))];
47
+ }
48
+
49
+ /** The newest readable broker snapshot, stamped with the time it was read; null when none is readable. */
50
+ export function readBrokerHealth({ files, readFile = fs.readFileSync, now = Date.now } = {}) {
51
+ let best = null;
52
+ for (const file of files) {
53
+ let doc;
54
+ try { doc = JSON.parse(readFile(file, "utf8")); } catch { continue; }
55
+ if (doc?.schemaVersion !== 1 || !Number.isInteger(doc.writtenAtMs)) continue;
56
+ if (!best || doc.writtenAtMs > best.writtenAtMs) best = { writtenAtMs: doc.writtenAtMs, sampledAtMs: now(), state: doc.state, admissionPaused: doc.admissionPaused, inFlight: doc.inFlight, queued: doc.queued, admitting: doc.admitting, pid: doc.pid ?? null };
57
+ }
58
+ return best;
59
+ }
60
+
61
+ const cpuTotals = (cpus) => cpus.reduce((sum, cpu) => { const t = cpu.times; sum.idle += t.idle; sum.total += t.user + t.nice + t.sys + t.idle + t.irq; return sum; }, { idle: 0, total: 0 });
62
+ /** Host-wide CPU idle share over `ms`, from the OS tick counters (every process, not only ours). */
63
+ export async function sampleCpu(ms, { cpus = os.cpus, loadavg = os.loadavg, sleep = sleepFor, now = Date.now, platform = process.platform } = {}) {
64
+ const startedAtMs = now();
65
+ const first = cpuTotals(cpus());
66
+ await sleep(ms);
67
+ const second = cpuTotals(cpus());
68
+ const total = second.total - first.total;
69
+ return { startedAtMs, durationMs: now() - startedAtMs, idlePercent: total > 0 ? round3((100 * (second.idle - first.idle)) / total) : null, cores: cpus().length, loadavg1: platform === "win32" ? null : round3(loadavg()[0]) };
70
+ }
71
+
72
+ /** Root jobs of the broker's listing (`rustright list`): live ones from `inFlight`, finished ones from `recent`. Tolerant of wrapping. */
73
+ export function parseBrokerJobs(text) {
74
+ const jobs = []; let found = false;
75
+ const take = (rows, live) => {
76
+ if (!Array.isArray(rows)) return;
77
+ found = true;
78
+ for (const row of rows) {
79
+ const submittedAtMs = Date.parse(row?.submittedAt);
80
+ if (!row || typeof row !== "object" || !Number.isFinite(submittedAtMs)) continue;
81
+ const startedAtMs = row.startedAt ? Date.parse(row.startedAt) : null;
82
+ jobs.push({ id: String(row.id), workspace: row.workspace ?? null, live, submittedAtMs, startedAtMs: Number.isFinite(startedAtMs) ? startedAtMs : null, endedAtMs: live ? null : (Number.isFinite(row.elapsedMs) ? submittedAtMs + row.elapsedMs : null) });
83
+ }
84
+ };
85
+ for (const line of String(text ?? "").split(/\r?\n/)) {
86
+ let doc; try { doc = JSON.parse(line); } catch { continue; }
87
+ for (const holder of [doc, doc?.event, doc?.result]) if (holder && typeof holder === "object") { take(holder.inFlight, true); take(holder.recent, false); }
88
+ }
89
+ if (!found) throw new Error("the broker job listing has no inFlight or recent list");
90
+ return jobs;
91
+ }
92
+
93
+ const samePath = (a, b) => String(a ?? "").replaceAll("\\", "/").replace(/\/+$/, "").toLowerCase() === String(b ?? "").replaceAll("\\", "/").replace(/\/+$/, "").toLowerCase();
94
+ /**
95
+ * Which root jobs touched the span [fromMs, toMs]. `ours` counts the root jobs in the measured checkout that were submitted
96
+ * at the run start; anything else that overlaps the span, in any workspace, is foreign. A finished job whose end is unknown
97
+ * is treated as still running (it overlaps).
98
+ */
99
+ export function analyzeJobs({ jobs, root, fromMs, toMs, runStartMs, checkedAtMs, slackMs = 1000 }) {
100
+ const mine = jobs.filter((job) => samePath(job.workspace, root) && job.submittedAtMs >= runStartMs - 5000 && job.submittedAtMs <= runStartMs + 60_000);
101
+ const overlaps = (job) => (job.startedAtMs ?? job.submittedAtMs) <= toMs + slackMs && (job.live || job.endedAtMs === null || job.endedAtMs >= fromMs - slackMs);
102
+ const foreign = jobs.filter((job) => overlaps(job) && !(mine.length === 1 && job.id === mine[0].id));
103
+ return { checked: true, fromMs, toMs, checkedAtMs, ours: mine.length, foreign: foreign.map(({ id, workspace, live, startedAtMs, endedAtMs }) => ({ id, workspace, live, startedAtMs, endedAtMs })) };
104
+ }
105
+
106
+ /** The broker listing at one moment, reduced to what the window proof needs: when it was read, which root jobs it names, which are live. */
107
+ export const listingRecord = (jobs, checkedAtMs) => ({ checked: true, checkedAtMs, ids: jobs.map((job) => job.id), live: jobs.filter((job) => job.live).map((job) => job.id) });
108
+
109
+ async function listBrokerJobs({ client, env, run }) {
110
+ const result = await run(client, ["list"], { env, timeout: 30_000, windowsHide: true });
111
+ if (result.error || result.status !== 0) throw new Error(`rustright list failed: ${result.error?.message ?? `exit ${result.status}`}`);
112
+ return parseBrokerJobs(result.stdout);
113
+ }
114
+
115
+ // ---- the proof -----------------------------------------------------------------------------------
116
+
117
+ async function defaultHarness(root) {
118
+ const [owner, proof, budgets, supplement, workloadEnv] = await Promise.all([
119
+ loadFromRoot(root, "scripts", "ci", "runtime-qualify.mjs"), loadFromRoot(root, "scripts", "ci", "local-runtime-proof.mjs"), loadFromRoot(root, "packages", "release", "runtime-budgets.mjs"),
120
+ loadFromRoot(root, "scripts", "ci", "runtime-supplement.mjs"), loadFromRoot(root, "scripts", "ci", "runtime-workload-env.mjs"),
121
+ ]);
122
+ return { owner, proof, budgets, workloadEnv, helperPaths: supplement.trustedRuntimeHelperPaths };
123
+ }
124
+
125
+ /** `args` of a pinned bench command with `--no-run` before the bench's own `--` arguments: a warm build, never a measurement. */
126
+ export const warmArgs = (args) => { const at = args.indexOf("--"); return at < 0 ? [...args, "--no-run"] : [...args.slice(0, at), "--no-run", ...args.slice(at)]; };
127
+
128
+ /**
129
+ * Measures `crate` on this host and returns its runtime entry (see scripts/ci/runtime-supplement.mjs verifyLocalRuntimeProof
130
+ * for what admission checks). Throws with every reason on the first refusal; nothing partial is returned.
131
+ * options: root, commit, crate, version, archiveSha256, candidateId, broker, cargoCommand, repository, scratch,
132
+ * repeats (policy range), waitIdleMs (how long to wait for a quiet host before refusing).
133
+ */
134
+ export async function measureRuntimeCrate(options, deps = {}) {
135
+ const { root, commit, crate, version, archiveSha256, candidateId, broker, cargoCommand, repository, scratch, repeats, waitIdleMs = 0 } = options;
136
+ const log = deps.log ?? (() => {});
137
+ const platform = deps.platform ?? process.platform;
138
+ const arch = deps.arch ?? process.arch;
139
+ const now = deps.now ?? Date.now;
140
+ const sleep = deps.sleep ?? sleepFor;
141
+ const H = await (deps.loadHarness ?? defaultHarness)(root);
142
+ const { owner, proof } = H;
143
+ const policy = proof.WINDOW_POLICY;
144
+ if (!Number.isInteger(repeats) || repeats < policy.minRepeats || repeats > policy.maxRepeats) throw new Error(`repeats must be an integer from ${policy.minRepeats} to ${policy.maxRepeats}`);
145
+ const gitText = deps.git ?? ((args) => { const r = spawnSync("git", args, { cwd: root, encoding: "utf8", windowsHide: true, maxBuffer: 64 * 1024 * 1024 }); return r.error || r.status !== 0 ? null : (r.stdout ?? "").trim(); });
146
+ const blob = deps.gitBlob ?? ((full) => { const r = spawnSync("git", ["cat-file", "blob", `${commit}:${full}`], { cwd: root, encoding: "buffer", windowsHide: true, maxBuffer: 64 * 1024 * 1024 }); if (r.error || r.status !== 0) throw new Error(`candidate blob missing: ${full}`); return Buffer.from(r.stdout); });
147
+ const lsTree = (paths) => { const text = gitText(["ls-tree", "-r", "--name-only", commit, ...paths]); if (text === null) throw new Error("cannot list the candidate tree"); return text.split(/\r?\n/).filter(Boolean); };
148
+ const assertCheckout = (when) => {
149
+ const head = gitText(["rev-parse", "HEAD"]); const dirty = gitText(["status", "--porcelain"]);
150
+ if (head !== commit) throw new Error(`${when}: HEAD is ${String(head).slice(0, 12)}, not the candidate commit ${commit.slice(0, 12)}; the measured source changed`);
151
+ if (dirty !== "") throw new Error(`${when}: the checkout is not clean, so the measured source is not the candidate:\n${dirty}`);
152
+ };
153
+ assertCheckout("start");
154
+
155
+ // Trusted coverage first: a crate without it has nothing to run, and the candidate cannot supply its own list.
156
+ const trusted = owner.workloadsFor(crate);
157
+ const { prescription, workloads } = owner.readSourcePrescription(crate, blob);
158
+ if (JSON.stringify(workloads.map((w) => w.id)) !== JSON.stringify(trusted.map((w) => w.id))) throw new Error("candidate workloads differ from the trusted table");
159
+ const crateDir = path.join(root, "crates", crate);
160
+ let budget;
161
+ try { budget = JSON.parse(blob(`crates/${crate}/runtime-budget.json`)); } catch { throw new Error("candidate runtime-budget.json is missing or not JSON"); }
162
+
163
+ const bindings = {};
164
+ for (const full of [...lsTree([`crates/${crate}/src`]), `crates/${crate}/Cargo.toml`]) bindings[full] = sha256(blob(full));
165
+ Object.assign(bindings, owner.bindHarnessMetadata(crate, prescription, blob));
166
+ for (const full of lsTree(["Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".cargo", `crates/${crate}/benches`, "crates/rightkit-child-process/src", "crates/rightkit-child-process/Cargo.toml"])) bindings[full] = sha256(blob(full));
167
+ const { specs, problems: scopeProblems = [] } = (deps.measuredPathspecs ?? H.budgets.measuredPathspecs)(crateDir);
168
+ if (scopeProblems.length) throw new Error(`cannot bind the measured dependency scope: ${scopeProblems.join("; ")}`);
169
+ for (const full of lsTree(specs)) bindings[full] = sha256(blob(full));
170
+ const helpers = Object.fromEntries(H.helperPaths().map((name) => [name, sha256(blob(name))]));
171
+
172
+ const baseEnv = Object.fromEntries(Object.entries(deps.env ?? process.env).filter(([name]) => !CREDENTIAL_LIKE.test(name)));
173
+ const brokerEnv = Object.fromEntries(BROKER_ENV.filter((name) => baseEnv[name]).map((name) => [name, baseEnv[name]]));
174
+ const socket = discoverBrokerSocket(deps.env ?? process.env, { platform });
175
+ if (socket) brokerEnv.RIGHTKIT_BUILD_BROKER_SOCKET = socket;
176
+ const run = deps.run ?? ((command, args, settings) => spawnCollect(command, args, { maxBuffer: 64 * 1024 * 1024, ...settings }));
177
+ const invoke = (args, settings) => run(cargoCommand[0], [...cargoCommand.slice(1), ...args], { cwd: root, windowsHide: true, ...settings });
178
+ const toolchainOf = async () => {
179
+ const rustc = await run(cargoCommand[0] === "rightkit" ? "rightkit" : "rustc", cargoCommand[0] === "rightkit" ? ["rustc", "-Vv"] : ["-Vv"], { cwd: root, env: { ...baseEnv, ...brokerEnv }, windowsHide: true, timeout: 60_000 });
180
+ const cargo = await invoke(["-V"], { env: { ...baseEnv, ...brokerEnv }, timeout: 60_000 });
181
+ if (rustc.error || rustc.status !== 0 || cargo.error || cargo.status !== 0) throw new Error("cannot read the toolchain through the broker");
182
+ return { rustc: String(rustc.stdout).trim(), cargo: String(cargo.stdout).trim() };
183
+ };
184
+ const toolchain = await toolchainOf();
185
+
186
+ const healthFiles = deps.healthFiles ?? brokerHealthFiles({ env: deps.env ?? process.env, platform });
187
+ const readHealth = deps.readHealth ?? (() => readBrokerHealth({ files: healthFiles, now }));
188
+ const cpuSample = deps.sampleCpu ?? ((ms) => sampleCpu(ms, { sleep, now, platform }));
189
+ const listJobs = deps.listJobs ?? (() => listBrokerJobs({ client: broker.client, env: { ...baseEnv, ...brokerEnv }, run }));
190
+
191
+ async function awaitQuiet(label) {
192
+ const deadline = now() + waitIdleMs;
193
+ for (;;) {
194
+ let problems;
195
+ const first = readHealth();
196
+ problems = first ? proof.brokerIdleProblems(first, "before", policy) : ["before: no broker health snapshot is readable"];
197
+ if (!problems.length) {
198
+ const cpu = await cpuSample(policy.before.minCpuSampleMs + 500);
199
+ const confirm = readHealth();
200
+ problems = [...proof.cpuIdleProblems(cpu, "before", policy.before), ...(confirm ? proof.brokerIdleProblems(confirm, "before", policy) : ["before: no broker health snapshot is readable"])];
201
+ if (!problems.length) return { health: confirm, cpu };
202
+ }
203
+ if (now() >= deadline) throw new Error(`${label}: the host is not quiet${waitIdleMs ? ` after waiting ${Math.round(waitIdleMs / 60_000)} min` : ""}; no measurement was taken: ${problems.join("; ")}`);
204
+ await sleep(5000);
205
+ }
206
+ }
207
+
208
+ // The owner recorder rewrites runtime-budget.json and writes an evidence alias inside the checkout, as in CI; it is restored
209
+ // in `finally` and the checkout is verified clean again.
210
+ function recordRun({ workload, raw, index, command }) {
211
+ const budgetFile = path.join(crateDir, "runtime-budget.json");
212
+ const before = fs.readFileSync(budgetFile);
213
+ const alias = path.join(crateDir, "runtime-budget.evidence", `${platform}-${arch}-${version}-synthetic.txt`);
214
+ const prior = fs.existsSync(alias) ? fs.readFileSync(alias) : null;
215
+ const resultFile = path.join(scratch, `${workload.id}-run-${index}.txt`);
216
+ fs.mkdirSync(scratch, { recursive: true });
217
+ fs.writeFileSync(resultFile, raw);
218
+ try {
219
+ const record = (deps.recordMeasurement ?? H.budgets.recordMeasurement)({ crateDir, resultFile, scope: "synthetic", command, description: workload.description });
220
+ return owner.preserveWorkloadRecord(structuredClone(record), workload.id, crate);
221
+ } finally {
222
+ fs.writeFileSync(budgetFile, before);
223
+ if (prior !== null) fs.writeFileSync(alias, prior); else if (fs.existsSync(alias)) fs.unlinkSync(alias);
224
+ fs.rmSync(resultFile, { force: true });
225
+ }
226
+ }
227
+
228
+ const entryWorkloads = [];
229
+ for (const workload of trusted) {
230
+ const gate = owner.budgetForWorkload(budget, workload);
231
+ const command = `cargo ${workload.args.join(" ")}`;
232
+ // Warm build first: compilation is never part of a quiet window and never overlaps a measurement.
233
+ log(`[local-runtime] ${crate}/${workload.id}: warm build`);
234
+ const warm = await invoke(warmArgs(workload.args), { env: { ...baseEnv, ...brokerEnv }, timeout: BENCH_TIMEOUT_MS });
235
+ if (warm.error || warm.status !== 0) throw new Error(`${workload.id}: the warm build failed (${warm.error?.message ?? `exit ${warm.status}`})\n${tail(`${warm.stdout}\n${warm.stderr}`)}`);
236
+ const runs = [];
237
+ for (let index = 0; index < repeats; index += 1) {
238
+ assertCheckout(`${workload.id}#${index} before`);
239
+ const label = `${workload.id} run ${index + 1}/${repeats}`;
240
+ log(`[local-runtime] ${crate}/${label}: waiting for a quiet host`);
241
+ const quiet = await awaitQuiet(label);
242
+ // The broker's job listing at grant: read once the host is quiet and before the run starts. Nothing may be live.
243
+ let grantJobs;
244
+ try { grantJobs = listingRecord(await listJobs(), now()); } catch (error) { grantJobs = { checked: false, error: error.message }; }
245
+ let provisioned; let cleanup = null; let result; const during = [];
246
+ const startedAtMs = now();
247
+ try {
248
+ provisioned = H.workloadEnv.provisionWorkloadEnv(workload.env);
249
+ const env = { ...H.workloadEnv.workloadEnvironment(baseEnv, workload.env, provisioned.values), ...brokerEnv, CARGO_INCREMENTAL: "1" };
250
+ const running = invoke(workload.args, { env, timeout: BENCH_TIMEOUT_MS });
251
+ let finished = false;
252
+ running.then(() => { finished = true; }, () => { finished = true; });
253
+ while (!finished) {
254
+ // Only a snapshot the broker WROTE during the run is evidence of it, and each distinct write is kept once.
255
+ const sample = readHealth(); if (sample && sample.writtenAtMs >= startedAtMs && (!during.length || sample.writtenAtMs > during[during.length - 1].writtenAtMs)) during.push(sample);
256
+ await Promise.race([running.catch(() => {}), sleep(1000)]);
257
+ }
258
+ result = await running;
259
+ } catch (error) { result = { error, status: null, signal: null, stdout: "", stderr: `workload environment failed: ${error.message}` }; }
260
+ finally { try { provisioned?.dispose(); } catch (error) { cleanup = error.message; } }
261
+ const endedAtMs = now();
262
+ // Broker idle again, from a snapshot written after the run ended (the broker rewrites health every 5 s).
263
+ let afterHealth = null; const settleBy = now() + 20_000;
264
+ for (;;) {
265
+ const sample = readHealth(); if (sample) afterHealth = sample;
266
+ if (afterHealth && afterHealth.writtenAtMs >= endedAtMs && !proof.brokerIdleProblems(afterHealth, "after", policy).length) break;
267
+ if (now() >= settleBy) break;
268
+ await sleep(1000);
269
+ }
270
+ const afterCpu = await cpuSample(policy.after.minCpuSampleMs + 500);
271
+ const checkedAtMs = now();
272
+ let jobs;
273
+ try {
274
+ const listing = await listJobs();
275
+ jobs = { ...analyzeJobs({ jobs: listing, root, fromMs: quiet.cpu.startedAtMs, toMs: afterCpu.startedAtMs + afterCpu.durationMs, runStartMs: startedAtMs, checkedAtMs }), atGrant: grantJobs, atRelease: listingRecord(listing, checkedAtMs) };
276
+ } catch (error) { jobs = { checked: false, error: error.message }; }
277
+ const window = { policy: policy.id, startedAtMs, endedAtMs, before: { health: quiet.health, cpu: quiet.cpu }, during: { health: during }, after: { health: afterHealth, cpu: afterCpu }, jobs };
278
+ const windowIssues = proof.windowProblems(window, policy, { minWindowMs: proof.minWindowMsFor(crate, workload.id, policy), runStartedAtMs: startedAtMs, runEndedAtMs: endedAtMs });
279
+ window.quiet = windowIssues.length === 0;
280
+ assertCheckout(`${label} after`);
281
+ if (!window.quiet) throw new Error(`${label}: the window was not quiet (disturbed by something on this host; rerun when idle): ${windowIssues.join("; ")}`);
282
+ const raw = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
283
+ if (cleanup) throw new Error(`${label}: ${cleanup}`);
284
+ if (result.error || result.status !== 0) throw new Error(`${label}: the bench failed (${result.error?.message ?? `exit ${result.status}`})\n${tail(raw)}`);
285
+ let record;
286
+ try { record = recordRun({ workload, raw, index, command }); } catch (error) { throw new Error(`${label}: the owner recorder rejected the run: ${error.message}`); }
287
+ assertCheckout(`${label} recorded`);
288
+ const issues = owner.measurementProblems(record, gate, commit, platform);
289
+ if (issues.length) throw new Error(`${label}: over budget or unproven: ${issues.join("; ")}`);
290
+ runs.push({ index, startedAt: new Date(startedAtMs).toISOString(), endedAt: new Date(endedAtMs).toISOString(), exitCode: result.status, signal: result.signal ?? null, rawSha256: sha256(Buffer.from(raw)), raw, record, window });
291
+ log(`[local-runtime] ${crate}/${label}: ok (${JSON.stringify(record.observed)})`);
292
+ }
293
+ const selected = proof.worstIndex(runs.map((run) => run.record), gate);
294
+ const picked = runs[selected];
295
+ entryWorkloads.push({
296
+ schema: 2, source: "local", workload: workload.id, intervalMs: workload.intervalMs ?? null, crate, version, repository, runId: candidateId, runAttempt: "local", sourceSha: commit,
297
+ artifactId: "local", artifactDigest: null, harnessSha: commit, qualificationRunId: candidateId, qualificationRunAttempt: "local", manifestSha256: null, bindings, toolchain,
298
+ host: { os: platform, arch, release: (deps.release ?? os.release)(), runner: "local" }, command, invocation: [...cargoCommand, ...workload.args].join(" "), envNames: [...workload.env],
299
+ rawFile: `${workload.id}-bench.txt`, rawSha256: picked.rawSha256, exitCode: picked.exitCode, signal: null, problems: [], record: picked.record, selected, repeats: runs,
300
+ });
301
+ }
302
+ assertCheckout("end");
303
+ const cpus = (deps.cpus ?? os.cpus)();
304
+ return {
305
+ schema: 1, kind: "local-runtime-crate", source: "local", policy: policy.id, crate, version, sourceSha: commit, candidateId, manifestSha256: null, archiveSha256,
306
+ host: { os: platform, arch, release: (deps.release ?? os.release)(), model: cpus[0]?.model ?? null, cores: cpus.length }, broker: structuredClone(broker),
307
+ harness: { sourceSha: commit, helpers }, workloads: entryWorkloads,
308
+ };
309
+ }
310
+
311
+ /** Fills in what is only known once the packed manifest is sealed (the host measured before the driver sealed it). */
312
+ export function bindRuntimeManifest(runtime, manifestSha256) {
313
+ const bound = structuredClone(runtime);
314
+ for (const entry of Object.values(bound.crates)) {
315
+ entry.manifestSha256 = manifestSha256;
316
+ for (const item of entry.workloads) { item.manifestSha256 = manifestSha256; item.artifactDigest = `local:${manifestSha256}`; }
317
+ }
318
+ return bound;
319
+ }
package/lock-graph.mjs ADDED
@@ -0,0 +1,51 @@
1
+ // Pure Cargo.lock graph functions shipped with @rightkit/release for rightkit-gate (the local gate).
2
+ // KEEP IDENTICAL to parseLock, changedLockNames and reach in scripts/ci/select.mjs (RightKit CI, not shipped);
3
+ // rightkit-gate.test.mjs compares the two sources and fails on any difference. No process, no filesystem.
4
+
5
+ /**
6
+ * Parse Cargo.lock into name -> { signatures: string[], deps: Set<name> }.
7
+ * A package's signature covers version, source, checksum and its dependency list, so any change
8
+ * to what gets compiled changes it. Dependency edges are by name: over-approximate, never under.
9
+ */
10
+ export function parseLock(text) {
11
+ const packages = new Map();
12
+ for (const block of String(text ?? "").split(/^\[\[package\]\]\s*$/m).slice(1)) {
13
+ const name = /^name = "([^"]+)"/m.exec(block)?.[1];
14
+ if (!name) continue;
15
+ const field = (key) => new RegExp(`^${key} = "([^"]*)"`, "m").exec(block)?.[1] ?? "";
16
+ const depsRaw = /^dependencies = \[([^\]]*)\]/m.exec(block)?.[1] ?? "";
17
+ const deps = [...depsRaw.matchAll(/"([^"\s]+)[^"]*"/g)].map((m) => m[1]);
18
+ const entry = packages.get(name) ?? { signatures: [], deps: new Set() };
19
+ entry.signatures.push([field("version"), field("source"), field("checksum"), deps.join(",")].join("|"));
20
+ for (const dep of deps) entry.deps.add(dep);
21
+ packages.set(name, entry);
22
+ }
23
+ for (const entry of packages.values()) entry.signatures.sort();
24
+ return packages;
25
+ }
26
+
27
+ /** Names whose lock entry is new or different in `next`. */
28
+ export function changedLockNames(previous, next) {
29
+ const changed = new Set();
30
+ for (const [name, entry] of next) {
31
+ const before = previous.get(name);
32
+ if (!before || before.signatures.join("\n") !== entry.signatures.join("\n")) changed.add(name);
33
+ }
34
+ return changed;
35
+ }
36
+
37
+ /** Every package name reachable from `name` through the lock graph, `name` excluded. */
38
+ export function reach(lock, name, memo = new Map()) {
39
+ if (memo.has(name)) return memo.get(name);
40
+ const seen = new Set();
41
+ const stack = [...(lock.get(name)?.deps ?? [])];
42
+ while (stack.length) {
43
+ const next = stack.pop();
44
+ if (seen.has(next)) continue;
45
+ seen.add(next);
46
+ for (const dep of lock.get(next)?.deps ?? []) stack.push(dep);
47
+ }
48
+ seen.delete(name);
49
+ memo.set(name, seen);
50
+ return seen;
51
+ }
package/managed-cargo.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { discoverBrokerSocket, managedControlCandidates, resolveManagedRoots } from './managed-roots.mjs';
2
2
  import { randomUUID } from "node:crypto";
3
- import { homedir } from "node:os";
3
+ import { homedir, userInfo } from "node:os";
4
4
  import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
5
5
  import path from "node:path";
6
6
  import { spawn as spawnProcess, spawnSync } from "./background-process.mjs";
@@ -17,6 +17,9 @@ export function managedRuntimeClient(env = process.env, platform = process.platf
17
17
  if (platform === 'darwin') {
18
18
  const client = path.posix.join(env.HOME || homedir(), 'Library', 'Application Support', 'RustRight', 'runtime', 'rustright');
19
19
  if (exists(client)) return client;
20
+ // Broker-managed HOME may point at a build home; the installed native client belongs to the OS account.
21
+ const accountClient = path.posix.join(userInfo().homedir, 'Library', 'Application Support', 'RustRight', 'runtime', 'rustright');
22
+ if (exists(accountClient)) return accountClient;
20
23
  }
21
24
  // RustRight's own install on Windows (2026-10-18 cutover): the runtime moved out of the control root to
22
25
  // <drive>\.rustright\runtime, beside the control root's drive, and runtime-current was retired.
@@ -302,22 +302,39 @@ export function validateOwnershipFloor(trust, { scannerVersion, catalogVersion,
302
302
  if (trust.policyBundle?.digest && policyBundleDigest !== undefined && trust.policyBundle.digest !== policyBundleDigest) warnings.push(POLICY_BUNDLE_DRIFT_WARNING);
303
303
  return { ok: problems.length === 0, problems, warnings };
304
304
  }
305
- function protectedLocation(file, platform, { runner = spawnSync, inspect = lstatSync, stat = statSync, requireNonElevated = true } = {}) {
305
+ // Windows ACL answers, per runner, for a short window: every trust check walks the same ancestor directories (C:\, C:\Users, ...)
306
+ // and each answer costs a Windows PowerShell start (~2 s), so a scan of many files repeated the same lookups hundreds of times
307
+ // (the fingerprint-samples test: 187 scanner runs, ~8 ancestors each). An ACL that changes within the window is caught on the
308
+ // next check after it; a failed lookup is never cached.
309
+ const WINDOWS_ACL_MEMO = new WeakMap();
310
+ export const WINDOWS_ACL_MEMO_TTL_MS = 30_000;
311
+ function windowsAclLookup(runner, target, script, now) {
312
+ let memo = WINDOWS_ACL_MEMO.get(runner);
313
+ if (!memo) { memo = new Map(); WINDOWS_ACL_MEMO.set(runner, memo); }
314
+ const hit = memo.get(target);
315
+ if (hit && now - hit.at < WINDOWS_ACL_MEMO_TTL_MS) return hit.r;
316
+ const r = runner("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", windowsHide: true, env: windowsPowerShellEnvironment({ RK_TRUST_CHECK_TARGET: target }) });
317
+ if (r && r.status === 0) memo.set(target, { at: now, r }); else memo.delete(target);
318
+ return r;
319
+ }
320
+
321
+ function protectedLocation(file, platform, { runner = spawnSync, inspect = lstatSync, stat = statSync, requireNonElevated = true, now = Date.now } = {}) {
306
322
  const problems = [];
307
323
  if (platform === "win32") {
308
324
  const dirs = [];
309
- let current = path.dirname(file);
325
+ // Audit the requested platform's ancestors even when fixtures run on another host.
326
+ let current = path.win32.dirname(file);
310
327
  while (true) {
311
328
  dirs.push(current);
312
- if (path.dirname(current) === current) break;
313
- current = path.dirname(current);
329
+ if (path.win32.dirname(current) === current) break;
330
+ current = path.win32.dirname(current);
314
331
  }
315
332
  dirs.push(file);
316
333
  for (const target of dirs) {
317
334
  try { if (inspect(target).isSymbolicLink()) problems.push(`trust path contains a reparse/symlink: ${target}`); } catch { problems.push(`cannot inspect trust path: ${target}`); continue; }
318
335
  const script = "$ErrorActionPreference='Stop'; $a=Get-Acl -LiteralPath $env:RK_TRUST_CHECK_TARGET; $item=Get-Item -Force -LiteralPath $env:RK_TRUST_CHECK_TARGET; $id=[Security.Principal.WindowsIdentity]::GetCurrent(); $p=New-Object Security.Principal.WindowsPrincipal($id); $ownerSid=$a.GetOwner([Security.Principal.SecurityIdentifier]).Value; $ownerName=$a.Owner; $out=[ordered]@{ownerSID=$ownerSid;ownerName=$ownerName;attributes=$item.Attributes.ToString();directory=[bool]$item.PSIsContainer;nonempty=(!$item.PSIsContainer -or [System.IO.Directory]::EnumerateFileSystemEntries($item.FullName).GetEnumerator().MoveNext());elevated=$p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator);access=@($a.Access | ForEach-Object { [ordered]@{identitySID=$_.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value;type=$_.AccessControlType.ToString();mask=[int64]$_.FileSystemRights.value__;propagationFlags=[int]$_.PropagationFlags} })}; $out | ConvertTo-Json -Compress -Depth 5";
319
- const r = runner("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", windowsHide: true, env: windowsPowerShellEnvironment({ RK_TRUST_CHECK_TARGET: target }) });
320
- if (r.status !== 0) { problems.push(`cannot inspect trust ACL: ${target}`); continue; }
336
+ const r = windowsAclLookup(runner, target, script, now());
337
+ if (!r || r.status !== 0) { problems.push(`cannot inspect trust ACL: ${target}`); continue; }
321
338
  try {
322
339
  const acl = JSON.parse(r.stdout.trim());
323
340
  const result = evaluateWindowsAcl(acl, { target, requireNonElevated });
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema": 1,
3
3
  "source": {
4
- "table": "private-audits/TABLE1-MANDATORY.md:56-78 (M1-M23 requirement catalog)",
4
+ "table": "private-audits/TABLE1-MANDATORY.md:56-78 (M1-M23 requirement catalog; M24-M25 added 2026-10-21, pending)",
5
5
  "versions": "registry readback 2026-10-19 after the mandatory-set publish (#790): min = registryMax = the live version",
6
6
  "note": "min = registryMax = the required version: the live registry version of the published mandatory set (#790). Raise both with each mandatory-set publish."
7
7
  },
@@ -196,8 +196,8 @@
196
196
  {
197
197
  "name": "@rightkit/settings-ui",
198
198
  "ecosystem": "npm",
199
- "min": "0.6.1",
200
- "registryMax": "0.6.1"
199
+ "min": "0.7.0",
200
+ "registryMax": "0.7.0"
201
201
  }
202
202
  ]
203
203
  },
@@ -209,8 +209,8 @@
209
209
  {
210
210
  "name": "rightkit-credentials",
211
211
  "ecosystem": "cargo",
212
- "min": "0.3.0",
213
- "registryMax": "0.3.0"
212
+ "min": "0.4.2",
213
+ "registryMax": "0.4.2"
214
214
  }
215
215
  ]
216
216
  },
@@ -291,8 +291,8 @@
291
291
  {
292
292
  "name": "@rightkit/logs",
293
293
  "ecosystem": "npm",
294
- "min": "0.1.5",
295
- "registryMax": "0.1.5"
294
+ "min": "0.1.6",
295
+ "registryMax": "0.1.6"
296
296
  }
297
297
  ]
298
298
  },
@@ -330,8 +330,8 @@
330
330
  {
331
331
  "name": "@rightkit/app-update",
332
332
  "ecosystem": "npm",
333
- "min": "0.4.0",
334
- "registryMax": "0.4.0"
333
+ "min": "0.4.1",
334
+ "registryMax": "0.4.1"
335
335
  }
336
336
  ]
337
337
  },
@@ -343,8 +343,8 @@
343
343
  {
344
344
  "name": "@rightkit/release",
345
345
  "ecosystem": "npm",
346
- "min": "0.4.0",
347
- "registryMax": "0.4.0",
346
+ "min": "0.4.1",
347
+ "registryMax": "0.4.1",
348
348
  "allowDev": true
349
349
  }
350
350
  ]
@@ -427,8 +427,9 @@
427
427
  {
428
428
  "name": "@rightkit/release",
429
429
  "ecosystem": "npm",
430
- "min": "0.4.0",
431
- "allowDev": true
430
+ "min": "0.4.1",
431
+ "allowDev": true,
432
+ "registryMax": "0.4.1"
432
433
  },
433
434
  {
434
435
  "name": "rightkit-daemon",
@@ -436,6 +437,45 @@
436
437
  "min": "0.3.0"
437
438
  }
438
439
  ]
440
+ },
441
+ {
442
+ "id": "M24",
443
+ "name": "Issue tracker",
444
+ "profile": "all",
445
+ "owners": [],
446
+ "ownerPending": "@rightkit/issues-tracker 0.1.0 is not yet published (rightkit#875); never fails.",
447
+ "pendingOwners": [
448
+ {
449
+ "name": "@rightkit/issues-tracker",
450
+ "ecosystem": "npm",
451
+ "min": "0.1.0",
452
+ "allowDev": true
453
+ }
454
+ ]
455
+ },
456
+ {
457
+ "id": "M25",
458
+ "name": "End-to-end journeys",
459
+ "profile": [
460
+ "desktop",
461
+ "apple"
462
+ ],
463
+ "owners": [],
464
+ "ownerPending": "Journeys capability (E2E-TESTING-STANDARD, rightkit#878) is not built or published in rightkit-qa / @rightkit/qa; never fails.",
465
+ "pendingOwners": [
466
+ {
467
+ "name": "rightkit-qa",
468
+ "ecosystem": "cargo",
469
+ "min": "0.7.0",
470
+ "allowDev": true
471
+ },
472
+ {
473
+ "name": "@rightkit/qa",
474
+ "ecosystem": "npm",
475
+ "min": "0.5.1",
476
+ "allowDev": true
477
+ }
478
+ ]
439
479
  }
440
480
  ],
441
481
  "applicabilityRecords": {
@@ -2,7 +2,7 @@
2
2
  // right-release ownership-mandatory: Table 1 mandatory-owner-set check (plan task 5.1).
3
3
  //
4
4
  // This evaluates declarations only; it does not establish active target/features, lock resolution or production adoption.
5
- // A registered app must declare every Table 1 owner (rows M1-M23) at or above the required version,
5
+ // A registered app must declare every Table 1 owner (enforced rows M1-M20, M22; pending rows M21, M23-M25 never fail) at or above the required version,
6
6
  // from the registry. Report mode (default) prints per-app findings and exits 0; --enforce exits 1
7
7
  // on any missing owner, below-required version, or path/git/patch override ("not-on-registry").
8
8
  // Mode switching of the ownership gate itself (Report -> Enforce) is a separate step; this check
@@ -15,8 +15,9 @@
15
15
  // { "profile": "desktop", "notApplicable": { "M16": "reason text" },
16
16
  // "M22": { "role": "client|host|both|absent", "entrypoints": ["src-tauri/src/agent.rs#register_tools"] } }
17
17
  //
18
- // PK17.scanner (report only, never enforcement): rows M21 and M23 stay `ownerPending` and never fail, but each lists its
19
- // `pendingOwners` (M21: rightkit-fs, rightkit-daemon, rightkit-qa; M23: @rightkit/release, rightkit-daemon) and the report shows
18
+ // PK17.scanner (report only, never enforcement): rows M21, M23, M24 and M25 stay `ownerPending` and never fail, but each lists its
19
+ // `pendingOwners` (M21: rightkit-fs, rightkit-daemon, rightkit-qa; M23: @rightkit/release, rightkit-daemon; M24: @rightkit/issues-tracker,
20
+ // unpublished; M25: rightkit-qa, @rightkit/qa, journeys capability unbuilt) and the report shows
20
21
  // how each app's declarations measure up (`pending[].candidateOwners`, `enforced: false`). Moving those owners into `rows` and
21
22
  // raising minimums is the separate activation step (PK17.activation). The M22 applicability record (PK15-2) is validated and
22
23
  // reported under `applicability.M22`; an invalid or contradictory record is a `problem`, which never changes the exit code.
@@ -295,6 +296,11 @@ export function loadAppDeclaration(root, data) {
295
296
  return { profile: doc.profile ?? data.defaultProfile, notApplicable, records, problems };
296
297
  }
297
298
 
299
+ // A pending row's `profile` is one profile name, a list of profile names, or "all" (repository-wide: every profile, including future ones).
300
+ export function pendingAppliesTo(row, active) {
301
+ return row.profile === "all" || (Array.isArray(row.profile) ? row.profile.includes(active) : row.profile === active);
302
+ }
303
+
298
304
  export function checkApp(root, { data = loadData(), profile, amendments = [] } = {}) {
299
305
  const name = path.basename(root);
300
306
  if (!existsSync(root) || !statSync(root).isDirectory()) return { app: name, root, scanned: false, profile: profile ?? data.defaultProfile, owners: [], pending: [], notApplicable: [], problems: ["app root missing"], violations: 0 };
@@ -319,7 +325,7 @@ export function checkApp(root, { data = loadData(), profile, amendments = [] } =
319
325
  const facts = manifests.flatMap((m) => m.facts);
320
326
  // Pending rows never fail. Their `pendingOwners` are judged like any owner and listed as `candidateOwners` (`enforced: false`)
321
327
  // so the report shows what activation would find; the result is never counted in `violations`.
322
- const pending = data.pendingRows.filter((r) => r.profile === active).map((r) => ({
328
+ const pending = data.pendingRows.filter((r) => pendingAppliesTo(r, active)).map((r) => ({
323
329
  row: r.id, name: r.name, status: "owner-pending", note: r.ownerPending,
324
330
  candidateOwners: r.id in declaration.notApplicable ? [] : (r.pendingOwners ?? []).map((o) => {
325
331
  const judged = judge({ ...o, rows: [r.id] }, facts);
package/package.json CHANGED
@@ -1,17 +1,21 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "Portable release CLI/SDK with fail-closed public GitHub Releases and private R2 + RightApps distribution.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
7
7
  "bin": {
8
- "right-release": "cli/right-release.mjs"
8
+ "right-release": "cli/right-release.mjs",
9
+ "rightkit-gate": "bin/rightkit-gate.mjs"
9
10
  },
10
11
  "rightkit": {
11
12
  "publishCheck": {
12
13
  "binSmoke": {
13
14
  "right-release": [
14
15
  "--version"
16
+ ],
17
+ "rightkit-gate": [
18
+ "--version"
15
19
  ]
16
20
  }
17
21
  }
@@ -40,7 +40,7 @@ export const hasShellMeta = (text) => SHELL_META.test(String(text));
40
40
  /** right-release subcommands a config may invoke (the CLI's own commands; unknown names are refused). */
41
41
  export const OWNER_SUBCOMMANDS = Object.freeze(new Set([
42
42
  "release", "build", "package", "publish", "upload", "deps", "publish-update", "hardening", "harden", "hardeningscan", "sign-windows",
43
- "sign-updater", "sign-dev-artifact", "sign-candidate", "engine-locate", "stage-sidecar", "init", "pe", "icons", "recipe-build",
43
+ "sign-updater", "sign-dev-artifact", "sign-qa-bundle", "sign-candidate", "engine-locate", "stage-sidecar", "init", "pe", "icons", "recipe-build",
44
44
  "signing-agent", "create-mac-updater", "mirror-root-artifact", "test", "web-build", "license-notices", "toolchain", "github",
45
45
  "cargo-target", "qa", "ownership", "ownership-approval", "ownership-report", "ownership-mandatory", "adoption-evidence", "swift-package", "bundle-scan",
46
46
  "check-release-dependencies", "dist-scan", "identity-scan", "apple-profile-create", "ios-credentials", "dev", "doctor", "hooks",