@rightkit/release 0.6.13 → 0.6.14

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.
Files changed (109) hide show
  1. package/README.md +49 -1
  2. package/adapter-env.mjs +165 -0
  3. package/android-ndk.mjs +5 -1
  4. package/android-release.mjs +73 -8
  5. package/app-check.mjs +22 -8
  6. package/app-closure.mjs +214 -0
  7. package/assemble-app-command.mjs +24 -7
  8. package/atomic-write.mjs +57 -0
  9. package/bin/darwin/rightkit-swap +0 -0
  10. package/bin/rightkit-gate.mjs +1190 -337
  11. package/broker-client.mjs +106 -0
  12. package/broker-exception.mjs +211 -0
  13. package/broker-handshake.mjs +160 -0
  14. package/broker-pins.mjs +27 -8
  15. package/broker-wire.mjs +262 -0
  16. package/build-launch.mjs +364 -12
  17. package/build-ownership.mjs +117 -49
  18. package/build-release.mjs +3 -2
  19. package/cache-command.mjs +7 -2
  20. package/candidate-build-receipt.mjs +96 -0
  21. package/candidate-receipt-command.mjs +29 -0
  22. package/cargo-contract.mjs +4 -3
  23. package/cargo-ledger.mjs +1 -1
  24. package/cargo-license-notices.mjs +2 -2
  25. package/cargo-shapes.mjs +332 -0
  26. package/cargo-target-shim.mjs +44 -5
  27. package/cargo-target.mjs +5 -0
  28. package/carried-adapter-run.mjs +31 -0
  29. package/carried-cargo-run.mjs +9 -0
  30. package/carried-cargo.mjs +237 -0
  31. package/carrier-census.mjs +124 -0
  32. package/class-a-launch-guard.mjs +266 -33
  33. package/cli/right-release.mjs +2 -0
  34. package/clippy-route.mjs +23 -11
  35. package/compile-inputs.mjs +9 -12
  36. package/control-plane.mjs +18 -0
  37. package/deps-cargo-lock.mjs +208 -0
  38. package/deps.mjs +5 -0
  39. package/deterministic-scans.mjs +273 -44
  40. package/development-signing.mjs +14 -1
  41. package/development.mjs +109 -24
  42. package/direct-origin.mjs +107 -0
  43. package/disk-command.mjs +1 -0
  44. package/disk-policy.mjs +18 -3
  45. package/freshness-census.mjs +206 -58
  46. package/gate-executor.mjs +30 -12
  47. package/gate-stages.mjs +93 -16
  48. package/github-release.mjs +2 -1
  49. package/github-upload-gate.mjs +2 -1
  50. package/install-tauri-authenticode.mjs +346 -0
  51. package/ios-dev.mjs +77 -23
  52. package/ios-package-recipe.mjs +100 -41
  53. package/linux-artifacts.mjs +17 -2
  54. package/linux-qualifier.mjs +83 -0
  55. package/local-publish.mjs +36 -14
  56. package/local-runtime.mjs +2 -2
  57. package/lock-identity.mjs +138 -0
  58. package/lsclean.sh +0 -0
  59. package/managed-cargo.mjs +151 -44
  60. package/managed-roots.mjs +130 -12
  61. package/media-sidecar-catalog.mjs +1 -1
  62. package/native-adapters.mjs +411 -0
  63. package/native-helpers.mjs +26 -18
  64. package/native-tool-planning.mjs +94 -0
  65. package/ownership-mark.mjs +182 -0
  66. package/pack-digest.mjs +122 -0
  67. package/package.json +12 -12
  68. package/platform-dir.mjs +14 -0
  69. package/pnpm-adapter-grammar.mjs +226 -0
  70. package/progress-control.mjs +7 -2
  71. package/provenance-real-receipt.pins.json +7 -0
  72. package/provenance-verify.mjs +262 -1
  73. package/publication-outcomes.mjs +21 -0
  74. package/publish-update.mjs +2 -1
  75. package/qa-journeys-prebuilt-run.mjs +18 -0
  76. package/qa-journeys-prebuilt.mjs +148 -0
  77. package/qa-snapshot-run.mjs +53 -0
  78. package/qa-snapshot.mjs +164 -0
  79. package/qualification-verify.mjs +144 -0
  80. package/qualify-reuse.mjs +38 -0
  81. package/recipe-build.mjs +16 -3
  82. package/release-qa-audit.mjs +2 -2
  83. package/release-qa-command.mjs +2 -1
  84. package/release-qa-receipt.mjs +3 -2
  85. package/release-state.mjs +3 -2
  86. package/release.mjs +14 -3
  87. package/rightkit-drift.mjs +27 -5
  88. package/rightkit-versions.json +14 -10
  89. package/rustright-names.mjs +32 -0
  90. package/scan-tool-enrolment.mjs +167 -0
  91. package/sign-windows.mjs +212 -112
  92. package/signing-files.mjs +35 -0
  93. package/signing-identity.mjs +7 -0
  94. package/snapshot-broker.mjs +528 -57
  95. package/snapshot-check.mjs +95 -11
  96. package/snapshot-metadata.mjs +123 -0
  97. package/snapshot-request.mjs +389 -96
  98. package/stage-binary.mjs +22 -4
  99. package/stage-commands.mjs +56 -13
  100. package/stage-sidecar.mjs +93 -21
  101. package/suite-signing-policy.mjs +657 -0
  102. package/suite-signing.mjs +223 -0
  103. package/swift-scratch.mjs +25 -0
  104. package/tauri-locked.mjs +25 -0
  105. package/test-command.mjs +164 -29
  106. package/unit-gate.mjs +201 -31
  107. package/upload-large.mjs +0 -0
  108. package/web-build.mjs +2 -2
  109. package/win-package-recipe.mjs +26 -2
@@ -44,11 +44,12 @@
44
44
  // unreadable stat: the process is held. Residual on POSIX, stated plainly: a descendant that started a
45
45
  // new session, dropped the variable, runs outside the snapshot directory, names it in no argument AND holds no file in it
46
46
  // open or mapped at the instant the nets sample it (it reads between samples) is invisible; a sampled net cannot prove
47
- // such a process will not read next. (On macOS an Apple platform binary no longer escapes that way: hidden means held
48
- // after the baseline unless lineage clears it.) Another user's unreadable processes are
47
+ // such a process will not read next. (On macOS a hidden-environment process is attributed by no start time: it is reported in
48
+ // the output and never held; rightkit#1313.) Another user's unreadable processes are
49
49
  // skipped on the premise that they cannot read this user's managed root.
50
50
  // Any host: a reboot after the record was written ends every process it covered.
51
- // - if a broker request may have been submitted, `rustright snapshot-lease status` reports REAPED for that snapshot & handle.
51
+ // - if a carried (snapshot-bound) request was sent under the handle (`broker.submitted`, set only just before such a send; an uncarried
52
+ // probe or an installed carrier map never sets it), `rustright snapshot-lease status` reports REAPED for that snapshot & handle.
52
53
  // Any other answer (alive, a probe that fails or is unsupported, ACTIVE, UNKNOWN, a record or holder state that cannot be read)
53
54
  // is busy: never a reclaim. The same rules decide a normal release: the record is removed only when nothing but the gate itself
54
55
  // is left; otherwise it stays, and the next gate reclaims it once the survivors are gone.
@@ -64,12 +65,17 @@ import { spawn as spawnDetachedDefault, spawnSync } from "./background-process.m
64
65
  import { managedRuntimeClient } from "./managed-cargo.mjs";
65
66
  import { resolveManagedRoots } from "./managed-roots.mjs";
66
67
  import { requireManagedRoot } from "./managed-root-guard.mjs";
68
+ import { OWNERSHIP_ENV, OWNERSHIP_FILE_ENV, OwnershipBusy, holds, markBrokerSubmit, markBrokerUnsent, markCarriedSend, markLockFile } from "./ownership-mark.mjs";
69
+
70
+ // The mark itself lives in a leaf module (ownership-mark.mjs): snapshot-broker.mjs records carried sends through it, and importing this
71
+ // file from there would close a cycle (this file -> managed-cargo -> cargo-target-shim -> snapshot-broker).
72
+ export { OWNERSHIP_ENV, OWNERSHIP_FILE_ENV, OwnershipBusy, markCarriedSend, markLockFile };
67
73
 
68
74
  export const OWNERSHIP_SCHEMA = "rightkit.build-ownership/1";
69
75
  export const HOLDER_SCHEMA = "rightkit.build-ownership-holder/1";
70
- export const OWNERSHIP_ENV = "RIGHTKIT_BUILD_OWNERSHIP";
71
76
  /** The owner record's path, exported beside the handle so every process under the ownership records detached children there. */
72
- export const OWNERSHIP_FILE_ENV = "RIGHTKIT_BUILD_OWNERSHIP_FILE";
77
+ /** GB-85: how long a successful stage's release waits for every submission under its handle to be durably complete (the broker's own bounded `--wait-ms`, never a poll of the build). */
78
+ export const RELEASE_LEASE_WAIT_MS = 15_000;
73
79
  export const LEASE_STATUS_SCHEMA = "rustright.snapshot-lease-status.v1";
74
80
  const LEASE_STATES = new Set(["ACTIVE", "REAPED", "UNKNOWN"]);
75
81
  // A start time read from `ps` (or /proc/<pid>/stat) is never late, only early: ps prints whole seconds (up to 1 s early), and on
@@ -83,9 +89,6 @@ const START_SLACK_MS = 3000;
83
89
  const SAME_START_MS = 3000;
84
90
  const sameStart = (a, b) => Number.isFinite(a) && Number.isFinite(b) && Math.abs(a - b) <= SAME_START_MS;
85
91
 
86
- export class OwnershipBusy extends Error {
87
- constructor(message, detail = {}) { super(message); this.code = "busy"; this.detail = detail; }
88
- }
89
92
 
90
93
  /** The directory beside the host's snapshots (or beside any injected root) that holds the owner record. */
91
94
  export const OWNERSHIP_DIRNAME = "build-ownership";
@@ -867,10 +870,22 @@ export function readHolder(file) {
867
870
  /**
868
871
  * RustRight's lease state for a snapshot & ownership handle (`rustright snapshot-lease status`). Anything but a well-formed
869
872
  * answer is UNKNOWN: a missing client, a failed or unknown command, a schema or state the gate does not know.
873
+ *
874
+ * The answer is read exactly as the broker writes it (BROKER-CONTRACT section 16, GB-23 and GB-85): `{ type, schema, state, snapshot, ownership, members, waitedMs, waitTimedOut }`.
875
+ * `members` is one row per submission under the handle and `state` is only their aggregate (ACTIVE while any is held, REAPED only with at least one member and every member REAPED, else
876
+ * UNKNOWN); a state that is not the aggregate of its own rows is a contradiction, never a result. A wait was asked with `--wait-ms` (clamped to 30000): the answer then always carries
877
+ * `waitedMs` (a count) and `waitTimedOut` (a boolean, true exactly when the state is not REAPED at the bound). A timeout is UNKNOWN with the reason; the gate never polls and never resubmits.
878
+ * The result carries `members`, `waitedMs` and `waitTimedOut` for the callers that account by name.
870
879
  */
871
- export function brokerLeaseState({ snapshot, handle, env = process.env, platform = process.platform, spawn = spawnSync, client = managedRuntimeClient(env, platform) }) {
880
+ export function brokerLeaseState({ snapshot, handle, env = process.env, platform = process.platform, spawn = spawnSync, client = managedRuntimeClient(env, platform), waitMs = 0 }) {
872
881
  if (!client) return { state: "UNKNOWN", why: "no RustRight client on this host" };
873
- const r = run(spawn, client, ["snapshot-lease", "status", "--snapshot", snapshot, "--ownership", handle], env);
882
+ // GB-85: `--wait-ms N` makes the broker answer as soon as every submission under the handle is durably complete (REAPED), or at the bound with the state it has
883
+ // (waitTimedOut: true). A client that predates the option answers with a usage error: the plain query is then made, never a guess.
884
+ const clamp = (wait) => Math.min(Math.trunc(wait), 30_000);
885
+ const ask = (wait) => run(spawn, client, ["snapshot-lease", "status", "--snapshot", snapshot, "--ownership", handle, ...(wait > 0 ? ["--wait-ms", String(clamp(wait))] : [])], env);
886
+ let waited = Number.isFinite(waitMs) && waitMs > 0 ? waitMs : 0;
887
+ let r = ask(waited);
888
+ if (waited > 0 && r.status !== 0 && !r.error && /wait-ms|unknown|unexpected|unrecognized|usage/i.test(String(r.stderr ?? ""))) { waited = 0; r = ask(0); }
874
889
  if (r.error || r.status !== 0) return { state: "UNKNOWN", why: `rustright snapshot-lease status: ${r.error?.message ?? (String(r.stderr ?? "").trim().split(/\r?\n/).pop() || `exit ${r.status}`)}` };
875
890
  let answer = null;
876
891
  try { answer = JSON.parse(String(r.stdout ?? "").trim().split(/\r?\n/).pop()); } catch { answer = null; }
@@ -878,7 +893,25 @@ export function brokerLeaseState({ snapshot, handle, env = process.env, platform
878
893
  // The answer must echo what was asked (RustRight echoes `snapshot` as received and `ownership` as the handle): another pair's answer,
879
894
  // or no echo at all, binds nothing to this owner, so it is UNKNOWN and the record is held.
880
895
  if (answer.snapshot !== snapshot || answer.ownership !== handle) return { state: "UNKNOWN", why: `the lease status does not echo the queried snapshot and ownership handle (${JSON.stringify(answer.snapshot ?? null)}, ${JSON.stringify(answer.ownership ?? null)})` };
881
- return { state: answer.state, why: `rustright snapshot-lease status: ${answer.state}` };
896
+ const unknown = (why) => ({ state: "UNKNOWN", why: `rustright snapshot-lease status: ${why}`, ...(Array.isArray(answer.members) ? { members: answer.members } : {}) });
897
+ // GB-23: `members` is one row per submission the broker knows under the handle ({handle, state, spawned, refused?, job?}); the answer's own state is only the aggregate.
898
+ const covers = (h) => h === handle || String(h).startsWith(`${handle}#`);
899
+ if (!Array.isArray(answer.members)) return unknown("the answer carries no members list (one row per submission under the handle), so no submission can be accounted for by name");
900
+ if (answer.members.some((m) => !m || typeof m.handle !== "string" || !covers(m.handle) || !LEASE_STATES.has(m.state))) return { state: "UNKNOWN", why: `the lease status carries members that are not submissions under ${handle}` };
901
+ const rows = answer.members;
902
+ const aggregate = rows.some((m) => m.state === "ACTIVE") ? "ACTIVE" : rows.length > 0 && rows.every((m) => m.state === "REAPED") ? "REAPED" : "UNKNOWN";
903
+ if (answer.state !== aggregate) return unknown(`the answer says ${answer.state} but its ${rows.length} member row(s) aggregate to ${aggregate} (REAPED needs at least one member and every member REAPED): it contradicts itself, so it is not read`);
904
+ // GB-85: waitedMs is a count and waitTimedOut a boolean; both are always sent by a broker that takes a wait, and are required once a wait was asked.
905
+ const hasWaited = answer.waitedMs !== undefined; const hasTimedOut = answer.waitTimedOut !== undefined;
906
+ if (hasWaited && !(Number.isInteger(answer.waitedMs) && answer.waitedMs >= 0)) return unknown(`waitedMs is not a non-negative integer (${JSON.stringify(answer.waitedMs)})`);
907
+ if (hasTimedOut && typeof answer.waitTimedOut !== "boolean") return unknown(`waitTimedOut is not a boolean (${JSON.stringify(answer.waitTimedOut)})`);
908
+ if (waited > 0 && (!hasWaited || !hasTimedOut)) return unknown(`a wait of ${clamp(waited)} ms was asked but the answer carries no ${!hasWaited ? "waitedMs" : "waitTimedOut"}`);
909
+ const timedOut = answer.waitTimedOut === true;
910
+ if (timedOut && (waited <= 0 || answer.state === "REAPED")) return unknown(`waitTimedOut is true although ${waited <= 0 ? "no wait was asked" : "the state is REAPED"}: it contradicts itself`);
911
+ if (waited > 0 && answer.state !== "REAPED" && !timedOut) return unknown(`the state is ${answer.state} after a wait without waitTimedOut: it contradicts itself`);
912
+ const timing = { ...(hasWaited ? { waitedMs: answer.waitedMs } : {}), ...(hasTimedOut ? { waitTimedOut: answer.waitTimedOut } : {}) };
913
+ if (timedOut) return { state: "UNKNOWN", why: `rustright snapshot-lease status: still ${answer.state} after waiting ${answer.waitedMs} ms (waitTimedOut); ownership stays UNKNOWN`, members: rows, ...timing };
914
+ return { state: answer.state, why: `rustright snapshot-lease status: ${answer.state}`, members: rows, ...timing };
882
915
  }
883
916
 
884
917
  function publish(target, text) {
@@ -933,7 +966,7 @@ function coveredMember(m, owner, { byPid, own }) {
933
966
  * Whether the work `owner` covers, other than the process `self` (the live gate at release, null at reclaim), is proven gone.
934
967
  * Returns the proofs, or throws a reason that is busy.
935
968
  */
936
- function coveredWorkGone(owner, { probe, lease, file, selfPid = null }) {
969
+ function coveredWorkGone(owner, { probe, lease, file, selfPid = null, waitMs = 0 }) {
937
970
  const proofs = [];
938
971
  let rebooted = false;
939
972
  // A reboot is only a question for a dead owner; a live gate releasing its own record has not outlived one.
@@ -1024,30 +1057,72 @@ function coveredWorkGone(owner, { probe, lease, file, selfPid = null }) {
1024
1057
  proofs.push(`no process has a working directory, executable, open or mapped file or argument inside or naming ${owner.snapshot}`);
1025
1058
  }
1026
1059
  }
1027
- // macOS: a process whose environment `ps eww` does not show is held after the anchor unless its lineage clears it (row 5.24a).
1060
+ // macOS: a process whose environment `ps eww` does not show cannot be attributed to this ownership by the token net. Start time plus a
1061
+ // hidden environment is no attribution (rightkit#1313: an unrelated same-user process started during the run held every later gate), so
1062
+ // such a process is REPORTED here and never held. Only lineage the gate recorded (tracked child groups and intents above), the exact
1063
+ // ownership token, an actual snapshot user, and the broker lease hold a release.
1028
1064
  if (typeof probe.hiddenHolders === "function") {
1029
1065
  let hidden;
1030
- try { hidden = probe.hiddenHolders({ owner, anchorMs: skipBefore, recorded }); } catch (e) { throw new Error(`processes whose environment is hidden cannot be classified (${e.message})`); }
1031
- if (hidden) {
1032
- const others = hidden.filter((h) => h.pid !== selfPid);
1033
- if (others.length) {
1034
- const shown = others.slice(0, 8);
1035
- throw new Error(`${others.length} process(es) may still be this ownership's work (hidden environment, held after the anchor unless its lineage clears it): ${shown.map((h) => `${h.pid}: ${h.why}`).join("; ")}${others.length > shown.length ? `; and ${others.length - shown.length} more (pids ${others.slice(shown.length).map((h) => h.pid).join(", ")})` : ""}`);
1036
- }
1037
- proofs.push("every live process of this user whose environment is hidden started before the anchor or descends from a process that did");
1066
+ try { hidden = probe.hiddenHolders({ owner, anchorMs: skipBefore, recorded }); } catch { hidden = null; }
1067
+ const others = (hidden ?? []).filter((h) => h.pid !== selfPid);
1068
+ if (others.length) {
1069
+ const shown = others.slice(0, 8);
1070
+ proofs.push(`reported, not held: ${others.length} unattributed process(es) of this user with a hidden environment started after the anchor (no recorded lineage, token or snapshot use): ${shown.map((h) => `${h.pid}: ${h.why}`).join("; ")}${others.length > shown.length ? `; and ${others.length - shown.length} more (pids ${others.slice(shown.length).map((h) => h.pid).join(", ")})` : ""}`);
1038
1071
  }
1039
1072
  }
1040
1073
  } else if (!rebooted) {
1041
1074
  throw new Error(`the record names no containment (${JSON.stringify(c)})`);
1042
1075
  }
1043
1076
  if (owner.broker?.submitted) {
1044
- const status = lease({ snapshot: owner.snapshot, handle: owner.handle });
1045
- if (status.state !== "REAPED") throw new Error(`broker work may exist under this handle and its lease is ${status.state} (${status.why})`);
1046
- proofs.push(`broker lease for ${owner.handle} is REAPED`);
1077
+ // Every submission under this handle must be accounted for by NAME, never by the bare handle (GB-21, GB-23, GB-85). The bare handle answers REAPED when at least one
1078
+ // member completed and none is held (rustright snapshot.rs `status`), so it proves only that no lease is held now: a completed sibling never stands in for an
1079
+ // unknown submission. Each recorded sub-handle (broker.handles, recorded before its send) is asked by its exact name and must be one of
1080
+ // - REAPED, as a member of that answer (a durable completion; `spawned: false` for a refusal that ran nothing), or
1081
+ // - UNSENT: the gate itself proved, under the record's lock, that its client never started (broker.unsent), AND the broker holds nothing under it (UNKNOWN, no members).
1082
+ // An UNKNOWN or ACTIVE one, an ambiguous send, a start that may have run, keeps the record; nothing is counted complete on the absence of a completion.
1083
+ const handles = [...new Set(Array.isArray(owner.broker.handles) ? owner.broker.handles : [])];
1084
+ const unsent = owner.broker.unsent && typeof owner.broker.unsent === "object" ? owner.broker.unsent : {};
1085
+ const waitOptions = waitMs > 0 ? { waitMs } : {};
1086
+ const settled = (sub) => unsent[sub] && typeof unsent[sub].reason === "string";
1087
+ const everyUnsent = handles.length > 0 && handles.every(settled);
1088
+ const status = lease({ snapshot: owner.snapshot, handle: owner.handle, ...waitOptions });
1089
+ if (status.state === "ACTIVE" || (status.state !== "REAPED" && !everyUnsent)) throw new Error(`broker work may exist under this handle and its lease is ${status.state} (${status.why})`);
1090
+ if (status.state === "REAPED") proofs.push(`broker lease for ${owner.handle} is REAPED`);
1091
+ // GB-23: the answer's own member rows account for each recorded sub-handle too: a recorded one that the broker lists and does not list REAPED keeps ownership, whatever the aggregate said.
1092
+ if (Array.isArray(status.members)) for (const sub of handles) { const row = status.members.find((m) => m.handle === sub); if (row && row.state !== "REAPED") throw new Error(`the broker lists sub-handle ${sub} as ${row.state} among the members of ${owner.handle}`); }
1093
+ let neverStarted = 0;
1094
+ for (const sub of handles) {
1095
+ if (typeof sub !== "string" || !sub.startsWith(`${owner.handle}#`) || sub.length <= owner.handle.length + 1) throw new Error(`the record lists ${JSON.stringify(sub)}, which is not a sub-handle of ${owner.handle}`);
1096
+ const one = lease({ snapshot: owner.snapshot, handle: sub, ...waitOptions });
1097
+ const member = Array.isArray(one.members) ? one.members.find((m) => m.handle === sub) : null;
1098
+ if (one.state === "REAPED") {
1099
+ if (Array.isArray(one.members) && !(member && member.state === "REAPED")) throw new Error(`the broker answers REAPED for ${sub} but lists no completed submission under that name`);
1100
+ continue;
1101
+ }
1102
+ if (settled(sub) && one.state === "UNKNOWN" && Array.isArray(one.members) && one.members.length === 0) { proofs.push(`${sub} never started (${unsent[sub].reason}) and the broker holds nothing under it`); neverStarted += 1; continue; }
1103
+ throw new Error(`broker work may exist under sub-handle ${sub} and its lease is ${one.state} (${one.why})`);
1104
+ }
1105
+ if (handles.length) proofs.push(neverStarted === 0 ? `broker lease for each of ${handles.length} sub-handle(s) is REAPED` : `every one of ${handles.length} sub-handle(s) is accounted for: ${handles.length - neverStarted} REAPED, ${neverStarted} proven never started`);
1047
1106
  }
1048
1107
  return proofs;
1049
1108
  }
1050
1109
 
1110
+ /**
1111
+ * Whether the claimant a standing `.reclaim` body names is `"dead"` (proven), `"live"` or `"unknown"`. The body is the claimant's own owner record, so the same identity
1112
+ * rules as the record's judge apply: another host or an unreadable body is unknown; a reboot since the claimant started, a gone pid, or a pid that now runs a process that
1113
+ * started at another time is dead; the same process still alive is live.
1114
+ */
1115
+ export function judgeClaimant(text, { probe, host }) {
1116
+ let rec = null; try { rec = JSON.parse(text ?? ""); } catch { rec = null; }
1117
+ if (rec?.schema !== OWNERSHIP_SCHEMA || typeof rec.handle !== "string" || !Number.isInteger(rec.pid) || rec.pid <= 0 || rec.pid > 0x7fffffff || !Number.isFinite(rec.startedMs)) return "unknown";
1118
+ if (rec.host !== host) return "unknown";
1119
+ try { const boot = probe.bootTime?.(); if (Number.isFinite(boot) && rec.startedMs < boot) return "dead"; } catch { /* the pid proof below decides */ }
1120
+ let now; try { now = probe.identity(rec.pid); } catch { return "unknown"; }
1121
+ if (!now.alive) return "dead";
1122
+ if (!Number.isFinite(now.startedMs)) return "unknown";
1123
+ return sameStart(now.startedMs, rec.startedMs) ? "live" : "dead";
1124
+ }
1125
+
1051
1126
  /**
1052
1127
  * Why the record `observed` may be reclaimed (`{ owner, proofs }`), or an OwnershipBusy throw naming what is still live or
1053
1128
  * unknown. Pure apart from the probes.
@@ -1072,7 +1147,7 @@ export function judgeOwner(observed, { file, probe, lease, host = os.hostname()
1072
1147
 
1073
1148
  /**
1074
1149
  * Acquires the host's build ownership (or joins it, when RIGHTKIT_BUILD_OWNERSHIP names the current record). Returns
1075
- * `{ handle, file, joined, reclaimed, owner, markBrokerSubmit(), held(), release() }`. Throws OwnershipBusy while another build
1150
+ * `{ handle, file, joined, reclaimed, owner, markBrokerSubmit(callHandle), held(), release() }`. Throws OwnershipBusy while another build
1076
1151
  * owns the host, its liveness is unknown, or this gate cannot be contained.
1077
1152
  */
1078
1153
  export function acquireBuildOwnership({
@@ -1109,7 +1184,7 @@ export function acquireBuildOwnership({
1109
1184
  if (!live.alive || (Number.isFinite(owner.startedMs) && Number.isFinite(live.startedMs) && !sameStart(live.startedMs, owner.startedMs))) {
1110
1185
  throw new OwnershipBusy(`build ownership ${file}: ${OWNERSHIP_ENV}=${inherited} names a record whose owner pid ${owner.pid} is no longer the process that took it; a nested gate joins only a live owner's record`);
1111
1186
  }
1112
- return { handle: inherited, file, joined: true, reclaimed: null, owner, markBrokerSubmit: () => markBrokerSubmit(file, inherited), held: () => holds(file, inherited), release: () => ({ released: false, joined: true }) };
1187
+ return { handle: inherited, file, joined: true, reclaimed: null, owner, markBrokerSubmit: (callHandle) => markBrokerSubmit(file, inherited, callHandle), markBrokerUnsent: (callHandle, reason) => markBrokerUnsent(file, inherited, callHandle, reason), held: () => holds(file, inherited), release: () => ({ released: false, joined: true }) };
1113
1188
  }
1114
1189
  throw new OwnershipBusy(`build ownership ${file}: ${OWNERSHIP_ENV}=${inherited} is not the current owner; a nested gate never takes ownership of its own`);
1115
1190
  }
@@ -1145,12 +1220,13 @@ export function acquireBuildOwnership({
1145
1220
  if (!holds(file, handle)) return { released: false, why: "no longer the owner" };
1146
1221
  let proofs;
1147
1222
  // The record as it is now (a broker submit may have been marked since it was written).
1148
- try { proofs = coveredWorkGone(JSON.parse(readText(file)), { probe, lease, file, selfPid: process.pid }); }
1223
+ try { proofs = coveredWorkGone(JSON.parse(readText(file)), { probe, lease, file, selfPid: process.pid, waitMs: RELEASE_LEASE_WAIT_MS }); }
1149
1224
  catch (e) { return { released: false, why: `left for the next gate to reclaim: ${e.message}` }; }
1150
1225
  finally { if (exportEnv && process.env[OWNERSHIP_ENV] === handle) { delete process.env[OWNERSHIP_ENV]; delete process.env[OWNERSHIP_FILE_ENV]; } }
1151
1226
  // Only a release that proved zero readers leaves the marker; a held one leaves none (and keeps the record).
1152
1227
  writeCleanMarker(file, record);
1153
1228
  fs.rmSync(file, { force: true });
1229
+ fs.rmSync(markLockFile(file), { force: true });
1154
1230
  fs.rmSync(childrenDir(file, handle), { recursive: true, force: true });
1155
1231
  return { released: true, proofs };
1156
1232
  };
@@ -1173,7 +1249,7 @@ export function acquireBuildOwnership({
1173
1249
  catch (e) { if (holds(file, handle)) fs.rmSync(file, { force: true }); throw new OwnershipBusy(`build ownership ${file}: the containment could not be recorded (${e.code ?? e.message}: ${e.message})`); }
1174
1250
  }
1175
1251
  if (exportEnv) { process.env[OWNERSHIP_ENV] = handle; process.env[OWNERSHIP_FILE_ENV] = file; }
1176
- return { handle, file, joined: false, reclaimed, owner: record, contained: platform === "win32" ? record.containment.contained === true : true, markBrokerSubmit: () => markBrokerSubmit(file, handle), held: () => holds(file, handle), release };
1252
+ return { handle, file, joined: false, reclaimed, owner: record, contained: platform === "win32" ? record.containment.contained === true : true, markBrokerSubmit: (callHandle) => markBrokerSubmit(file, handle, callHandle), markBrokerUnsent: (callHandle, reason) => markBrokerUnsent(file, handle, callHandle, reason), held: () => holds(file, handle), release };
1177
1253
  };
1178
1254
  if (publish(file, body)) return done(null);
1179
1255
  const observed = readText(file);
@@ -1185,16 +1261,27 @@ export function acquireBuildOwnership({
1185
1261
  record.freeHost = false;
1186
1262
  const reclaimBody = JSON.stringify(record);
1187
1263
  const reclaim = `${file}.reclaim`;
1188
- if (!publish(reclaim, reclaimBody)) throw new OwnershipBusy(`build ownership ${file} is being reclaimed by another process (remove ${reclaim} only if no gate is running)`);
1264
+ // GB-56: a `.reclaim` already there is judged by the identity its body carries (the claimant's owner record: pid, start, host, boot), never taken for a live claimant on its
1265
+ // existence alone. A claimant PROVEN dead (its pid is gone, the pid now runs a process that started at another time, or the host rebooted since) is removed, compare-by-content,
1266
+ // and the claim retried once; a live, unreadable or unprovable claimant keeps the lock. Bounded: one retry, so two reclaimers never loop.
1267
+ if (!publish(reclaim, reclaimBody)) {
1268
+ const standing = readText(reclaim);
1269
+ const verdict = judgeClaimant(standing, { probe, host: os.hostname() });
1270
+ if (verdict === "dead" && readText(reclaim) === standing) { fs.rmSync(reclaim, { force: true }); }
1271
+ if (verdict !== "dead" || !publish(reclaim, reclaimBody)) throw new OwnershipBusy(`build ownership ${file} is being reclaimed by another process (${verdict === "dead" ? "its claim was dropped but another gate took it first" : verdict === "live" ? "a live gate holds the reclaim claim" : "the reclaim claim cannot be proven dead"}: remove ${reclaim} only if no gate is running)`);
1272
+ }
1189
1273
  try {
1274
+ hooks.afterClaim?.(reclaim);
1190
1275
  if (readText(file) !== observed) throw new OwnershipBusy(`build ownership ${file} changed before it could be reclaimed`);
1191
1276
  fs.rmSync(file, { force: true });
1277
+ fs.rmSync(markLockFile(file), { force: true });
1192
1278
  fs.rmSync(holderFile(file, judged.owner.handle), { force: true });
1193
1279
  fs.rmSync(holderScriptFile(holderFile(file, judged.owner.handle)), { force: true });
1194
1280
  fs.rmSync(childrenDir(file, judged.owner.handle), { recursive: true, force: true });
1195
1281
  if (!publish(file, reclaimBody)) throw new OwnershipBusy(`build ownership ${file} was taken by another process`);
1196
1282
  } finally {
1197
- fs.rmSync(reclaim, { force: true });
1283
+ // Token-checked: only this reclaim's own claim is removed (a successor's claim, published after this one was judged dead, stays).
1284
+ if (readText(reclaim) === reclaimBody) fs.rmSync(reclaim, { force: true });
1198
1285
  }
1199
1286
  return done({ from: judged.owner.handle, pid: judged.owner.pid, proofs: judged.proofs });
1200
1287
  }
@@ -1209,23 +1296,4 @@ function rewriteRecord(file, handle, change) {
1209
1296
  try { fs.renameSync(tmp, file); } finally { fs.rmSync(tmp, { force: true }); }
1210
1297
  }
1211
1298
 
1212
- function holds(file, handle) {
1213
- try { return JSON.parse(fs.readFileSync(file, "utf8")).handle === handle; } catch { return false; }
1214
- }
1215
1299
 
1216
- /**
1217
- * Records, before the first broker submit, that broker work may exist under this handle; from then on the record is reclaimed
1218
- * only on the broker's REAPED. Replaces the record atomically and only while it is still this handle's.
1219
- */
1220
- function markBrokerSubmit(file, handle) {
1221
- const text = readText(file);
1222
- let owner = null; try { owner = JSON.parse(text ?? ""); } catch { owner = null; }
1223
- if (owner?.handle !== handle) throw new OwnershipBusy(`build ownership ${file} is no longer held by ${handle}; no broker request may be submitted`);
1224
- if (owner.broker?.submitted) return owner;
1225
- const next = { ...owner, broker: { ...(owner.broker ?? {}), submitted: true, at: new Date().toISOString() } };
1226
- const tmp = `${file}.${crypto.randomUUID()}.tmp`;
1227
- fs.writeFileSync(tmp, JSON.stringify(next), { flag: "wx", mode: 0o600 });
1228
- try { fs.renameSync(tmp, file); } finally { fs.rmSync(tmp, { force: true }); }
1229
- if (!holds(file, handle)) throw new OwnershipBusy(`build ownership ${file} changed while recording a broker submit`);
1230
- return next;
1231
- }
package/build-release.mjs CHANGED
@@ -55,6 +55,7 @@ import { ANDROID_RECEIPT_NAME, androidSealEntry, validateAndroidTarget, verifyAn
55
55
  import { IOS_RECEIPT_NAME, iosSealEntry, verifyIosReceipt } from "./ios-package-recipe.mjs";
56
56
  import { expandIosPackageTarget } from "./package-recipe-targets.mjs";
57
57
  import { assertUpdaterKeyId, assertUpdaterSignature, isSuiteUpdaterApp, resolveUpdaterTrust } from "./github-updater-release.mjs";
58
+ import { platformDirOf } from "./platform-dir.mjs";
58
59
 
59
60
  // The iOS lane's own packaging code, kept out of the win/mac fingerprint for the same reason as the Android one.
60
61
  const IOS_PIPELINE_FINGERPRINT = createHash("sha256")
@@ -266,7 +267,7 @@ try {
266
267
  : collectToolVersions(config.packageManager ?? "pnpm", appRoot);
267
268
  const inputHashes = hashInputs(requiredInputs);
268
269
  const releaseId = `${config.app}-${config.version}-${shortCommit}`;
269
- const platformDir = platform === "win" ? "windows" : platform === "android" ? "android" : platform === "ios" ? "ios" : "mac";
270
+ const platformDir = platformDirOf(platform);
270
271
  const stateRoot = path.join(vaultRoot, "state", releaseId, platformDir);
271
272
  const signingIdentity = platform === "win" ? {
272
273
  contract: target.signingContract ?? "<missing>",
@@ -611,7 +612,7 @@ async function admit() {
611
612
  throw error;
612
613
  }
613
614
  // A fresh build asks the registries once, here; a resume or a re-run over a sealed release reuses its recorded snapshot instead.
614
- const platformName = platform === "win" ? "windows" : platform;
615
+ const platformName = platformDirOf(platform);
615
616
  const sealed = config?.app && config?.version
616
617
  && existsSync(path.join(repoRoot, ".right-release", "sealed", `${config.app}-${config.version}-${commit.slice(0, 8)}`, platformName, "release-manifest.json"));
617
618
  const reuse = Boolean(resumeFrom) || Boolean(sealed);
package/cache-command.mjs CHANGED
@@ -30,13 +30,18 @@ export async function runCacheCommand(args = process.argv.slice(2), { env = proc
30
30
  const [command, ...rest] = args;
31
31
  const json = rest.includes("--json");
32
32
  const platform = platformFrom(env);
33
- const cacheRoot = resolveSharedCacheRoot({ platform, env });
33
+ // GB-65: the broker-owned route is decided BEFORE the Cache V2 root is resolved. A broker Mac sets no RIGHT_RELEASE_CACHE_ROOT (the broker
34
+ // owns the target), and resolving the unused root first threw for all three commands before the intended skip. Arguments are still
35
+ // validated, and nothing is inspected, migrated or pruned on this route.
34
36
  if (BROKER_GATED_COMMANDS.has(command) && isBrokerHost(env)) {
35
- const result = { schema: 1, command, cacheRoot, brokerManaged: true, skipped: true, message: BROKER_MANAGED_MESSAGE };
37
+ rejectUnknown(rest.filter((value, i) => !(command === "migrate" && (value === "--config" || rest[i - 1] === "--config"))), new Set(command === "status" ? ["--json"] : ["--json", "--dry-run", "--apply"]));
38
+ if (command !== "status" && rest.includes("--dry-run") && rest.includes("--apply")) throw new Error(`cache ${command} accepts either --dry-run or --apply`);
39
+ const result = { schema: 1, command, cacheRoot: null, brokerManaged: true, skipped: true, message: BROKER_MANAGED_MESSAGE };
36
40
  if (json) stdout(JSON.stringify(result));
37
41
  else stdout(BROKER_MANAGED_MESSAGE);
38
42
  return result;
39
43
  }
44
+ const cacheRoot = resolveSharedCacheRoot({ platform, env });
40
45
  if (command === "status") {
41
46
  rejectUnknown(rest, new Set(["--json"]));
42
47
  const snapshot = inspectCache({ cacheRoot });
@@ -0,0 +1,96 @@
1
+ // GB-73 (BROKER-CONTRACT section 16): the candidate build receipt the gate writes from its SNAPSHOT build, which RustRight's installer takes instead of a clean primary checkout:
2
+ // rustright-install stage|cutover ... --candidate-receipt <file> --candidate-receipt-sha256 <digest>
3
+ // where the file is exactly
4
+ // { "schema": "rustright.candidate-build-receipt.v1", "commit", "tree", "source": "snapshot", "artifacts": { "broker", "client" } }
5
+ // (full lowercase commit and tree ids, the sha256 of the broker and client binaries being installed). The installer accepts it only when the file hashes to the DIGEST THE GATE RECORDED (the
6
+ // digest comes from the gate's record, never from the file), the commit is the asked-for one, the tree is that commit's own tree in the checkout's object database, the artifact digests are the
7
+ // ones being installed and the commit is on origin/main; a tampered receipt, a wrong tree, other binaries and an off-origin commit each refuse before effects. So this module writes the receipt
8
+ // deterministically, records its digest apart from it, hands the installer its two arguments, and verifies a receipt the way the installer does before the gate hands it on.
9
+ import crypto from "node:crypto";
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { writeFileAtomic } from "./atomic-write.mjs";
13
+
14
+ export const CANDIDATE_RECEIPT_SCHEMA = "rustright.candidate-build-receipt.v1";
15
+ export const CANDIDATE_RECORD_SCHEMA = "rightkit.candidate-receipt-record/1";
16
+ export const CANDIDATE_RECEIPT_FILE = "candidate-build-receipt.json";
17
+ export const CANDIDATE_RECORD_FILE = "candidate-build-receipt.record.json";
18
+ const OBJECT_ID = /^[0-9a-f]{40}$/;
19
+ const DIGEST = /^[0-9a-f]{64}$/;
20
+ const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
21
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
22
+
23
+ /** The receipt object for a snapshot build of `commit` (whose tree is `tree`): exactly RustRight's fields, in a fixed order. Refuses anything that is not a full lowercase id or digest. */
24
+ export function buildCandidateReceipt({ commit, tree, brokerSha256, clientSha256 }) {
25
+ const problems = [];
26
+ if (!OBJECT_ID.test(commit ?? "")) problems.push("commit must be a full lowercase object id");
27
+ if (!OBJECT_ID.test(tree ?? "")) problems.push("tree must be a full lowercase object id");
28
+ if (!DIGEST.test(brokerSha256 ?? "")) problems.push("the broker artifact digest must be 64 lowercase hex characters");
29
+ if (!DIGEST.test(clientSha256 ?? "")) problems.push("the client artifact digest must be 64 lowercase hex characters");
30
+ if (problems.length) throw Object.assign(new Error(`the candidate build receipt cannot be written: ${problems.join("; ")}`), { code: "RK_CANDIDATE_RECEIPT_INVALID" });
31
+ return { schema: CANDIDATE_RECEIPT_SCHEMA, commit, tree, source: "snapshot", artifacts: { broker: brokerSha256, client: clientSha256 } };
32
+ }
33
+
34
+ /** The bytes the receipt is written as and hashed over: its JSON, one line, a final newline. */
35
+ export const candidateReceiptBytes = (receipt) => Buffer.from(`${JSON.stringify(receipt)}\n`, "utf8");
36
+
37
+ /** The digest of one built artifact (a regular file), or a typed refusal. */
38
+ export function artifactDigest(file, { readFile = fs.readFileSync, stat = fs.statSync } = {}) {
39
+ if (typeof file !== "string" || !path.isAbsolute(file)) throw Object.assign(new Error(`the artifact ${JSON.stringify(file)} must be an absolute path`), { code: "RK_CANDIDATE_RECEIPT_INVALID" });
40
+ let meta; try { meta = stat(file); } catch (e) { throw Object.assign(new Error(`the artifact ${file} cannot be read (${e.code ?? e.message})`), { code: "RK_CANDIDATE_RECEIPT_INVALID" }); }
41
+ if (!meta.isFile()) throw Object.assign(new Error(`the artifact ${file} is not a regular file`), { code: "RK_CANDIDATE_RECEIPT_INVALID" });
42
+ return sha256(readFile(file));
43
+ }
44
+
45
+ /**
46
+ * Writes the receipt and, apart from it, the gate's record of its digest, into `dir` (created, owner-only, each written atomically). Returns the record: `{ schema, file, sha256, commit, tree,
47
+ * artifacts, recordFile }`. A directory that already holds a receipt for other bytes is refused: a candidate's receipt is written once.
48
+ */
49
+ export function writeCandidateReceipt({ dir, receipt, now = Date.now(), write = writeFileAtomic, read = (f) => fs.readFileSync(f), exists = fs.existsSync }) {
50
+ const file = path.join(dir, CANDIDATE_RECEIPT_FILE); const recordFile = path.join(dir, CANDIDATE_RECORD_FILE);
51
+ const bytes = candidateReceiptBytes(receipt);
52
+ if (exists(file) && sha256(read(file)) !== sha256(bytes)) throw Object.assign(new Error(`${file} already holds a different candidate build receipt; a candidate's receipt is written once`), { code: "RK_CANDIDATE_RECEIPT_EXISTS" });
53
+ write(file, bytes, { mode: 0o600 });
54
+ const record = { schema: CANDIDATE_RECORD_SCHEMA, file, sha256: sha256(bytes), commit: receipt.commit, tree: receipt.tree, artifacts: { ...receipt.artifacts }, recordedAt: new Date(now).toISOString() };
55
+ write(recordFile, Buffer.from(`${JSON.stringify(record)}\n`, "utf8"), { mode: 0o600 });
56
+ return { ...record, recordFile };
57
+ }
58
+
59
+ /** The installer's two arguments for a receipt record: the file and the digest the GATE recorded. */
60
+ export function candidateReceiptArgs(record) {
61
+ if (!record || typeof record.file !== "string" || !DIGEST.test(record.sha256 ?? "")) throw Object.assign(new Error("a candidate receipt record names its file and its sha256"), { code: "RK_CANDIDATE_RECEIPT_INVALID" });
62
+ return ["--candidate-receipt", record.file, "--candidate-receipt-sha256", record.sha256];
63
+ }
64
+
65
+ /**
66
+ * Whether the receipt at `file` is acceptable for installing `commit` / `tree` with the binaries whose digests are `brokerSha256` and `clientSha256`, judged as RustRight's installer judges it
67
+ * (provenance.rs): the file hashes to `expectedSha256` (the gate's recorded digest, never a value read from the file), it is exactly {schema, commit, tree, source, artifacts:{broker, client}} with the
68
+ * v1 schema and source "snapshot", the commit and tree are the asked-for ones and the artifact digests are the ones being installed. Returns the problems; none means acceptable. (That the commit is on
69
+ * origin/main and the tree is the commit's own is the installer's check, in the checkout's object database.)
70
+ */
71
+ export function candidateReceiptProblems({ file, expectedSha256, commit, tree, brokerSha256, clientSha256, readFile = fs.readFileSync }) {
72
+ let bytes; try { bytes = readFile(file); } catch (e) { return [`the receipt ${file} cannot be read (${e.code ?? e.message})`]; }
73
+ const problems = [];
74
+ if (!DIGEST.test(expectedSha256 ?? "")) return ["the gate's recorded digest is missing or malformed"];
75
+ if (sha256(bytes) !== expectedSha256) problems.push(`the file hashes to ${sha256(bytes)}, not the digest the gate recorded (${expectedSha256}): it was changed after it was written`);
76
+ let doc; try { doc = JSON.parse(Buffer.from(bytes).toString("utf8")); } catch { return [...problems, "the receipt is not JSON"]; }
77
+ if (!isObject(doc) || Object.keys(doc).sort().join() !== "artifacts,commit,schema,source,tree") problems.push("the receipt is not exactly {schema, commit, tree, source, artifacts}");
78
+ if (doc?.schema !== CANDIDATE_RECEIPT_SCHEMA) problems.push(`the schema is ${JSON.stringify(doc?.schema)}, not ${CANDIDATE_RECEIPT_SCHEMA}`);
79
+ if (doc?.source !== "snapshot") problems.push(`the source is ${JSON.stringify(doc?.source)}, not "snapshot"`);
80
+ if (doc?.commit !== commit) problems.push(`the receipt is for commit ${String(doc?.commit).slice(0, 12)}, not ${String(commit).slice(0, 12)}`);
81
+ if (doc?.tree !== tree) problems.push(`the receipt names tree ${String(doc?.tree).slice(0, 12)}, not the commit's ${String(tree).slice(0, 12)}`);
82
+ const artifacts = doc?.artifacts;
83
+ if (!isObject(artifacts) || Object.keys(artifacts).sort().join() !== "broker,client") problems.push("artifacts is not exactly {broker, client}");
84
+ else {
85
+ if (artifacts.broker !== brokerSha256) problems.push("the broker binary being installed is not the one the receipt names");
86
+ if (artifacts.client !== clientSha256) problems.push("the client binary being installed is not the one the receipt names");
87
+ }
88
+ return problems;
89
+ }
90
+
91
+ /** The gate's record read back (never the receipt's own digest): `{ record }` or `{ problem }`. */
92
+ export function readCandidateRecord(recordFile, { readFile = fs.readFileSync } = {}) {
93
+ let doc; try { doc = JSON.parse(readFile(recordFile, "utf8")); } catch (e) { return { problem: `the candidate receipt record ${recordFile} cannot be read (${e.code ?? e.message})` }; }
94
+ if (doc?.schema !== CANDIDATE_RECORD_SCHEMA || typeof doc.file !== "string" || !DIGEST.test(doc.sha256 ?? "") || !OBJECT_ID.test(doc.commit ?? "") || !OBJECT_ID.test(doc.tree ?? "")) return { problem: `${recordFile} is not a ${CANDIDATE_RECORD_SCHEMA}` };
95
+ return { record: doc };
96
+ }
@@ -0,0 +1,29 @@
1
+ // `rightkit-gate candidate-receipt --commit <full sha> --broker <file> --client <file> [--out <dir>]` (GB-73): writes the candidate build receipt (candidate-build-receipt.mjs) for the
2
+ // binaries a gate SNAPSHOT build of that commit produced, records its digest apart from it, and prints one JSON line with the record and the installer's two arguments
3
+ // (`--candidate-receipt <file> --candidate-receipt-sha256 <digest>`). The commit's tree is read from the repository the gate runs in; the artifact digests are computed here, never taken from a flag.
4
+ import path from "node:path";
5
+ import { gateRunsRoot } from "./managed-roots.mjs";
6
+ import { artifactDigest, buildCandidateReceipt, candidateReceiptArgs, writeCandidateReceipt } from "./candidate-build-receipt.mjs";
7
+
8
+ export const CANDIDATE_RECEIPT_USAGE = "usage: rightkit-gate candidate-receipt --commit <full lowercase sha> --broker <broker binary> --client <client binary> [--out <dir>] (the binaries a gate snapshot build of that commit produced)";
9
+
10
+ export async function candidateReceiptCommand(o, args, { artifact = artifactDigest, write = writeCandidateReceipt } = {}) {
11
+ const take = (name) => { const i = args.indexOf(name); return i < 0 ? undefined : args[i + 1]; };
12
+ const known = new Set(["--commit", "--broker", "--client", "--out"]);
13
+ const stray = args.filter((a, i) => (a.startsWith("--") ? !known.has(a) : !known.has(args[i - 1] ?? "")));
14
+ const commit = take("--commit"); const broker = take("--broker"); const client = take("--client");
15
+ if (stray.length || !commit || !broker || !client) { o.err(`rightkit-gate: ${CANDIDATE_RECEIPT_USAGE}`); return 2; }
16
+ const fail = (e) => { o.err(`rightkit-gate: ${e.code ?? "RK_CANDIDATE_RECEIPT_INVALID"}: ${e.message}`); return 1; };
17
+ let record;
18
+ try {
19
+ const resolved = o.spawn("git", ["rev-parse", "--verify", "--quiet", `${commit}^{tree}`], { cwd: o.cwd, encoding: "utf8", env: o.env });
20
+ const tree = String(resolved?.stdout ?? "").trim();
21
+ if (resolved?.status !== 0 || !/^[0-9a-f]{40}$/.test(tree)) throw Object.assign(new Error(`the commit ${commit} is not in this repository, so its tree cannot be read`), { code: "RK_CANDIDATE_RECEIPT_INVALID" });
22
+ const receipt = buildCandidateReceipt({ commit, tree, brokerSha256: artifact(broker), clientSha256: artifact(client) });
23
+ const out = take("--out") ?? path.join(gateRunsRoot({ env: o.env, platform: o.platform, home: o.env.HOME || o.env.USERPROFILE || undefined }), "candidate-receipts", commit);
24
+ if (!path.isAbsolute(out)) throw Object.assign(new Error(`--out ${out} must be an absolute directory`), { code: "RK_CANDIDATE_RECEIPT_INVALID" });
25
+ record = write({ dir: out, receipt });
26
+ } catch (e) { return fail(e); }
27
+ o.out(JSON.stringify({ ...record, installerArgs: candidateReceiptArgs(record) }));
28
+ return 0;
29
+ }
@@ -2,7 +2,7 @@ import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import path from "node:path";
4
4
  import { spawnSync } from "./background-process.mjs";
5
- import { directPlatform, managedRuntimeClient, runCargoSync } from "./managed-cargo.mjs";
5
+ import { directPlatform, linuxBrokerDeclared, managedRuntimeClient, runCargoSync } from "./managed-cargo.mjs";
6
6
 
7
7
  const SKIP_DIRECTORIES = new Set([".audit", ".cache", ".git", ".claude", ".right-release", ".worktrees", "node_modules", "target", "vendor"]);
8
8
  const CRATES_IO_SOURCES = new Set([
@@ -30,8 +30,9 @@ export function managedCargoShimOnPath(env = process.env, platform = process.pla
30
30
  }
31
31
 
32
32
  export function brokerManagedHost(env = process.env, platform = process.platform, exists = existsSync) {
33
- if (directPlatform(platform)) return false; // Linux is never broker-managed, whatever socket or control root the env names
34
- return Boolean(managedRuntimeClient(env, platform, exists)) || Boolean(env.RIGHTKIT_BUILD_BROKER_SOCKET) || managedCargoShimOnPath(env, platform, exists);
33
+ // A Linux host is broker-managed only through its explicit declared route (managed-cargo linuxBrokerDeclared), whatever socket or control root the env names.
34
+ if (directPlatform(platform)) return linuxBrokerDeclared(env, platform);
35
+ return Boolean(managedRuntimeClient(env, platform, exists)) || Boolean(env.RUSTRIGHT_BUILD_BROKER_SOCKET || env.RIGHTKIT_BUILD_BROKER_SOCKET) || managedCargoShimOnPath(env, platform, exists);
35
36
  }
36
37
 
37
38
  export function isolatedCargoMetadataEnv(cargoHome, env = process.env, platform = process.platform, exists = existsSync) {
package/cargo-ledger.mjs CHANGED
@@ -48,7 +48,7 @@ export function readLedger(dir, token) {
48
48
  const entries = [...byId.values()];
49
49
  for (const e of entries) {
50
50
  if (OK_STATUSES.includes(e.status)) continue;
51
- const call = `cargo ${(e.argv ?? []).join(" ")}`.trim();
51
+ const call = `${e.adapter ?? "cargo"} ${(e.argv ?? []).join(" ")}`.trim();
52
52
  problems.push(e.status === "pending" ? `${call} (from ${e.cwd}) never completed: the wrapper recorded it and no outcome, so it is unverified` : `${call} (from ${e.cwd}) was ${e.status}${e.code ? ` (${e.code})` : ""}${e.message ? `: ${e.message}` : ""}`);
53
53
  }
54
54
  return { entries, problems };
@@ -6,7 +6,7 @@
6
6
  // legal: { cargoNotices: { output: "legal/THIRD_PARTY_CARGO_LICENSES.html", template: "legal/about.hbs",
7
7
  // config?: "legal/about.toml", manifestPath?: "src-tauri/Cargo.toml", features?: ["a"], allFeatures?: true } }
8
8
  //
9
- // `output`, `template`, `config` and `manifestPath` are app-relative and may not leave the app. The run is `cargo about generate --locked
9
+ // `output`, `template`, `config` and `manifestPath` are app-relative and may not leave the app. The run is `cargo about generate --frozen
10
10
  // --fail [--config <config>] [--manifest-path <path>] [--all-features | --features a,b] <template>`; cargo-about writes the rendered
11
11
  // appendix to stdout, which is normalised to LF and one trailing newline so the same lockfile always produces the same bytes. `--fail`
12
12
  // makes an unaccepted license or an unresolved clarification an error instead of a warning. `--check` writes nothing and exits 1 when
@@ -43,7 +43,7 @@ export function resolveCargoNoticeOptions(config, flags = {}) {
43
43
  /** The `cargo about` argument list (after the managed client adds `cargo`). */
44
44
  export function cargoAboutArgs(options) {
45
45
  return [
46
- "about", "generate", "--locked", "--fail",
46
+ "about", "generate", "--frozen", "--fail",
47
47
  ...(options.config ? ["--config", options.config] : []),
48
48
  ...(options.manifestPath ? ["--manifest-path", options.manifestPath] : []),
49
49
  ...(options.allFeatures ? ["--all-features"] : options.features.length ? ["--features", options.features.join(",")] : []),