@junghanacs/entwurf 0.22.0 → 0.23.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.
Files changed (135) hide show
  1. package/AGENTS.md +4 -1
  2. package/BASELINE.md +1 -0
  3. package/CHANGELOG.md +514 -0
  4. package/DELIVERY.md +10 -2
  5. package/README.md +43 -4
  6. package/VERIFY.md +47 -0
  7. package/docs/acp-backend-rail.md +96 -17
  8. package/docs/setup-clean-host.md +11 -1
  9. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +39 -40
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +2 -1
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-declaration.js +609 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +95 -56
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +40 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +6 -2
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +7 -1
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +67 -2
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +9 -1
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +24 -0
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +2 -2
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +1 -1
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +423 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +98 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +1018 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +213 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +46 -273
  27. package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +109 -0
  28. package/mcp/entwurf-bridge/src/index.ts +41 -41
  29. package/mcp/entwurf-bridge/tsconfig.build.json +1 -0
  30. package/package.json +9 -8
  31. package/pi-extensions/entwurf-control.ts +91 -52
  32. package/pi-extensions/lib/acp/acp-client.ts +3 -3
  33. package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
  34. package/pi-extensions/lib/acp/backend.ts +4 -4
  35. package/pi-extensions/lib/acp/event-mapper.ts +5 -5
  36. package/pi-extensions/lib/acp/tool-surface.ts +2 -1
  37. package/pi-extensions/lib/codex-declaration.js +612 -0
  38. package/pi-extensions/lib/codex-fresh-preflight.ts +107 -51
  39. package/pi-extensions/lib/entwurf-control-rpc.ts +46 -0
  40. package/pi-extensions/lib/entwurf-fact-provider.ts +13 -2
  41. package/pi-extensions/lib/entwurf-facts.ts +14 -1
  42. package/pi-extensions/lib/entwurf-peer-observe.ts +72 -2
  43. package/pi-extensions/lib/entwurf-peers-render.ts +9 -1
  44. package/pi-extensions/lib/entwurf-self-address.ts +33 -0
  45. package/pi-extensions/lib/entwurf-v2-contract.ts +2 -2
  46. package/pi-extensions/lib/entwurf-v2-lock.ts +1 -1
  47. package/pi-extensions/lib/entwurf-v2-runner.ts +1 -1
  48. package/pi-extensions/lib/fresh-call-composition.ts +493 -0
  49. package/pi-extensions/lib/fresh-call-dispatch.ts +142 -0
  50. package/pi-extensions/lib/herdr-fresh-call.ts +1378 -0
  51. package/pi-extensions/lib/herdr-placement.ts +276 -0
  52. package/pi-extensions/lib/mux-fresh-call.ts +95 -301
  53. package/run.sh +588 -204
  54. package/scripts/check-acp-usage-accounting.ts +9 -9
  55. package/scripts/check-codex-birth-hook.ts +187 -0
  56. package/scripts/check-entwurf-control-rpc.ts +138 -0
  57. package/scripts/check-entwurf-fact-provider.ts +4 -2
  58. package/scripts/check-entwurf-peers-surface.ts +3 -1
  59. package/scripts/check-entwurf-self-address.ts +68 -0
  60. package/scripts/check-fresh-call-dispatch.ts +301 -0
  61. package/scripts/check-gate-qualification.ts +17 -4
  62. package/scripts/check-herdr-activation.ts +1293 -0
  63. package/scripts/check-herdr-fresh-call.ts +1250 -0
  64. package/scripts/check-herdr-placement.ts +383 -0
  65. package/scripts/check-herdr-plugin-build.ts +842 -0
  66. package/scripts/check-herdr-plugin-profile.ts +371 -0
  67. package/scripts/check-herdr-plugin.ts +523 -0
  68. package/scripts/check-herdr-runtime-bootstrap.ts +1264 -0
  69. package/scripts/check-herdr-sandbox.ts +456 -0
  70. package/scripts/check-herdr-supply.ts +184 -0
  71. package/scripts/check-mux-launcher-fence.ts +138 -0
  72. package/scripts/check-peer-facts.ts +331 -0
  73. package/scripts/check-pi-launch.ts +358 -0
  74. package/scripts/check-release-gate-outcomes.ts +19 -0
  75. package/scripts/check-typing-call-fence.ts +296 -0
  76. package/scripts/codex-birth-doctor.sh +159 -16
  77. package/scripts/codex-birth-install.sh +178 -72
  78. package/scripts/codex-birth-uninstall.sh +162 -7
  79. package/scripts/fixtures/herdr-supply.json +29 -0
  80. package/scripts/herdr-activation.mjs +536 -0
  81. package/scripts/herdr-plugin-activate.mjs +270 -0
  82. package/scripts/herdr-plugin-deactivate.mjs +193 -0
  83. package/scripts/herdr-runtime.mjs +1203 -0
  84. package/scripts/install-herdr-ci.sh +96 -0
  85. package/scripts/inventory-verification-surface.ts +86 -10
  86. package/scripts/lib/codex-fresh-live-protocol.ts +2 -2
  87. package/scripts/lib/tree-digest.ts +97 -0
  88. package/scripts/meta-bridge-install.sh +19 -2
  89. package/scripts/meta-bridge-state.py +167 -11
  90. package/scripts/mutants/codex-caller-seat.json +6 -8
  91. package/scripts/mutants/codex-native.json +195 -29
  92. package/scripts/mutants/control-socket-disconnect.json +57 -0
  93. package/scripts/mutants/fresh-call-dispatch.json +154 -0
  94. package/scripts/mutants/herdr-activation.json +305 -0
  95. package/scripts/mutants/herdr-fresh-call.json +448 -0
  96. package/scripts/mutants/herdr-placement.json +161 -0
  97. package/scripts/mutants/herdr-plugin-build.json +160 -0
  98. package/scripts/mutants/herdr-plugin-profile.json +202 -0
  99. package/scripts/mutants/herdr-plugin.json +133 -0
  100. package/scripts/mutants/herdr-runtime-bootstrap.json +411 -0
  101. package/scripts/mutants/herdr-supply.json +133 -0
  102. package/scripts/mutants/meta-hook-session-switch.json +2 -2
  103. package/scripts/mutants/mux-fresh-call.json +104 -17
  104. package/scripts/mutants/mux-launcher-fence.json +13 -0
  105. package/scripts/mutants/omp-fresh.json +4 -4
  106. package/scripts/mutants/peer-facts.json +98 -0
  107. package/scripts/mutants/pi-launch.json +113 -0
  108. package/scripts/mutants/resume-args.json +6 -6
  109. package/scripts/mutants/self-address.json +33 -0
  110. package/scripts/mutants/typing-call-fence.json +17 -0
  111. package/scripts/mutants/v2-surface.json +2 -2
  112. package/scripts/mutants/v2-visible-resume.json +17 -17
  113. package/scripts/peer-facts.ts +120 -0
  114. package/scripts/register-pi-package.py +8 -0
  115. package/scripts/register-pi-provider.py +88 -12
  116. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  117. package/scripts/smoke-codex-birth.sh +281 -12
  118. package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
  119. package/scripts/smoke-herdr-fresh-call-live.ts +1027 -0
  120. package/scripts/smoke-herdr-plugin-build-live.ts +543 -0
  121. package/scripts/smoke-herdr-raw-install-live.sh +849 -0
  122. package/scripts/tsconfig.json +5 -0
  123. package/scripts/check-entwurf-facts.ts +0 -385
  124. package/scripts/check-entwurf-resume-args.ts +0 -122
  125. package/scripts/check-entwurf-v2-decider.ts +0 -657
  126. package/scripts/check-entwurf-v2-lock.ts +0 -371
  127. package/scripts/check-entwurf-v2-mailbox.ts +0 -261
  128. package/scripts/check-entwurf-v2-matrix.ts +0 -401
  129. package/scripts/check-entwurf-v2-native-push.ts +0 -221
  130. package/scripts/check-entwurf-v2-release.ts +0 -151
  131. package/scripts/check-entwurf-v2-runner.ts +0 -448
  132. package/scripts/check-entwurf-v2-send-fallback.ts +0 -350
  133. package/scripts/check-entwurf-v2-send.ts +0 -458
  134. package/scripts/check-entwurf-v2-visible-resume.ts +0 -445
  135. package/scripts/check-socket-discovery.ts +0 -401
@@ -0,0 +1,456 @@
1
+ /**
2
+ * check-herdr-sandbox — REAL herdr acceptance for the #116 launch rail (C2a).
3
+ *
4
+ * This is the half `check-herdr-fresh-call` cannot judge. That gate pins argv, encoding,
5
+ * parsing and the reclaim decision without a binary; here a PRIVATE herdr server creates a real
6
+ * tab, starts a real blank pi in its initial pane, and the assertions read what herdr and our own
7
+ * record store actually produced. There is no fake herdr anywhere in this repo, for the same reason there is
8
+ * no fake tmux (`scripts/check-mux-placement.ts:6-9`): a stand-in authors the contract before
9
+ * anything is measured.
10
+ *
11
+ * ISOLATION IS THE PRECONDITION, AND IT HAS TWO ROOTS. `[측정 2026-09-14]` herdr's integration
12
+ * and the meta-record store follow `PI_CODING_AGENT_DIR`, while the entwurf control socket
13
+ * follows `HOME` (`<HOME>/.pi/entwurf-control/`). Sandbox one and the other writes into the
14
+ * operator's tree, so both are sandboxed here, the child starts from an EXPLICIT environment
15
+ * (no inherited `PI_SESSION_ID`/`PI_AGENT_ID`/auth), and the operator's own herdr panes are
16
+ * snapshotted read-only before and after and must be identical.
17
+ *
18
+ * WHY `--approve` IS IN THE FIXTURE AND NOT IN THE RAIL. `[측정 2026-09-14]` a fresh HOME has no
19
+ * `trust.json`, so pi parks on `Trust project folder?` and waits for a KEYSTROKE — which this
20
+ * product never sends. `pi --approve` ("Trust project-local files for this run") clears that for
21
+ * one run and `[측정]` writes NO trust file. The gate is the operator of its own sandbox, so it
22
+ * may authorise its own fixture. Production argv must never carry it: `project-trust-handler.ts`
23
+ * records "an agent cannot self-promote trust" as an intended security asymmetry, and a launcher
24
+ * approving on the operator's behalf would quietly take that judgement away from them.
25
+ *
26
+ * ADMISSION. herdr is an OPTIONAL rail, so a host without the binary prints a named SKIP and
27
+ * exits 0 — a deterministic surface has no third outcome, and a silent pass would let green lie.
28
+ * A host WITH herdr that cannot complete the cell FAILS: absent is optional, broken is not.
29
+ * `ENTWURF_REQUIRE_HERDR=1` turns absence itself into a failure. C2b is wired: CI installs the
30
+ * exact asset owned by `scripts/fixtures/herdr-supply.json` through
31
+ * `scripts/install-herdr-ci.sh` before `check:full`; this gate then makes an absent binary red.
32
+ *
33
+ * Each claim carries its QK token on exactly ONE assertion.
34
+ *
35
+ * HS-VERSION-BOUNDARY the measured herdr compatibility boundary is pinned, not assumed
36
+ * HS-SANDBOX-ISOLATED the private server answers on its own socket and the child's
37
+ * environment carries no operator identity
38
+ * HS-ONE-SEED ONE checkout package registration loads entwurf's pi extension
39
+ * HS-NO-TRUST-WRITE the fixture's `--approve` leaves no trust state behind
40
+ * HS-OFFICIAL-TRIPLE herdr's direct witness carries source+agent+shape, not a bare value
41
+ * HS-EXACT-ONE-RECORD that witness resolves to exactly one sandbox V3 record
42
+ * HS-CONTROL-SOCKET-ALIVE the citizen's control socket answers the production probe
43
+ * HS-CLOSE-REFUSES-LIVE the conditional close refuses a pane that has an agent
44
+ * HS-CLOSE-RECLAIMS the same decision closes a pane we own with no agent, and that
45
+ * reclaims the tab it was the whole of
46
+ * HS-NO-RESIDUE server stop leaves no socket, no process and no tree
47
+ * HS-OPERATOR-UNTOUCHED the operator's panes and pi config are identical afterwards
48
+ */
49
+
50
+ import assert from "node:assert/strict";
51
+ import { spawn, spawnSync } from "node:child_process";
52
+ import fs from "node:fs";
53
+ import os from "node:os";
54
+ import path from "node:path";
55
+ import { fileURLToPath } from "node:url";
56
+ import {
57
+ buildHerdrPaneCloseArgs,
58
+ buildHerdrPaneGetArgs,
59
+ buildHerdrTabCreateArgs,
60
+ decideConditionalClose,
61
+ parseHerdrAgentStartResponse,
62
+ parseHerdrPaneGetResponse,
63
+ parseHerdrTabCreateResponse,
64
+ } from "../pi-extensions/lib/herdr-fresh-call.ts";
65
+ import { joinKeyOf, parseHerdrPaneList } from "../pi-extensions/lib/herdr-placement.ts";
66
+ import { probeSocketLiveness } from "../pi-extensions/lib/socket-probe.ts";
67
+
68
+ const LABEL = "check-herdr-sandbox";
69
+ const REPO_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
70
+
71
+ /** The ONE herdr line this rail has been measured against — READ from the supply manifest, never
72
+ * spelled here. A constant in this file was the drift hole C2a shipped: CI could install one
73
+ * version while the gate expected another and nothing would say so. Now the installer and this
74
+ * boundary read the same file, so they cannot disagree. A different line is not a silent pass and
75
+ * not a skip: it is a FAILURE that asks for a re-measurement. */
76
+ const SUPPLY_MANIFEST = "scripts/fixtures/herdr-supply.json";
77
+ const OWNING_DOC = "docs/herdr-launch-rail.md";
78
+
79
+ interface HerdrSupply {
80
+ readonly version: string;
81
+ readonly tag: string;
82
+ readonly assets: Readonly<Record<string, { readonly name: string; readonly sha256: string }>>;
83
+ }
84
+
85
+ function readSupply(): HerdrSupply {
86
+ return JSON.parse(fs.readFileSync(path.join(REPO_DIR, SUPPLY_MANIFEST), "utf8")) as HerdrSupply;
87
+ }
88
+
89
+ /** Absence of an optional rail is not a defect — but it must be visible. */
90
+ const SKIP_MARKER = "[entwurf:herdr-rail-skip]";
91
+ /** How a future CI turns "no herdr here" into red without changing this file. */
92
+ const REQUIRE_ENV = "ENTWURF_REQUIRE_HERDR";
93
+
94
+ let passed = 0;
95
+ function ok(label: string, cond: boolean): void {
96
+ assert.ok(cond, label);
97
+ console.log(` ok ${label}`);
98
+ passed++;
99
+ }
100
+
101
+ function skip(reason: string): never {
102
+ console.log(`${SKIP_MARKER} ${LABEL} — ${reason}`);
103
+ console.log(
104
+ `${SKIP_MARKER} herdr is an OPTIONAL rail: this cell did not run. Set ${REQUIRE_ENV}=1 to make its absence a failure.`,
105
+ );
106
+ process.exit(0);
107
+ }
108
+
109
+ function fail(reason: string): never {
110
+ console.error(`[${LABEL}] FAIL — ${reason}`);
111
+ process.exit(1);
112
+ }
113
+
114
+ /** PATH lookup without a shell: the gate's own admission decision must not depend on how a
115
+ * shell would have quoted anything. */
116
+ function which(bin: string): string | null {
117
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
118
+ if (dir.length === 0) continue;
119
+ const candidate = path.join(dir, bin);
120
+ try {
121
+ fs.accessSync(candidate, fs.constants.X_OK);
122
+ if (fs.statSync(candidate).isFile()) return candidate;
123
+ } catch {
124
+ // Not here, or not executable by us. Keep walking.
125
+ }
126
+ }
127
+ return null;
128
+ }
129
+
130
+ interface Sandbox {
131
+ readonly root: string;
132
+ readonly agentDir: string;
133
+ readonly env: NodeJS.ProcessEnv;
134
+ }
135
+
136
+ /** The child's whole environment, stated rather than inherited. `PATH` and `TERM` are the only
137
+ * things carried across, and every identity/auth carrier the operator's shell holds is simply
138
+ * not in this map. */
139
+ function makeSandbox(bin: string): Sandbox {
140
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-herdr-gate."));
141
+ const agentDir = path.join(root, "pi-agent");
142
+ for (const dir of [".config", ".state", ".data", ".cache", "pi-agent"]) {
143
+ fs.mkdirSync(path.join(root, dir), { recursive: true });
144
+ }
145
+ // The ONE seed: this checkout, registered as a pi package, so the extension under test is
146
+ // the source in this working tree and not an installed copy of some other version.
147
+ fs.writeFileSync(path.join(agentDir, "settings.json"), `${JSON.stringify({ packages: [REPO_DIR] }, null, 2)}\n`);
148
+ return {
149
+ root,
150
+ agentDir,
151
+ env: {
152
+ PATH: `${path.dirname(bin)}:${process.env.PATH ?? ""}`,
153
+ TERM: "xterm-256color",
154
+ HOME: root,
155
+ XDG_CONFIG_HOME: path.join(root, ".config"),
156
+ XDG_STATE_HOME: path.join(root, ".state"),
157
+ XDG_DATA_HOME: path.join(root, ".data"),
158
+ XDG_CACHE_HOME: path.join(root, ".cache"),
159
+ PI_CODING_AGENT_DIR: agentDir,
160
+ },
161
+ };
162
+ }
163
+
164
+ function herdr(
165
+ bin: string,
166
+ sandbox: Sandbox,
167
+ args: readonly string[],
168
+ ): { status: number; stdout: string; stderr: string } {
169
+ const run = spawnSync(bin, [...args], { encoding: "utf8", env: sandbox.env, timeout: 120_000 });
170
+ return { status: run.status ?? 1, stdout: run.stdout ?? "", stderr: run.stderr ?? "" };
171
+ }
172
+
173
+ /** The operator's own panes, read WITHOUT the sandbox environment. An operator with no server
174
+ * running is a valid state — it just has to be the same state afterwards. */
175
+ function operatorPanes(bin: string): string {
176
+ const run = spawnSync(bin, ["pane", "list"], { encoding: "utf8", timeout: 30_000 });
177
+ if ((run.status ?? 1) !== 0) return "no-operator-server";
178
+ const rows = parseHerdrPaneList(run.stdout ?? "");
179
+ if (rows === null) return "unreadable";
180
+ return JSON.stringify(rows.map((row) => [row.paneId, row.agent, row.sessionKind, row.sessionValue]).sort());
181
+ }
182
+
183
+ function sleep(ms: number): void {
184
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
185
+ }
186
+
187
+ function waitForSocket(file: string, timeoutMs: number): boolean {
188
+ const deadline = Date.now() + timeoutMs;
189
+ while (Date.now() < deadline) {
190
+ if (fs.existsSync(file)) return true;
191
+ sleep(100);
192
+ }
193
+ return false;
194
+ }
195
+
196
+ /** Linux-only: a process still holding the sandbox HOME after `server stop`. Elsewhere the
197
+ * socket/tree checks stand alone and this one reports `null` rather than guessing. */
198
+ function sandboxProcesses(root: string): number | null {
199
+ if (process.platform !== "linux") return null;
200
+ let count = 0;
201
+ for (const entry of fs.readdirSync("/proc")) {
202
+ if (!/^\d+$/.test(entry)) continue;
203
+ try {
204
+ const environ = fs.readFileSync(`/proc/${entry}/environ`, "utf8");
205
+ if (environ.split("\0").includes(`HOME=${root}`)) count++;
206
+ } catch {
207
+ // The process exited while we were reading it, which is the state we wanted anyway.
208
+ }
209
+ }
210
+ return count;
211
+ }
212
+
213
+ async function main(): Promise<void> {
214
+ console.log(`[${LABEL}]`);
215
+ const required = process.env[REQUIRE_ENV] === "1";
216
+
217
+ const bin = which("herdr");
218
+ if (bin === null) {
219
+ if (required) fail(`${REQUIRE_ENV}=1 but no herdr binary is on PATH`);
220
+ skip("no herdr binary on PATH");
221
+ }
222
+
223
+ const supply = readSupply();
224
+ const version = spawnSync(bin, ["--version"], { encoding: "utf8", timeout: 30_000 });
225
+ const versionText = `${version.stdout ?? ""}`.trim();
226
+ if ((version.status ?? 1) !== 0) fail(`herdr is on PATH at ${bin} but \`herdr --version\` failed: ${version.stderr}`);
227
+ ok(
228
+ `[QK:HS-VERSION-BOUNDARY] the herdr under test is the EXACT version the supply manifest pins (${supply.version}) — the gate and the CI installer read that one file, so an installed version and an expected version cannot drift apart silently; ${OWNING_DOC} owns why that version, ${SUPPLY_MANIFEST} owns which`,
229
+ versionText.includes(`herdr ${supply.version}`),
230
+ );
231
+
232
+ const pi = which("pi");
233
+ if (pi === null) {
234
+ // Present-but-incomplete is a failure, not a skip: the rail IS here, and a green run
235
+ // would claim a cell that never happened.
236
+ fail(`herdr is installed (${bin}) but pi is not on PATH — the herdr rail is present and incomplete`);
237
+ }
238
+
239
+ const before = operatorPanes(bin);
240
+ const operatorTrust = path.join(os.homedir(), ".pi", "agent", "trust.json");
241
+ const operatorTrustBefore = fs.existsSync(operatorTrust) ? fs.statSync(operatorTrust).mtimeMs : null;
242
+
243
+ const sandbox = makeSandbox(bin);
244
+ const serverLog = fs.openSync(path.join(sandbox.root, "server.log"), "a");
245
+ const server = spawn(bin, ["server"], {
246
+ cwd: sandbox.root,
247
+ env: sandbox.env,
248
+ detached: true,
249
+ stdio: ["ignore", serverLog, serverLog],
250
+ });
251
+ server.unref();
252
+
253
+ let cleanupDone = false;
254
+ const stopServer = (): void => {
255
+ if (cleanupDone) return;
256
+ cleanupDone = true;
257
+ herdr(bin, sandbox, ["server", "stop"]);
258
+ sleep(2000);
259
+ };
260
+
261
+ try {
262
+ const socketPath = path.join(sandbox.root, ".config", "herdr", "herdr.sock");
263
+ if (!waitForSocket(socketPath, 20_000)) fail(`the private herdr server never opened ${socketPath}`);
264
+
265
+ const listed = herdr(bin, sandbox, ["pane", "list"]);
266
+ const sandboxRows = parseHerdrPaneList(listed.stdout);
267
+ ok(
268
+ "[QK:HS-SANDBOX-ISOLATED] the private server answers on its OWN socket under the sandbox root, and the operator's panes are not in its listing — two servers, no shared state",
269
+ listed.status === 0 &&
270
+ sandboxRows !== null &&
271
+ fs.existsSync(socketPath) &&
272
+ !socketPath.startsWith(os.homedir()) &&
273
+ sandbox.env.PI_SESSION_ID === undefined &&
274
+ sandbox.env.PI_AGENT_ID === undefined,
275
+ );
276
+
277
+ const install = herdr(bin, sandbox, ["integration", "install", "pi"]);
278
+ const extension = path.join(sandbox.agentDir, "extensions", "herdr-agent-state.ts");
279
+ if (install.status !== 0 || !fs.existsSync(extension)) {
280
+ fail(`\`herdr integration install pi\` did not land in the sandbox (${extension}): ${install.stderr}`);
281
+ }
282
+
283
+ const ws = herdr(bin, sandbox, ["workspace", "create", "--label", "entwurf-gate", "--no-focus", "--cwd", REPO_DIR]);
284
+ if (ws.status !== 0) fail(`workspace create failed in the sandbox: ${ws.stderr}`);
285
+
286
+ // The production placement argv, with the production identity scrub, against a real
287
+ // server: the workspace comes from herdr's own answer about the pane we are standing in,
288
+ // exactly as the rail resolves it, never from the `w1:` prefix of an id.
289
+ const callerGet = herdr(bin, sandbox, buildHerdrPaneGetArgs("w1:p1"));
290
+ const callerPane = callerGet.status === 0 ? parseHerdrPaneGetResponse(callerGet.stdout) : null;
291
+ if (callerPane?.workspaceId === undefined) {
292
+ fail(`pane get of the caller pane failed or carried no workspace: ${callerGet.stderr || callerGet.stdout}`);
293
+ }
294
+ const tabRun = herdr(bin, sandbox, buildHerdrTabCreateArgs({ workspaceId: callerPane.workspaceId, cwd: REPO_DIR }));
295
+ const tab = tabRun.status === 0 ? parseHerdrTabCreateResponse(tabRun.stdout) : null;
296
+ if (tab === null) fail(`tab create failed or was unreadable: ${tabRun.stderr || tabRun.stdout}`);
297
+ const tabRootPane = tab.rootPane;
298
+
299
+ // `--approve` is the fixture's own one-run authorisation; `--entwurf-control` is the
300
+ // production citizenship flag. No prompt, no model, no credentials.
301
+ const start = herdr(bin, sandbox, [
302
+ "agent",
303
+ "start",
304
+ "entwurfgate",
305
+ "--kind",
306
+ "pi",
307
+ "--pane",
308
+ tabRootPane.paneId,
309
+ "--",
310
+ "--approve",
311
+ "--entwurf-control",
312
+ ]);
313
+ if (start.status !== 0) fail(`blank pi did not start in the sandbox: ${start.stderr || start.stdout}`);
314
+
315
+ const startedPane = parseHerdrAgentStartResponse(start.stdout);
316
+ const startJson = JSON.parse(start.stdout) as {
317
+ result?: {
318
+ argv?: string[];
319
+ agent?: { agent_session?: { agent?: string; kind?: string; source?: string; value?: string } };
320
+ };
321
+ };
322
+ const session = startJson.result?.agent?.agent_session;
323
+ const joinKey =
324
+ session === undefined
325
+ ? null
326
+ : joinKeyOf({
327
+ paneId: tabRootPane.paneId,
328
+ agent: session.agent ?? null,
329
+ sessionSource: session.source ?? null,
330
+ sessionKind: session.kind === "id" || session.kind === "path" ? session.kind : null,
331
+ sessionValue: session.value ?? null,
332
+ });
333
+ ok(
334
+ "[QK:HS-OFFICIAL-TRIPLE] the direct witness is an OFFICIAL herdr report — source, agent and the shape that source is measured to emit — and the production join leaf converts pi's session PATH to the native id rather than accepting a bare value",
335
+ startedPane !== null &&
336
+ session !== undefined &&
337
+ session.source === "herdr:pi" &&
338
+ session.agent === "pi" &&
339
+ session.kind === "path" &&
340
+ joinKey !== null &&
341
+ joinKey.backend === "pi" &&
342
+ (session.value ?? "").includes(joinKey.nativeSessionId),
343
+ );
344
+ ok(
345
+ "[QK:HS-ONE-SEED] ONE checkout package registration is the whole seed — entwurf's pi extension loaded from this working tree, and the fixture's argv reached pi byte-identical",
346
+ JSON.stringify(startJson.result?.argv) === JSON.stringify(["pi", "--approve", "--entwurf-control"]) &&
347
+ (JSON.parse(fs.readFileSync(path.join(sandbox.agentDir, "settings.json"), "utf8")) as { packages: string[] })
348
+ .packages.length === 1,
349
+ );
350
+
351
+ const storeDir = path.join(sandbox.agentDir, "meta-sessions");
352
+ const records = fs.existsSync(storeDir) ? fs.readdirSync(storeDir).filter((f) => f.endsWith(".meta.json")) : [];
353
+ const holders = records
354
+ .map((f) => JSON.parse(fs.readFileSync(path.join(storeDir, f), "utf8")) as Record<string, unknown>)
355
+ .filter((r) => r.nativeSessionId === joinKey?.nativeSessionId);
356
+ const record = holders[0];
357
+ ok(
358
+ "[QK:HS-EXACT-ONE-RECORD] the witness resolves to EXACTLY ONE V3 record in the sandbox store, carrying the backend and cwd it was launched with — no model turn and no credential was needed to mint it",
359
+ records.length === 1 &&
360
+ holders.length === 1 &&
361
+ record?.schemaVersion === 3 &&
362
+ record?.backend === "pi" &&
363
+ record?.cwd === REPO_DIR,
364
+ );
365
+ ok(
366
+ "[QK:HS-NO-TRUST-WRITE] the fixture's one-run --approve left NO trust state behind — the sandbox has no trust.json, so this authorisation cannot leak into any later run",
367
+ !fs.existsSync(path.join(sandbox.agentDir, "trust.json")),
368
+ );
369
+
370
+ const controlSocket = path.join(sandbox.root, ".pi", "entwurf-control", `${String(record?.gardenId)}.sock`);
371
+ const socketExists = fs.existsSync(controlSocket) && fs.statSync(controlSocket).isSocket();
372
+ const liveness = socketExists ? await probeSocketLiveness(controlSocket, { timeoutMs: 2000 }) : "dead";
373
+ ok(
374
+ "[QK:HS-CONTROL-SOCKET-ALIVE] the citizen's control socket answers the PRODUCTION probe — existence and `stat` are a separate, weaker fact, and only the probe says alive",
375
+ socketExists && liveness === "alive",
376
+ );
377
+
378
+ // ── the conditional close, against a real server ─────────────────────────────────
379
+ const liveGet = herdr(bin, sandbox, buildHerdrPaneGetArgs(tabRootPane.paneId));
380
+ const liveDecision = decideConditionalClose(
381
+ tabRootPane,
382
+ liveGet.status === 0 ? parseHerdrPaneGetResponse(liveGet.stdout) : null,
383
+ liveGet.status !== 0,
384
+ );
385
+ // THE REAL SERVER IS WHAT MAKES THIS CELL WORTH RUNNING, and 2026-09-18 is why: the
386
+ // refusal used to be spelled `agent-session-present`, which only fires once herdr has been
387
+ // TOLD the pane's session id. On the Claude rail that id arrives through a separate hook the
388
+ // child makes after it starts, so a pane holding a booted sibling read as empty and was
389
+ // closed. OCCUPANCY — herdr naming an AGENT in the pane — is the refusal now, and it does
390
+ // not depend on anybody having reported a session. Both facts are asserted: against a live
391
+ // pi citizen herdr has both, and the one that must carry the refusal is the first.
392
+ ok(
393
+ "[QK:HS-CLOSE-REFUSES-LIVE] the reclaim decision REFUSES a pane that now holds an agent, naming agent-present — occupancy alone, because a sibling whose session id herdr has not been told yet is still a sibling, and a living one is never closed by the path that exists to clean up a failed launch",
394
+ liveDecision.close === false && liveDecision.reason === "agent-present",
395
+ );
396
+ {
397
+ const live = liveGet.status === 0 ? parseHerdrPaneGetResponse(liveGet.stdout) : null;
398
+ ok(
399
+ "a real live citizen gives herdr BOTH facts — the agent label and the session report — and the refusal above is keyed to the one that arrives first",
400
+ live?.agent !== undefined && live?.hasAgentSession === true,
401
+ );
402
+ }
403
+
404
+ const spareRun = herdr(bin, sandbox, buildHerdrTabCreateArgs({ workspaceId: callerPane.workspaceId }));
405
+ const spareTab = spareRun.status === 0 ? parseHerdrTabCreateResponse(spareRun.stdout) : null;
406
+ if (spareTab === null)
407
+ fail(`the second tab create failed or was unreadable: ${spareRun.stderr || spareRun.stdout}`);
408
+ const sparePane = spareTab.rootPane;
409
+ const spareGet = herdr(bin, sandbox, buildHerdrPaneGetArgs(sparePane.paneId));
410
+ const spareDecision = decideConditionalClose(
411
+ sparePane,
412
+ spareGet.status === 0 ? parseHerdrPaneGetResponse(spareGet.stdout) : null,
413
+ spareGet.status !== 0,
414
+ );
415
+ const closeRun = spareDecision.close ? herdr(bin, sandbox, buildHerdrPaneCloseArgs(sparePane.paneId)) : null;
416
+ const afterClose = herdr(bin, sandbox, ["pane", "list"]);
417
+ const remaining = parseHerdrPaneList(afterClose.stdout) ?? [];
418
+ // `[측정 2026-09-15]` closing the sole pane of a tab takes the tab with it — so the same
419
+ // pane-level authority the rail already had is enough to reclaim a tab it created, and
420
+ // `tab close` (a bare tab id, no ownership token) never has to be reached for.
421
+ const remainingTabs = herdr(bin, sandbox, ["tab", "list"]);
422
+ ok(
423
+ "[QK:HS-CLOSE-RECLAIMS] the SAME decision closes a pane we own that has no agent — proof within one server generation, not a bare pane id — and closing that sole pane reclaims the tab it was the whole of, with no `tab close` and no empty tab left behind",
424
+ spareDecision.close === true &&
425
+ closeRun !== null &&
426
+ closeRun.status === 0 &&
427
+ !remaining.some((row) => row.paneId === sparePane.paneId) &&
428
+ remainingTabs.status === 0 &&
429
+ !remainingTabs.stdout.includes(`"${spareTab.tabId}"`),
430
+ );
431
+
432
+ stopServer();
433
+ ok(
434
+ "[QK:HS-NO-RESIDUE] stopping the private server reclaims its socket and leaves no process holding the sandbox — the fixture owns its own teardown",
435
+ !fs.existsSync(socketPath) && (sandboxProcesses(sandbox.root) ?? 0) === 0,
436
+ );
437
+ } finally {
438
+ stopServer();
439
+ fs.rmSync(sandbox.root, { recursive: true, force: true });
440
+ try {
441
+ fs.closeSync(serverLog);
442
+ } catch {
443
+ // Already closed with the process; nothing here depends on it.
444
+ }
445
+ }
446
+
447
+ const operatorTrustAfter = fs.existsSync(operatorTrust) ? fs.statSync(operatorTrust).mtimeMs : null;
448
+ ok(
449
+ "[QK:HS-OPERATOR-UNTOUCHED] the operator's herdr panes and pi trust store are byte-identical afterwards — the whole cell happened somewhere else",
450
+ operatorPanes(bin) === before && operatorTrustAfter === operatorTrustBefore && !fs.existsSync(sandbox.root),
451
+ );
452
+
453
+ console.log(`\n[${LABEL}] ${passed} assertions ok (private ${versionText})`);
454
+ }
455
+
456
+ await main();
@@ -0,0 +1,184 @@
1
+ /**
2
+ * check-herdr-supply — the herdr binary CI runs is the one this repository MEASURED (#116 C2b).
3
+ *
4
+ * No herdr binary is needed here, and no network: every cell below reads the supply manifest,
5
+ * the installer and the workflow as text. That is the point — this gate judges the SUPPLY
6
+ * AUTHORITY (who decides which bytes), while `check-herdr-sandbox` judges what those bytes do.
7
+ *
8
+ * WHY A MANIFEST INSTEAD OF A CONSTANT. C2a hardcoded `0.9.` inside the sandbox gate. A CI job
9
+ * installing some other version would have satisfied the gate's own skip/fail logic while the
10
+ * boundary check silently compared against a version nobody had installed — a drift with no
11
+ * observer. One file now owns version, tag and per-architecture digest; the installer and the
12
+ * gate both read it; and this gate refuses a second copy anywhere else.
13
+ *
14
+ * WHAT THE INTEGRITY EVIDENCE ACTUALLY IS, so nobody upgrades the claim later:
15
+ * `[측정 2026-09-14]` GitHub publishes an IMMUTABLE-RELEASE attestation (in-toto
16
+ * `release/v0.2`) listing each asset's digest for `pkg:github/herdrdev/herdr@v0.9.0`. That is
17
+ * GitHub signing "these digests are this tag's assets" — it is NOT a SLSA build provenance
18
+ * authored by herdr and NOT a reproducible-build proof. `[측정, gh 2.97.0]` `gh attestation
19
+ * verify` cannot close that predicate, so the installer depends on a pinned sha256 and never on
20
+ * that CLI. Our own digest is therefore the load-bearing check, with the attestation as
21
+ * corroboration a human can pull through the REST API.
22
+ *
23
+ * Each claim carries its QK token on exactly ONE assertion.
24
+ *
25
+ * HSUP-MANIFEST-SSOT version/tag/digests exist ONLY in the manifest
26
+ * HSUP-DIGEST-SHAPE every pinned digest is a full sha256 and they are distinct
27
+ * HSUP-ARCH-FAIL-CLOSED an unmeasured architecture is refused, never approximated
28
+ * HSUP-VERIFY-BEFORE-CHMOD the digest is checked while the file is still inert
29
+ * HSUP-NO-MUTABLE-SUPPLY no curl-pipe, no `latest`, no package manager, no global install
30
+ * HSUP-NO-ATTESTATION-CLI the installer does not depend on `gh attestation verify`
31
+ * HSUP-CI-INSTALL-BEFORE-FLOOR CI installs the pinned binary before the floor runs
32
+ * HSUP-CI-REQUIRES-HERDR that floor runs with absence turned red
33
+ * HSUP-CI-LINUX-ONLY only the Linux check job takes this; the other jobs are untouched
34
+ * HSUP-SETUP-NEVER-INSTALLS the product's own setup/package never installs herdr (Rule 17)
35
+ * HSUP-X86-UNEXECUTED the x86_64 asset is pinned but recorded as never executed
36
+ */
37
+
38
+ import assert from "node:assert/strict";
39
+ import { readFileSync } from "node:fs";
40
+ import * as path from "node:path";
41
+ import { fileURLToPath } from "node:url";
42
+
43
+ const REPO_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
44
+ const MANIFEST_PATH = "scripts/fixtures/herdr-supply.json";
45
+ const INSTALLER_PATH = "scripts/install-herdr-ci.sh";
46
+ const CI_PATH = ".github/workflows/ci.yml";
47
+ const SANDBOX_GATE_PATH = "scripts/check-herdr-sandbox.ts";
48
+
49
+ const read = (rel: string): string => readFileSync(path.join(REPO_DIR, rel), "utf8");
50
+
51
+ let passed = 0;
52
+ function ok(label: string, cond: boolean): void {
53
+ assert.ok(cond, label);
54
+ console.log(` ok ${label}`);
55
+ passed++;
56
+ }
57
+
58
+ interface Supply {
59
+ version: string;
60
+ tag: string;
61
+ tagCommit: string;
62
+ measuredOn: string;
63
+ measuredArch: string;
64
+ releaseUrlPrefix: string;
65
+ assets: Record<string, { name: string; sha256: string; executed: boolean; executedNote: string }>;
66
+ }
67
+
68
+ function main(): void {
69
+ console.log("[check-herdr-supply]");
70
+
71
+ const manifestText = read(MANIFEST_PATH);
72
+ const supply = JSON.parse(manifestText) as Supply;
73
+ const installer = read(INSTALLER_PATH);
74
+ const ci = read(CI_PATH);
75
+ const sandboxGate = read(SANDBOX_GATE_PATH);
76
+
77
+ const digests = Object.values(supply.assets).map((a) => a.sha256);
78
+
79
+ // ── the manifest is the only copy ────────────────────────────────────────────────────
80
+ // Searching the whole tree would trip over this gate's own prose, so the set is the
81
+ // files that could actually ACT on a wrong number: the installer, the workflow, and the
82
+ // gate whose boundary check reads it.
83
+ const consumers = [installer, ci, sandboxGate];
84
+ ok(
85
+ "[QK:HSUP-MANIFEST-SSOT] the version, the tag and every digest live ONLY in the manifest — the installer, the workflow and the sandbox gate READ it and none of them carries a second copy that could drift",
86
+ consumers.every((text) => !digests.some((d) => text.includes(d))) &&
87
+ !installer.includes(supply.version) &&
88
+ !ci.includes(supply.version) &&
89
+ !sandboxGate.includes(`"${supply.version}"`) &&
90
+ installer.includes(MANIFEST_PATH) &&
91
+ sandboxGate.includes(MANIFEST_PATH),
92
+ );
93
+ ok(
94
+ "[QK:HSUP-DIGEST-SHAPE] every pinned digest is a full lowercase sha256 and no two architectures share one — a truncated or duplicated digest is a check that cannot fail",
95
+ digests.length >= 2 &&
96
+ digests.every((d) => /^[0-9a-f]{64}$/.test(d)) &&
97
+ new Set(digests).size === digests.length &&
98
+ /^[0-9a-f]{40}$/.test(supply.tagCommit) &&
99
+ supply.tag === `v${supply.version}`,
100
+ );
101
+
102
+ // ── the installer's own refusals ─────────────────────────────────────────────────────
103
+ ok(
104
+ "[QK:HSUP-ARCH-FAIL-CLOSED] an architecture the manifest does not carry is REFUSED by name — there is no nearest-match binary, and a CI runner we have never measured must not silently get one",
105
+ /if \(!asset\) \{/.test(installer) &&
106
+ installer.includes("unsupported architecture") &&
107
+ installer.includes("no measured herdr asset for"),
108
+ );
109
+
110
+ const shaIndex = installer.indexOf("sha256sum");
111
+ const chmodIndex = installer.indexOf("chmod +x");
112
+ const curlIndex = installer.indexOf("curl -fsSL");
113
+ ok(
114
+ "[QK:HSUP-VERIFY-BEFORE-CHMOD] the digest is verified while the downloaded file is still inert data, and only then is it made executable — a byte we did not choose never becomes something the job can run",
115
+ curlIndex > 0 && shaIndex > curlIndex && chmodIndex > shaIndex && installer.includes('rm -f "$target"'),
116
+ );
117
+ // The installer's comments NAME every rejected supply path, which is the point of them —
118
+ // so these cells read the code with comments stripped. A guard that banned the words would
119
+ // have forced the reasons out of the file.
120
+ const installerCode = installer.replace(/^\s*#.*$/gm, "");
121
+ ok(
122
+ "[QK:HSUP-NO-MUTABLE-SUPPLY] the installer's CODE contains no curl-pipe, no `latest`, no package manager and no global install location — each of those would make a green run a claim about a binary nobody chose",
123
+ !/curl[^\n]*\|\s*(sh|bash)/.test(installerCode) &&
124
+ !installerCode.includes("/latest/") &&
125
+ !/\b(brew|mise|nix-env|npm i -g|sudo)\b/.test(installerCode) &&
126
+ !installerCode.includes(".local/bin") &&
127
+ installerCode.includes("RUNNER_TEMP"),
128
+ );
129
+ ok(
130
+ "[QK:HSUP-NO-ATTESTATION-CLI] the installer does not depend on `gh attestation verify` — measured on gh 2.97.0 it cannot close herdr's in-toto release predicate, and a check we cannot run is not a check",
131
+ !installerCode.includes("gh attestation") &&
132
+ manifestText.includes("in-toto.io/attestation/release/v0.2") &&
133
+ manifestText.includes("NOT a SLSA build provenance"),
134
+ );
135
+
136
+ // ── the CI wiring ────────────────────────────────────────────────────────────────────
137
+ const installIndex = ci.indexOf("./scripts/install-herdr-ci.sh");
138
+ const floorIndex = ci.indexOf("- run: pnpm run check:full");
139
+ ok(
140
+ "[QK:HSUP-CI-INSTALL-BEFORE-FLOOR] CI installs the pinned binary BEFORE the deterministic floor — installing after it would leave the herdr cell skipping in the one place it is supposed to be required",
141
+ installIndex > 0 && floorIndex > installIndex,
142
+ );
143
+ const floorStep = ci.slice(floorIndex, floorIndex + 200);
144
+ ok(
145
+ "[QK:HSUP-CI-REQUIRES-HERDR] that floor runs with ENTWURF_REQUIRE_HERDR=1, so an absent binary is red in CI while it stays an explicit SKIP on an operator's machine",
146
+ /ENTWURF_REQUIRE_HERDR:\s*"1"/.test(floorStep),
147
+ );
148
+ ok(
149
+ "[QK:HSUP-CI-LINUX-ONLY] exactly one job installs herdr and it is the Linux check job — the install-surface, artifact-consumer and macOS jobs are unchanged, because this rail is certified on Linux only",
150
+ ci.split("./scripts/install-herdr-ci.sh").length - 1 === 1 &&
151
+ ci.slice(0, installIndex).lastIndexOf("runs-on: ubuntu-latest") >
152
+ ci.slice(0, installIndex).lastIndexOf("\n check:") - 1 &&
153
+ installIndex < ci.indexOf(" install-surface:") &&
154
+ installIndex < ci.indexOf(" macos-install-surface:"),
155
+ );
156
+
157
+ // ── the product never supplies someone else's bytes ──────────────────────────────────
158
+ const pkg = JSON.parse(read("package.json")) as { scripts: Record<string, string>; bin?: Record<string, string> };
159
+ const scriptText = Object.values(pkg.scripts).join(" ");
160
+ ok(
161
+ "[QK:HSUP-SETUP-NEVER-INSTALLS] no product script and no shipped bin reaches the CI installer — Entwurf supplies Entwurf's bytes, never a placement owner's (Hard Rule 17), and this file is job scaffolding rather than a surface",
162
+ !scriptText.includes("install-herdr-ci") &&
163
+ !Object.values(pkg.bin ?? {}).some((p) => p.includes("install-herdr-ci")) &&
164
+ // run.sh DESCRIBES the supply gate in a comment; what matters is that no dispatch
165
+ // path runs the installer. Read the code, not the prose — banning the word would
166
+ // only push the explanation out of the file.
167
+ !read("run.sh")
168
+ .replace(/^\s*#.*$/gm, "")
169
+ .includes("install-herdr-ci"),
170
+ );
171
+
172
+ // ── honesty about what has actually run ──────────────────────────────────────────────
173
+ ok(
174
+ "[QK:HSUP-X86-UNEXECUTED] the x86_64 asset is pinned but RECORDED AS NEVER EXECUTED — every measurement in this lane ran on aarch64, so a pinned digest must not read as evidence that the cell passes on the other architecture",
175
+ supply.assets.x86_64?.executed === false &&
176
+ supply.assets.aarch64?.executed === true &&
177
+ supply.measuredArch === "aarch64" &&
178
+ (supply.assets.x86_64?.executedNote ?? "").length > 0,
179
+ );
180
+
181
+ console.log(`\n[check-herdr-supply] ${passed} assertions ok`);
182
+ }
183
+
184
+ main();