@junghanacs/entwurf 0.35.1 → 0.36.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 (55) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +403 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/DELIVERY.md +17 -0
  5. package/FAQ.md +3 -3
  6. package/README.md +30 -16
  7. package/VERIFY.md +12 -10
  8. package/demo/README.md +1 -1
  9. package/docs/acp-backend-rail.md +69 -5
  10. package/docs/setup-clean-host.md +30 -22
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +4 -3
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/codex-ws-client.js +99 -18
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +3 -3
  14. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +59 -14
  15. package/mcp/entwurf-bridge/dist/scripts/codex-socket-classify.js +44 -0
  16. package/mcp/entwurf-bridge/tsconfig.build.json +5 -0
  17. package/package.json +21 -16
  18. package/pi/pi-durable/bootstrap.mjs +12 -8
  19. package/pi/pi-durable/carrier/runtime.js +52 -2
  20. package/pi/pi-durable/carrier/subagent.js +6 -1
  21. package/pi/pi-durable/carrier/tui.js +31 -4
  22. package/pi/pi-durable/carrier-relocation.mjs +2 -2
  23. package/pi/pi-durable/overlay/upstream-pin.json +79 -36
  24. package/pi-extensions/lib/codex-fresh-preflight.ts +4 -3
  25. package/pi-extensions/lib/native-push/codex-ws-client.ts +129 -18
  26. package/pi-extensions/lib/pi-durable-runtime.ts +4 -4
  27. package/pi-extensions/meta-bridge-pi-durable.ts +77 -21
  28. package/run.sh +116 -39
  29. package/scripts/check-acp-prompt-lifecycle.ts +4 -2
  30. package/scripts/check-acp-session-reuse.ts +4 -2
  31. package/scripts/check-acp-stop-reason.ts +4 -2
  32. package/scripts/check-acp-stream-hooks.ts +4 -2
  33. package/scripts/check-acp-usage-accounting.ts +4 -2
  34. package/scripts/check-codex-app-server-launch.ts +55 -12
  35. package/scripts/check-codex-native-push.ts +231 -12
  36. package/scripts/check-gate-qualification.ts +4 -4
  37. package/scripts/check-herdr-supply.ts +32 -7
  38. package/scripts/codex-app-server-launch.sh +54 -29
  39. package/scripts/codex-socket-classify.ts +46 -0
  40. package/scripts/emit-pi-durable-carrier.ts +159 -168
  41. package/scripts/fixtures/herdr-supply.json +10 -9
  42. package/scripts/lib/acp-gate-home.ts +75 -0
  43. package/scripts/lib/codex-socket-listener.ts +429 -0
  44. package/scripts/mutants/acp-stop-reason.json +11 -0
  45. package/scripts/mutants/codex-app-server-launch.json +39 -12
  46. package/scripts/mutants/codex-native.json +67 -0
  47. package/scripts/mutants/control-send-receipt.json +1 -1
  48. package/scripts/mutants/herdr-supply.json +6 -6
  49. package/scripts/mutants/pack-install.json +3 -3
  50. package/scripts/mutants/pi-durable.json +165 -16
  51. package/scripts/mutants/pi-floor.json +5 -5
  52. package/scripts/pi-durable-contact-host.mjs +1 -1
  53. package/scripts/smoke-codex-fresh-live.ts +31 -7
  54. package/scripts/smoke-pi-durable-fresh-live.ts +22 -3
  55. package/pi/pi-durable/overlay/runtime-contacts.patch +0 -283
package/run.sh CHANGED
@@ -181,7 +181,7 @@ Usage:
181
181
  ./run.sh check-pi-durable-contact # #129 L-identity gate, CHECKOUT-ONLY (an installed package refuses it in run_ts). Opens the REAL durable app from THIS checkout's verified carrier (pi/pi-durable/carrier/ under its resolver, against the checkout's pinned pi SDK set — refused by name first) with the pi-durable contact, births the citizen and runs its tools adapter-direct through a privately emitted branch bridge (ENTWURF_PI_DURABLE_BRIDGE_ENTRY; its registry must match this checkout). Matrix roots {default, explicit} x {fresh, --continue}: one record keyed by the durable session dir, sender marker on the spawned host pid, exactly one bridge child whose /proc PPID is that host, poisoned identity carriers kept from it, its four root carriers bound to the roots written, the receiver ARMED (raw marker owned by the host pid + start key as pi-durable-host, joined to the sender marker; removed on close), self = the record and replyable, peers from an outside bridge, same garden id on reopen; the bundle's compiled owner join must admit pi-durable-host. Reaps only the host/bridges it owns on any failure. Harness-bypassed: no model turn, no ToolTask. Missing inputs → SKIP (97), never a pass
182
182
  ./run.sh check-pi-durable-send # #129 L-send FIRST CELL, CHECKOUT-ONLY. Same input as check-pi-durable-contact. One native durable turn: the app's own controller input → GenerationTask → ToolTask → the contact's entwurf_v2 → the private bridge → a seeded self-fetch target (C2 recipe, owned by a separate idle child). S = a 127.0.0.1 scripted OpenAI-compatible endpoint configured through the app's documented models.json (dummy key, PI_OFFLINE); H = the native ToolTask path; V (a vendor turn) is NOT run. Oracles: the wire (tools offered, v2 schema = the bridge's tools/list, exactly two requests, the tool result + receipt in the second), the raw mailbox (.msg 1, signal, sender = the host record), the native view (one call, its settled result, root current). Not exactly-once, not recovery, not a recipient turn. Missing inputs → SKIP (97)
183
183
  ./run.sh check-demo-reproducibility # source-checkout-only: discover offline demo Node tests; no LIVE, model, render or publication
184
- ./run.sh check-pi-durable-beside # #129 mutant execution coordinate (VERIFY rule 3): run_vitest over the six pi-durable beside tests (adapter, carrier/SDK-set verifier, preflight, owner join, carrier relocation/resolver/emitter/task footer, native-module file and directory ingress) — the pi-durable qualification lane's gate argv. Same files check-tests-beside-behavior already runs.
184
+ ./run.sh check-pi-durable-beside # #129 mutant execution coordinate (VERIFY rule 3): run_vitest over the six pi-durable beside tests (adapter, carrier/SDK-set verifier, preflight, owner join, carrier relocation/resolver, owned-source emission and typecheck fence, task footer, native-module file and directory ingress) — the pi-durable qualification lane's gate argv. Same files check-tests-beside-behavior already runs.
185
185
  ./run.sh check-pi-durable-receive # #129 L-receive FIRST CELL, CHECKOUT-ONLY. Same input (the bundle must carry the pi-durable-host owner join). The host driver gives the app NO input; a seeded claude-code sender (marker owned by a separate idle child, handed to a gate-owned sending bridge through ENTWURF_META_SENDER_MARKER) enqueues one message, the host's doorbell admits its notice to the ROOT conversation, and the scripted model drains it with entwurf_inbox_read. S withholds response #1 until the ENQUEUE receipt, the .delivered bytes, the signal, the host-owned receiver marker and the notice are captured. Oracles: receiver() admission, native view, raw archive (.delivered.read + lastReadAt), wire (exactly two requests, drain result = view), and after a green reap a forensic read-only SQLite copy (submission row by request id = reported id, root, input, done, placed entry = the notice). V NOT run. Missing inputs → SKIP (97)
186
186
  ./run.sh check-copilot-receive-arm # #82 RAIL 5 gate: the REAL receiver installer + the REAL extension.mjs forked with a stubbed SDK. Arms only after birth, marker owned by the WATCHER pid, self-fetch dispatch answer, doorbell carries the garden id and NOT the body, id-drift/foreign-parent refusals. Hermetic; no Copilot, no model turn
187
187
  ./run.sh check-copilot-launch # #82 RAIL 7 gate: the MANAGED launch `entwurf copilot`, driven through its public address against a FAKE VENDOR on a sandbox PATH. Receiver precondition refusals, EXTENSIONS token + operator token preservation, injected defaults before the `--` terminator, byte-identical argv, the 11 explicit permission/surface policy overrides that suppress `--yolo`, exec (not fork) pid identity, exit passthrough, recursion refusal. Hermetic; no Copilot, no model turn
@@ -273,7 +273,7 @@ Usage:
273
273
  ./run.sh uninstall-meta-bridge # 1.0.0 meta-bridge Phase 2: stateful GLOBAL uninstall (restore only keys/items captured in install-state)
274
274
  ./run.sh doctor-meta-bridge # THE RELEASE ORACLE (#51, Linux-certified repair axis). exit 0 = every required layer was MEASURED on this Linux host: toolchain + state + plugin/MCP + resolved-artifact launch-form classification (all 3 owner hooks + doorbell static contract) + synthetic owner join + store scan + hook errors + SessionStart evidence + REQUIRED live MCP↔marker join + writer-version parity. Missing live evidence is NOT CERTIFIED (open a Claude session and re-run), never a pass; Darwin is NOT CERTIFIED and stays nonzero, on ONE narrowed reason — start-key and ppid are portable there (`ps` fallback, same string the TS core mints), so what holds the lane closed is per-process environment DISCOVERY alone, pending physical-host evidence. Detection power is held by check-meta-doctor-oracle
275
275
  ./run.sh copilot [args...] # #82 RAIL 7: the MANAGED Copilot launch. exec()s the vendor CLI in THIS terminal (cwd/pid/exit preserved) with COPILOT_CLI_ENABLED_FEATURE_FLAGS=EXTENSIONS set for that one invocation — entwurf owns no part of your shell and writes nothing to it, but it owns the process it is about to become. Without that flag Copilot skips the extension scan SILENTLY. Refuses to launch unless the receiver unit is actually installed. Injects `--model auto` when no --model was given and `--yolo` when no explicit permission/surface policy flag was given, both BEFORE any `--`; every argument you pass is forwarded byte-identical. RUNNING THIS IS YOUR CONSENT to that profile — use plain `copilot` for stock vendor behaviour. Not tmux, not fresh-call, mints no citizen (birth is still the first prompt)
276
- ./run.sh pi-durable [args...] # #129/#130: the MANAGED durable launch. Verifies this package's pi-durable CARRIER (pi/pi-durable/carrier/, the pinned upstream app emitted by the maintainer) and the pi SDK set it resolves — every declared member/direct edge at exactly the pinned version — refusing by name (package-incomplete / carrier-drift / sdk-absent / sdk-mismatch, exit 3), then exec()s node under the carrier resolver with the packaged bootstrap (pi/pi-durable/bootstrap.mjs) in this terminal. A new session needs --provider <p> --model <exact catalog id> --width task-wide [--entwurf-bootstrap <{v:2,task}>]; --continue takes none of them. Nothing outside the package is located, installed or repaired
276
+ ./run.sh pi-durable [args...] # #129/#130: the MANAGED durable launch. Verifies this package's pi-durable CARRIER (pi/pi-durable/carrier/, emitted deterministically from Entwurf's owned source pi/pi-durable/src) and the pi SDK set it resolves — every declared member/direct edge at exactly the pinned version — refusing by name (package-incomplete / carrier-drift / sdk-absent / sdk-mismatch, exit 3), then exec()s node under the carrier resolver with the packaged bootstrap (pi/pi-durable/bootstrap.mjs) in this terminal. A new session needs --provider <p> --model <exact catalog id> --width task-wide [--entwurf-bootstrap <{v:2,task}>]; --continue takes none of them. Nothing outside the package is located, installed or repaired
277
277
  ./run.sh install-copilot-bridge # #82: GLOBAL install of the Copilot BIRTH plugin (own marketplace root; node+entry baked into the no-argv exec string). MCP wiring and the RECEIVER extension are separate install surfaces (install-copilot-mcp, install-copilot-receive). Also retires the stale Claude unit (--keep-stale-claude-unit opts out)
278
278
  ./run.sh uninstall-copilot-bridge # #86 C3a: honest inverse of install-copilot-bridge from the package-owned install-state (exact qualified id + exact marketplace name/path + recorded assembly only; state deleted LAST; a failing vendor list is UNKNOWN and refuses; never --force, never the stale Claude unit)
279
279
  ./run.sh doctor-copilot-bridge # #82/#86: fail-loud surface for that unit — runtime axis (red = a hook that RAN and failed, or a broken/unbaked artifact; "installed with zero records" is NOT red and is reported as NOT-YET: a Copilot session is born on its FIRST PROMPT, not when the window opens, measured) + ownership axis (install-state shape/binding, marketplace path drift, UNKNOWN vendor lists; legacy no-state install = named non-green, repair = install-copilot-bridge adoption). Either axis red = red
@@ -1596,7 +1596,10 @@ check_mux_fresh_call() {
1596
1596
  # pi-durable-fresh-live-receipts rides here for tmux-coordinate-row's reason: it is the
1597
1597
  # deterministic half of a LIVE sibling (smoke-pi-durable-fresh-live, #129) — the oracles that judge
1598
1598
  # that gate's receipts, proven on inline rows, so no model turn and no durable host is needed.
1599
- run_vitest test/mux-fresh-call.test.ts test/fresh-call-composition.contract.test.ts test/copilot-fresh-preflight.test.ts test/codex-fresh-preflight.test.ts test/fresh-call-surfaces.contract.test.ts test/fresh-call-provider.contract.test.ts test/omp-fresh-bootstrap.contract.test.ts test/tmux-coordinate-row.test.ts test/codex-fresh-live-protocol.test.ts test/codex-caller-seat.test.ts test/pi-durable-fresh-live-receipts.test.ts
1599
+ # codex-socket-listener rides here for the same reason (#128): it is the ownership precondition
1600
+ # of smoke-codex-fresh-live, judged on synthetic wrapper/self shapes and observed on a throwaway
1601
+ # wrapper → listener pair of its own, so no Codex, no app-server and no model turn is needed.
1602
+ run_vitest test/mux-fresh-call.test.ts test/fresh-call-composition.contract.test.ts test/copilot-fresh-preflight.test.ts test/codex-fresh-preflight.test.ts test/fresh-call-surfaces.contract.test.ts test/fresh-call-provider.contract.test.ts test/omp-fresh-bootstrap.contract.test.ts test/tmux-coordinate-row.test.ts test/codex-fresh-live-protocol.test.ts test/codex-caller-seat.test.ts test/pi-durable-fresh-live-receipts.test.ts test/codex-socket-listener.test.ts
1600
1603
  }
1601
1604
 
1602
1605
  check_pi_durable_beside() {
@@ -1606,7 +1609,7 @@ check_pi_durable_beside() {
1606
1609
  # from re-running every beside test. The six files are one contact: the adapter and its packaged
1607
1610
  # bootstrap, the installed carrier/SDK-set verifier (which also reads the three checkout gates as
1608
1611
  # SOURCE), the fresh preflight, the owner join that admits the durable host, the carrier's
1609
- # relocation grammar, resolver and maintainer emitter (#130) with the carrier's task footer (#134),
1612
+ # relocation grammar, resolver, owned-source emitter (#130, #141) and source fence with the carrier's task footer (#134),
1610
1613
  # and the bootstrap's explicit --native-module ingress (#130 P2) and its --native-module-dir (#134).
1611
1614
  run_vitest pi-extensions/meta-bridge-pi-durable.test.ts pi-extensions/lib/pi-durable-runtime.test.ts pi-extensions/lib/pi-durable-fresh-preflight.test.ts pi-extensions/lib/entwurf-deliverability.owner-join.test.ts pi-extensions/lib/pi-durable-carrier.test.ts pi-extensions/lib/pi-durable-native-module.test.ts
1612
1615
  }
@@ -2435,8 +2438,8 @@ const nextFloorRange = `>=${piAi} <${piMaj}.${piMin + 1}`;
2435
2438
  const floorGaps = [];
2436
2439
  const PI_CONSTELLATION = [
2437
2440
  '@earendil-works/pi-ai', '@earendil-works/pi-coding-agent', '@earendil-works/pi-tui',
2438
- '@earendil-works/pi-agent-core', '@earendil-works/pi-client', '@earendil-works/pi-protocol',
2439
- '@earendil-works/pi-telemetry', '@earendil-works/chord',
2441
+ '@earendil-works/pi-agent-core', '@earendil-works/pi-telemetry', '@earendil-works/chord',
2442
+ // `[측정 2026-10-13]` 1.2.0: pi-client and pi-protocol left — upstream removed both, E404 at 1.2.0.
2440
2443
  // `[측정 2026-09-30]` 0.99.1: pi-coding-agent's runtime deps gained the built-in extensions' packages.
2441
2444
  '@earendil-works/pi-codemode', '@earendil-works/pi-mcp',
2442
2445
  // #130: the durable library the pi-durable carrier imports — a production dependency since 0.32.
@@ -2577,6 +2580,65 @@ for (const [file, re, min, label] of SITES) {
2577
2580
  bound += hits.length;
2578
2581
  }
2579
2582
 
2583
+ // #141: the pi-durable carrier's emitter identity, the one place CI runs an EXACT Node. The check job
2584
+ // runs the Node the pin records as the carrier's emitter (CI-BOUND), and its check:full floor step runs
2585
+ // with ENTWURF_REQUIRE_EMITTER_NODE=1 (CI-REQUIRED), so that floor proves the emission under that
2586
+ // identity; every other job floats the major so the newest release of the lane stays covered. Both are
2587
+ // read from ACTIVE workflow lines only — the step's own `with:` / `env:` mapping, comments stripped —
2588
+ // so commented-out text or the same key on another step can never stand in for them.
2589
+ const EMITTER_BOUND_QK = '[QK:PI-DURABLE-EMITTER-NODE-CI-BOUND]';
2590
+ const EMITTER_REQUIRED_QK = '[QK:PI-DURABLE-EMITTER-NODE-CI-REQUIRED]';
2591
+ const emitterNode = JSON.parse(readFileSync('pi/pi-durable/overlay/upstream-pin.json', 'utf8')).carrier?.emitter?.node;
2592
+ assert.match(String(emitterNode), /^v\d+\.\d+\.\d+$/,
2593
+ `${EMITTER_BOUND_QK} pi/pi-durable/overlay/upstream-pin.json carrier.emitter.node must be an exact vX.Y.Z (got ${emitterNode})`);
2594
+ // jobs → steps → active lines. A full-line comment is dropped and a trailing ` # …` is cut; job heads are
2595
+ // the two-space keys under `jobs:`, a step starts at its six-space `- ` item.
2596
+ const ciJobs = new Map();
2597
+ {
2598
+ let inJobs = false;
2599
+ let steps;
2600
+ for (const raw of readFileSync('.github/workflows/ci.yml', 'utf8').split('\n')) {
2601
+ const line = raw.replace(/\s+#.*$/, '');
2602
+ if (/^\s*#/.test(raw) || line.trim() === '') continue;
2603
+ if (/^\S/.test(line)) { inJobs = line === 'jobs:'; steps = undefined; continue; }
2604
+ if (!inJobs) continue;
2605
+ const head = /^ ([a-z][a-z0-9-]*):$/.exec(line);
2606
+ if (head) { steps = []; ciJobs.set(head[1], steps); continue; }
2607
+ if (steps === undefined) continue;
2608
+ if (/^ - /.test(line)) steps.push([` ${line.slice(8)}`]);
2609
+ else if (steps.length > 0 && /^ /.test(line)) steps.at(-1).push(line);
2610
+ }
2611
+ }
2612
+ // A step's own value for `key` (`with:` / `env:` child at ten spaces), or a top-level step key at eight.
2613
+ const stepKey = (step, key) => step.find((l) => l.startsWith(` ${key}: `))?.slice(10 + key.length);
2614
+ const stepChild = (step, block, key) => {
2615
+ const at = step.indexOf(` ${block}:`);
2616
+ if (at === -1) return undefined;
2617
+ const children = [];
2618
+ for (const l of step.slice(at + 1)) { if (!l.startsWith(' ')) break; children.push(l); }
2619
+ return children.find((l) => l.startsWith(` ${key}: `))?.slice(12 + key.length);
2620
+ };
2621
+ const setupNodes = (job) => (ciJobs.get(job) ?? []).filter((s) => /^actions\/setup-node@/.test(stepKey(s, 'uses') ?? ''));
2622
+ const checkSetup = setupNodes('check');
2623
+ assert.equal(checkSetup.length, 1,
2624
+ `${EMITTER_BOUND_QK} .github/workflows/ci.yml: the check job must have exactly one actions/setup-node step (found ${checkSetup.length})`);
2625
+ const checkNode = stepChild(checkSetup[0], 'with', 'node-version');
2626
+ assert.equal(`v${checkNode}`, emitterNode,
2627
+ `${EMITTER_BOUND_QK} .github/workflows/ci.yml: the check job's setup-node runs Node ${checkNode}, but the carrier's pinned emitter is ${emitterNode} — its floor would prove the emission under another identity`);
2628
+ const floors = (ciJobs.get('check') ?? []).filter((s) => stepKey(s, 'run') === 'pnpm run check:full');
2629
+ assert.equal(floors.length, 1,
2630
+ `${EMITTER_REQUIRED_QK} .github/workflows/ci.yml: the check job must run \`pnpm run check:full\` in exactly one step (found ${floors.length})`);
2631
+ assert.equal(stepChild(floors[0], 'env', 'ENTWURF_REQUIRE_EMITTER_NODE'), '"1"',
2632
+ `${EMITTER_REQUIRED_QK} .github/workflows/ci.yml: the check job's check:full step does not set ENTWURF_REQUIRE_EMITTER_NODE: "1" in its own env, so an emitter-identity mismatch on that floor would be a skip, not red`);
2633
+ for (const [job] of ciJobs) {
2634
+ if (job === 'check') continue;
2635
+ for (const s of setupNodes(job)) {
2636
+ const value = stepChild(s, 'with', 'node-version');
2637
+ assert.match(String(value), /^\d+$/,
2638
+ `${EMITTER_BOUND_QK} .github/workflows/ci.yml: job ${job} pins node-version ${value}; only the check job is exact, the others float the major`);
2639
+ }
2640
+ }
2641
+
2580
2642
  // Sweep the tracked first-party contract TEXT surfaces, NOT just the files
2581
2643
  // already registered above. The first cut of this gate swept only SITES' own
2582
2644
  // files and therefore could not see a declaration in an UNREGISTERED file —
@@ -2627,7 +2689,7 @@ assert.ok(swept >= 6,
2627
2689
  // `bound` is the real declaration count; SITES.length is the number of RULES
2628
2690
  // (the CI rule alone matches two node-version lines). Naming the rule count a
2629
2691
  // declaration count would be this gate telling a small lie about its own reach.
2630
- console.log(`[check-node-floor-coherence] ok — Node >=${MAJOR} is coherent across engines.node, ${bound} bound declarations from ${SITES.length} site rules, and ${swept} declarations swept over ${tracked.length} tracked first-party contract text surfaces (single supported axis, no legacy lane). Non-text carriers (e.g. a Dockerfile \`FROM node:*\`) are OUTSIDE this pattern and must be bound by their own gate.`);
2692
+ console.log(`[check-node-floor-coherence] ok — Node >=${MAJOR} is coherent across engines.node, ${bound} bound declarations from ${SITES.length} site rules, and ${swept} declarations swept over ${tracked.length} tracked first-party contract text surfaces (single supported axis, no legacy lane); the pi-durable emitter ${emitterNode} is the CI check job's exact Node. Non-text carriers (e.g. a Dockerfile \`FROM node:*\`) are OUTSIDE this pattern and must be bound by their own gate.`);
2631
2693
  EOF
2632
2694
  )
2633
2695
  }
@@ -2916,7 +2978,7 @@ check_pi_import_surface() {
2916
2978
  # resolve under the extension loader.
2917
2979
  #
2918
2980
  # #130 CARRIER FENCE (replaces the #129 bootstrap exception, retired with the source runtime):
2919
- # the pi-durable carrier (pi/pi-durable/carrier/*.js, upstream app files emitted by
2981
+ # the pi-durable carrier (pi/pi-durable/carrier/*.js, emitted from Entwurf's owned source by
2920
2982
  # scripts/emit-pi-durable-carrier.ts) is the ONE place pi-coding-agent internals are reached,
2921
2983
  # and only through the reserved `entwurf-pi-dist:` scheme its resolver answers. The fence is
2922
2984
  # declared and asserted here, not inferred from the root-only scan staying quiet:
@@ -3758,7 +3820,6 @@ check_pack() {
3758
3820
  "mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js"
3759
3821
  "pi/pi-durable/bootstrap.mjs"
3760
3822
  "pi/pi-durable/overlay/upstream-pin.json"
3761
- "pi/pi-durable/overlay/runtime-contacts.patch"
3762
3823
  "pi/pi-durable/carrier-resolver.mjs"
3763
3824
  "pi/pi-durable/carrier-relocation.mjs"
3764
3825
  "pi/pi-durable/carrier/runtime.js"
@@ -3845,6 +3906,11 @@ check_pack() {
3845
3906
  # Maintainer take/renderer/helper TS is source-only, including non-test implementations.
3846
3907
  '^demo/.*\.ts$'
3847
3908
  )
3909
+ # #141: the owned pi-durable source, its typecheck fence and the retired overlay patch are maintainer
3910
+ # input. An installed consumer runs the emitted carrier JS (and its LICENSE) only — Rule 11: compiled
3911
+ # JS for installed entrypoints. The files array lists pi/pi-durable per FILE, so nothing admits these
3912
+ # today; this tripwire is what fails loud the day an allowlist entry would.
3913
+ local durable_source_pattern='^pi/pi-durable/(src/|tsconfig\.json$|overlay/runtime-contacts\.patch$)'
3848
3914
 
3849
3915
  local pass=1 f pat hit
3850
3916
 
@@ -3864,6 +3930,13 @@ check_pack() {
3864
3930
  fi
3865
3931
  done
3866
3932
 
3933
+ hit=$(grep -E "$durable_source_pattern" <<<"$file_list" || true)
3934
+ if [ -n "$hit" ]; then
3935
+ fail "[check-pack] [QK:PI-DURABLE-SOURCE-NOT-SHIPPED] the owned pi-durable source rides the tarball — an installed consumer runs the carrier JS only:"
3936
+ echo "$hit" | sed 's/^/ /' >&2
3937
+ pass=0
3938
+ fi
3939
+
3867
3940
  local total
3868
3941
  total=$(printf '%s\n' "$file_list" | wc -l | tr -d ' ')
3869
3942
  echo "[check-pack] $total files in tarball"
@@ -3898,7 +3971,7 @@ check_pack() {
3898
3971
  # which is exactly what property (1)'s `(_|$)` absorbs; all three shapes are fixtures
3899
3972
  # below so a future suffix change cannot pass vacuously.)
3900
3973
  pack_install_leaked_pi() {
3901
- grep '^@earendil-works+' | grep -Ev '@1\.1\.0(_|$)' || true
3974
+ grep '^@earendil-works+' | grep -Ev '@1\.2\.0(_|$)' || true
3902
3975
  }
3903
3976
 
3904
3977
  # Matcher self-test on SYNTHETIC lookalikes: a healthy install tree cannot exercise either
@@ -3918,15 +3991,15 @@ check_pack_pin_matcher() {
3918
3991
  # None may leak; the two lookalikes must — a PREFIX-EXTENDED version (`0.86.0-beta.1`,
3919
3992
  # the prerelease shape that an unanchored match would bless) and an off-pin version.
3920
3993
  matcher_probe=$(printf '%s\n' \
3921
- '@earendil-works+pi-ai@1.1.0' \
3922
- '@earendil-works+pi-ai@1.1.0_@modelcontextprotocol+sdk@1.29.0_zod@4.3.6' \
3923
- '@earendil-works+pi-ai@1.1.0_ws@8.21.3' \
3924
- '@earendil-works+pi-ai@1.1.0_@modelcontextprotocol+sdk@1.29.0_zod@4.3.6__ws@8.21.3_zod@4.3.6' \
3925
- '@earendil-works+pi-ai@1.1.0-beta.1' \
3994
+ '@earendil-works+pi-ai@1.2.0' \
3995
+ '@earendil-works+pi-ai@1.2.0_@modelcontextprotocol+sdk@1.29.0_zod@4.3.6' \
3996
+ '@earendil-works+pi-ai@1.2.0_ws@8.21.3' \
3997
+ '@earendil-works+pi-ai@1.2.0_@modelcontextprotocol+sdk@1.29.0_zod@4.3.6__ws@8.21.3_zod@4.3.6' \
3998
+ '@earendil-works+pi-ai@1.2.0-beta.1' \
3926
3999
  '@earendil-works+pi-agent-core@0.87.1' | pack_install_leaked_pi)
3927
- if [ "$matcher_probe" != '@earendil-works+pi-ai@1.1.0-beta.1
4000
+ if [ "$matcher_probe" != '@earendil-works+pi-ai@1.2.0-beta.1
3928
4001
  @earendil-works+pi-agent-core@0.87.1' ]; then
3929
- fail "[QK:PACK-INSTALL-PIN-MATCHER-BOUNDED] the pin-leak matcher must flag the prefix-extended 1.1.0-beta.1 and the off-pin 0.87.1 lookalikes, and pass 1.1.0 bare or with any measured peer-hash — got: ${matcher_probe:-<nothing leaked>}"
4002
+ fail "[QK:PACK-INSTALL-PIN-MATCHER-BOUNDED] the pin-leak matcher must flag the prefix-extended 1.2.0-beta.1 and the off-pin 0.87.1 lookalikes, and pass 1.2.0 bare or with any measured peer-hash — got: ${matcher_probe:-<nothing leaked>}"
3930
4003
  return 1
3931
4004
  fi
3932
4005
 
@@ -3934,11 +4007,11 @@ check_pack_pin_matcher() {
3934
4007
  # (0.85.0 onward, still true at 0.99.1). An off-pin chord MUST leak; the pinned one must not. A matcher narrowed
3935
4008
  # back to `^@earendil-works+pi-` sees nothing here and dies at this signature.
3936
4009
  matcher_probe=$(printf '%s\n' \
3937
- '@earendil-works+chord@1.1.0' \
4010
+ '@earendil-works+chord@1.2.0' \
3938
4011
  '@earendil-works+chord@0.87.1' \
3939
- '@earendil-works+pi-ai@1.1.0' | pack_install_leaked_pi)
4012
+ '@earendil-works+pi-ai@1.2.0' | pack_install_leaked_pi)
3940
4013
  if [ "$matcher_probe" != '@earendil-works+chord@0.87.1' ]; then
3941
- fail "[QK:PACK-INSTALL-PIN-MATCHER-COVERS-CLOSURE] the pin-leak matcher must cover every @earendil-works closure member, not just the pi-* families — an off-pin @earendil-works/chord has to leak (it is a runtime dependency of pi-coding-agent, pi-agent-core, pi-client and pi-protocol — `[측정 2026-09-20]` at 0.86.0, re-measured `[측정 2026-09-22]` at 0.87.0, `[측정 2026-09-23]` at 0.87.1 and `[측정 2026-09-30]` at 0.99.1, where pi-coding-agent adds pi-codemode and pi-mcp beside them) — got: ${matcher_probe:-<nothing leaked>}"
4014
+ fail "[QK:PACK-INSTALL-PIN-MATCHER-COVERS-CLOSURE] the pin-leak matcher must cover every @earendil-works closure member, not just the pi-* families — an off-pin @earendil-works/chord has to leak (it was a runtime dependency of pi-coding-agent, pi-agent-core, pi-client and pi-protocol — `[측정 2026-09-20]` at 0.86.0, re-measured `[측정 2026-09-22]` at 0.87.0, `[측정 2026-09-23]` at 0.87.1 and `[측정 2026-09-30]` at 0.99.1, where pi-coding-agent adds pi-codemode and pi-mcp beside them; `[측정 2026-10-13]` at 1.2.0 pi-durable alone declares it) — got: ${matcher_probe:-<nothing leaked>}"
3942
4015
  return 1
3943
4016
  fi
3944
4017
 
@@ -4113,8 +4186,8 @@ ORACLE
4113
4186
  # copied from this checkout into the sandbox — it never ships (test/ is outside the package files).
4114
4187
  # Its initialization appends `init` to the receipt this cell names; the same public launch must then
4115
4188
  # reach the same carrier model refusal, so the installed bootstrap loaded the module before the
4116
- # runtime and the native registry installed its extension (carrier/runtime.js 148) before that
4117
- # refusal (runtime.js 164). A module claiming an extension name the app installs itself is refused by
4189
+ # runtime and the native registry installed its extension (carrier/runtime.js 159) before that
4190
+ # refusal (runtime.js 175). A module claiming an extension name the app installs itself is refused by
4118
4191
  # name before anything opens: no durable session directory, no citizen, in a fresh sandbox home.
4119
4192
  # Native SDK file opens here are ordinary (the module runs in the native process); none is judged.
4120
4193
  local mod_dir="$box/pd-module" mod_home="$box/pd-module-home" mod_env=()
@@ -4169,7 +4242,7 @@ ORACLE
4169
4242
  # aligned host binary (the pinned version, installed above for L1) must be inside the range.
4170
4243
  # The previous host version is a variable, not a quoted `@pkg@x.y.z` spec: check-dep-versions reads
4171
4244
  # those specs as this repo's install pins, and this one is deliberately off the floor.
4172
- local host_home host_proj host_old="$box/pd-host-old" host_env=() host_old_ver="1.0.4"
4245
+ local host_home host_proj host_old="$box/pd-host-old" host_env=() host_old_ver="1.1.0"
4173
4246
  mkdir -p "$host_old"
4174
4247
  for spec in "${host_specs[@]}"; do host_old_specs+=("${spec%@*}@$host_old_ver"); done
4175
4248
  out=$(cd "$host_old" && npm install "${host_old_specs[@]}" --no-audit --no-fund 2>&1) || {
@@ -4187,7 +4260,7 @@ ORACLE
4187
4260
  fail "[check-pack-install] host $cell: the native pi-durable row must stay PASS whatever the PATH host is:"; echo "$out" | tail -20 | sed 's/^/ /' >&2; return 1
4188
4261
  fi
4189
4262
  if [ "$cell" = old ]; then
4190
- if [ "$rc" -eq 0 ] || ! grep -q "pi: FAIL — detected pi $host_old_ver is outside the supported range >=$pin_ver <1.2" <<<"$out" || [ -e "$host_proj/.pi" ] || [ -e "$host_home/.pi/agent/settings.json" ]; then
4263
+ if [ "$rc" -eq 0 ] || ! grep -q "pi: FAIL — detected pi $host_old_ver is outside the supported range >=$pin_ver <1.3" <<<"$out" || [ -e "$host_proj/.pi" ] || [ -e "$host_home/.pi/agent/settings.json" ]; then
4191
4264
  fail "[QK:PACK-INSTALL-HOST-RANGE-SEPARATE] [check-pack-install] a real pi $host_old_ver host beside the $pin_ver native set must be a named pi FAIL with zero Pi wiring and a non-green setup (rc=$rc):"
4192
4265
  echo "$out" | tail -20 | sed 's/^/ /' >&2
4193
4266
  return 1
@@ -4395,7 +4468,6 @@ _check_pack_install_impl() {
4395
4468
  "mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js"
4396
4469
  "pi/pi-durable/bootstrap.mjs"
4397
4470
  "pi/pi-durable/overlay/upstream-pin.json"
4398
- "pi/pi-durable/overlay/runtime-contacts.patch"
4399
4471
  "pi/pi-durable/carrier-resolver.mjs"
4400
4472
  "pi/pi-durable/carrier-relocation.mjs"
4401
4473
  "pi/pi-durable/carrier/runtime.js"
@@ -4558,21 +4630,26 @@ _check_pack_install_impl() {
4558
4630
  # pi-ai, pi-codemode, pi-mcp, pi-tui}, pi-ai {pi-telemetry}, pi-agent-core {pi-ai}, pi-durable
4559
4631
  # {chord, pi-ai}, and `npm view` gives pi-client {chord, pi-protocol}, pi-protocol {chord}. All
4560
4632
  # eleven are published at 1.1.0.
4561
- echo "[check-pack-install] pnpm add into $tmp (with the aligned 1.1.x SDK + chord + codemode + mcp + durable + typebox)"
4633
+ # `[측정 2026-10-13]` at 1.2.0 the constellation SHRINKS to the nine-member SDK set: upstream
4634
+ # removed pi-client and pi-protocol (7472cda0d), and `npm view <pkg>@1.2.0` answers E404 for both,
4635
+ # so their pins cannot move with the floor and leave instead of staying redundant. The 1.2.0
4636
+ # registry manifests give pi-coding-agent {pi-agent-core, pi-ai, pi-codemode, pi-mcp, pi-tui}
4637
+ # (chord dropped), pi-ai {pi-telemetry}, pi-agent-core {pi-ai}, pi-durable {chord, pi-ai,
4638
+ # pi-codemode}; all nine are published at 1.2.0
4639
+ # (.tmp-verify/141-kickoff/s1a/map-receipts/01-tarballs.txt).
4640
+ echo "[check-pack-install] pnpm add into $tmp (with the aligned 1.2.x SDK + chord + codemode + mcp + durable + typebox)"
4562
4641
  local install_log
4563
4642
  install_log=$(cd "$tmp" && pnpm add \
4564
4643
  "$tgz_path" \
4565
- "@earendil-works/pi-ai@1.1.0" \
4566
- "@earendil-works/pi-coding-agent@1.1.0" \
4567
- "@earendil-works/pi-tui@1.1.0" \
4568
- "@earendil-works/pi-agent-core@1.1.0" \
4569
- "@earendil-works/pi-client@1.1.0" \
4570
- "@earendil-works/pi-protocol@1.1.0" \
4571
- "@earendil-works/pi-telemetry@1.1.0" \
4572
- "@earendil-works/chord@1.1.0" \
4573
- "@earendil-works/pi-codemode@1.1.0" \
4574
- "@earendil-works/pi-mcp@1.1.0" \
4575
- "@earendil-works/pi-durable@1.1.0" \
4644
+ "@earendil-works/pi-ai@1.2.0" \
4645
+ "@earendil-works/pi-coding-agent@1.2.0" \
4646
+ "@earendil-works/pi-tui@1.2.0" \
4647
+ "@earendil-works/pi-agent-core@1.2.0" \
4648
+ "@earendil-works/pi-telemetry@1.2.0" \
4649
+ "@earendil-works/chord@1.2.0" \
4650
+ "@earendil-works/pi-codemode@1.2.0" \
4651
+ "@earendil-works/pi-mcp@1.2.0" \
4652
+ "@earendil-works/pi-durable@1.2.0" \
4576
4653
  "typebox@latest" \
4577
4654
  --ignore-workspace --ignore-scripts 2>&1) || {
4578
4655
  fail "[check-pack-install] pnpm add failed:"
@@ -4588,11 +4665,11 @@ _check_pack_install_impl() {
4588
4665
  local leaked_pi
4589
4666
  leaked_pi=$(ls "$tmp/node_modules/.pnpm" 2>/dev/null | pack_install_leaked_pi)
4590
4667
  if [ -n "$leaked_pi" ]; then
4591
- fail "[check-pack-install] UNVERIFIED pi runtime resolved into the install tree (expected only 1.1.0):"
4668
+ fail "[check-pack-install] UNVERIFIED pi runtime resolved into the install tree (expected only 1.2.0):"
4592
4669
  printf '%s\n' "$leaked_pi" | sed 's/^/ /' >&2
4593
4670
  return 1
4594
4671
  fi
4595
- echo "[check-pack-install] pi runtime tree pin verified: every @earendil-works package is 1.1.0 (chord and pi-durable included)"
4672
+ echo "[check-pack-install] pi runtime tree pin verified: every @earendil-works package is 1.2.0 (chord and pi-durable included)"
4596
4673
 
4597
4674
  # Resolve the installed package.json and confirm pi.extensions
4598
4675
  # arrived intact. If pi.extensions is empty or missing, the
@@ -39,6 +39,7 @@ import { pathToFileURL } from "node:url";
39
39
  import type { Api, AssistantMessageEvent, Message, Model, TranscriptContext } from "@earendil-works/pi-ai";
40
40
  import { normalizeContext } from "@earendil-works/pi-ai";
41
41
  import { isRetryableAssistantError } from "@earendil-works/pi-ai/compat";
42
+ import { assertOverlayInSandbox, sandboxAcpGateHome } from "./lib/acp-gate-home.ts";
42
43
 
43
44
  const sonnet = { id: "claude-sonnet-5-5" } as unknown as Model<Api>;
44
45
 
@@ -196,8 +197,6 @@ function makeHarness(recordDir: string) {
196
197
  await notifier?.({ sessionUpdate: "agent_message_chunk", content: { type: "text", text } });
197
198
  },
198
199
  deps: {
199
- resolveLaunch: () => ({ command: "node", args: ["fake"] }),
200
- ensureOverlay: () => {},
201
200
  spawnChild: () => {
202
201
  const c = makeFakeChild();
203
202
  children.push(c);
@@ -279,6 +278,7 @@ function reuseCtx(prior: string, latest: string): TranscriptContext {
279
278
  const TMP_EMIT = ".tmp-verify/acp-prompt-lifecycle";
280
279
  rmSync(TMP_EMIT, { recursive: true, force: true });
281
280
  const recordDir = mkdtempSync(resolve(tmpdir(), "acp-prompt-life-"));
281
+ const gateHome = sandboxAcpGateHome("acp-prompt-lifecycle-home-");
282
282
 
283
283
  try {
284
284
  execFileSync("node_modules/.bin/tsc", ["--outDir", TMP_EMIT, "--rootDir", ".", "--noEmit", "false"], {
@@ -288,6 +288,7 @@ try {
288
288
  mkdirSync(promptsOut, { recursive: true });
289
289
  copyFileSync("pi-extensions/lib/acp/prompts/engraving.md", resolve(promptsOut, "engraving.md"));
290
290
  const backend = (await import(pathToFileURL(resolve(TMP_EMIT, "pi-extensions/lib/acp/backend.js")).href)) as any;
291
+ await assertOverlayInSandbox(TMP_EMIT, gateHome);
291
292
 
292
293
  // ----------------------------------------------------------------------
293
294
  // CELL 1 — a long / quiet turn is NOT killed.
@@ -918,6 +919,7 @@ try {
918
919
  // non-empty or already gone — fine either way
919
920
  }
920
921
  rmSync(recordDir, { recursive: true, force: true });
922
+ gateHome.restore();
921
923
  }
922
924
 
923
925
  console.log(
@@ -32,6 +32,7 @@ import { resolve } from "node:path";
32
32
  import { pathToFileURL } from "node:url";
33
33
  import type { Api, AssistantMessageEvent, Context, Message, Model, TranscriptContext } from "@earendil-works/pi-ai";
34
34
  import { normalizeContext } from "@earendil-works/pi-ai";
35
+ import { assertOverlayInSandbox, sandboxAcpGateHome } from "./lib/acp-gate-home.ts";
35
36
 
36
37
  // The pi built-ins the Claude child also exposes natively. pi declares its tool
37
38
  // surface on every turn; since 0.86 that declaration reaches a provider as the
@@ -175,8 +176,6 @@ function makeHarness(recordDir: string, config: any = DEFAULT_RESOLVED_CONFIG, e
175
176
  });
176
177
 
177
178
  const deps = {
178
- resolveLaunch: () => ({ command: "node", args: ["fake"] }),
179
- ensureOverlay: () => {},
180
179
  spawnChild: () => {
181
180
  const c = makeFakeChild();
182
181
  children.push(c);
@@ -268,6 +267,7 @@ function reuseCtx(prior: string, latest: string): TranscriptContext {
268
267
  const TMP_EMIT = ".tmp-verify/acp-session-reuse";
269
268
  rmSync(TMP_EMIT, { recursive: true, force: true });
270
269
  const recordDir = mkdtempSync(resolve(tmpdir(), "acp-reuse-rec-"));
270
+ const gateHome = sandboxAcpGateHome("acp-session-reuse-home-");
271
271
 
272
272
  try {
273
273
  execFileSync("node_modules/.bin/tsc", ["--outDir", TMP_EMIT, "--rootDir", ".", "--noEmit", "false"], {
@@ -283,6 +283,7 @@ try {
283
283
  const backendUrl = pathToFileURL(resolve(TMP_EMIT, "pi-extensions/lib/acp/backend.js")).href;
284
284
  const storeUrl = pathToFileURL(resolve(TMP_EMIT, "pi-extensions/lib/acp/session-store.js")).href;
285
285
  const backend = (await import(backendUrl)) as any;
286
+ await assertOverlayInSandbox(TMP_EMIT, gateHome);
286
287
  const store = (await import(storeUrl)) as any;
287
288
  const configUrl = pathToFileURL(resolve(TMP_EMIT, "pi-extensions/lib/acp/config.js")).href;
288
289
  const configMod = (await import(configUrl)) as any;
@@ -814,6 +815,7 @@ try {
814
815
  // non-empty or already gone — fine either way
815
816
  }
816
817
  rmSync(recordDir, { recursive: true, force: true });
818
+ gateHome.restore();
817
819
  }
818
820
 
819
821
  console.log(
@@ -54,6 +54,7 @@ import { resolve } from "node:path";
54
54
  import { pathToFileURL } from "node:url";
55
55
  import type { Api, AssistantMessageEvent, Model } from "@earendil-works/pi-ai";
56
56
  import { normalizeContext } from "@earendil-works/pi-ai";
57
+ import { assertOverlayInSandbox, sandboxAcpGateHome } from "./lib/acp-gate-home.ts";
57
58
 
58
59
  const sonnet = { id: "claude-sonnet-5-5" } as unknown as Model<Api>;
59
60
 
@@ -118,8 +119,6 @@ const DEFAULT_RESOLVED_CONFIG: any = {
118
119
  */
119
120
  function makeHarness(recordDir: string, wireReason: string | null) {
120
121
  return {
121
- resolveLaunch: () => ({ command: "node", args: ["fake"] }),
122
- ensureOverlay: () => {},
123
122
  spawnChild: () => makeFakeChild(),
124
123
  createConnection: (_child: any, handlers: any) => ({
125
124
  initialize: async () => ({ agentCapabilities: {} }),
@@ -143,6 +142,7 @@ function makeHarness(recordDir: string, wireReason: string | null) {
143
142
  const TMP_EMIT = ".tmp-verify/acp-stop-reason";
144
143
  rmSync(TMP_EMIT, { recursive: true, force: true });
145
144
  const recordDir = mkdtempSync(resolve(tmpdir(), "acp-stop-rec-"));
145
+ const gateHome = sandboxAcpGateHome("acp-stop-reason-home-");
146
146
 
147
147
  /**
148
148
  * One driven turn, described by everything an operator could observe.
@@ -250,6 +250,7 @@ try {
250
250
  const backendUrl = pathToFileURL(resolve(TMP_EMIT, "pi-extensions/lib/acp/backend.js")).href;
251
251
  const eventUrl = pathToFileURL(resolve(TMP_EMIT, "pi-extensions/lib/acp/event-mapper.js")).href;
252
252
  const backend = (await import(backendUrl)) as any;
253
+ await assertOverlayInSandbox(TMP_EMIT, gateHome);
253
254
  const eventMod = (await import(eventUrl)) as any;
254
255
 
255
256
  // ----------------------------------------------------------------------
@@ -354,6 +355,7 @@ try {
354
355
  // non-empty or already gone — fine either way
355
356
  }
356
357
  rmSync(recordDir, { recursive: true, force: true });
358
+ gateHome.restore();
357
359
  }
358
360
 
359
361
  console.log(
@@ -34,6 +34,7 @@ import { resolve } from "node:path";
34
34
  import { pathToFileURL } from "node:url";
35
35
  import type { Api, AssistantMessageEvent, Message, Model, TranscriptContext } from "@earendil-works/pi-ai";
36
36
  import { normalizeContext } from "@earendil-works/pi-ai";
37
+ import { assertOverlayInSandbox, sandboxAcpGateHome } from "./lib/acp-gate-home.ts";
37
38
 
38
39
  const sonnet = { id: "claude-sonnet-5-5" } as unknown as Model<Api>;
39
40
 
@@ -108,8 +109,6 @@ function makeHarness(recordDir: string) {
108
109
  children,
109
110
  promptCalls,
110
111
  deps: {
111
- resolveLaunch: () => ({ command: "node", args: ["fake"] }),
112
- ensureOverlay: () => {},
113
112
  spawnChild: () => {
114
113
  const c = makeFakeChild();
115
114
  children.push(c);
@@ -187,6 +186,7 @@ const onResponse = () => {
187
186
  const TMP_EMIT = ".tmp-verify/acp-stream-hooks";
188
187
  rmSync(TMP_EMIT, { recursive: true, force: true });
189
188
  const recordDir = mkdtempSync(resolve(tmpdir(), "acp-stream-hooks-"));
189
+ const gateHome = sandboxAcpGateHome("acp-stream-hooks-home-");
190
190
 
191
191
  try {
192
192
  execFileSync("node_modules/.bin/tsc", ["--outDir", TMP_EMIT, "--rootDir", ".", "--noEmit", "false"], {
@@ -196,6 +196,7 @@ try {
196
196
  mkdirSync(promptsOut, { recursive: true });
197
197
  copyFileSync("pi-extensions/lib/acp/prompts/engraving.md", resolve(promptsOut, "engraving.md"));
198
198
  const backend = (await import(pathToFileURL(resolve(TMP_EMIT, "pi-extensions/lib/acp/backend.js")).href)) as any;
199
+ await assertOverlayInSandbox(TMP_EMIT, gateHome);
199
200
 
200
201
  // ----------------------------------------------------------------------
201
202
  // CELL 1 — NEW turn: the hook sees the EXACT wire params; undefined keeps them.
@@ -510,6 +511,7 @@ try {
510
511
  // non-empty or already gone — fine either way
511
512
  }
512
513
  rmSync(recordDir, { recursive: true, force: true });
514
+ gateHome.restore();
513
515
  }
514
516
 
515
517
  console.log(
@@ -60,6 +60,7 @@ import type {
60
60
  } from "@earendil-works/pi-ai";
61
61
  import { isContextOverflow, normalizeContext } from "@earendil-works/pi-ai";
62
62
  import { calculateContextTokens } from "@earendil-works/pi-coding-agent";
63
+ import { assertOverlayInSandbox, sandboxAcpGateHome } from "./lib/acp-gate-home.ts";
63
64
 
64
65
  const claude = { id: "claude-sonnet-5-5" } as unknown as Model<Api>;
65
66
  const cortex = { id: "cortex-auto" } as unknown as Model<Api>;
@@ -193,8 +194,6 @@ function makeHarness(recordDir: string, scripts: TurnScript[]) {
193
194
  return newSessionCalls;
194
195
  },
195
196
  deps: {
196
- resolveLaunch: () => ({ command: "node", args: ["fake"] }),
197
- ensureOverlay: () => {},
198
197
  spawnChild: () => {
199
198
  const c = makeFakeChild();
200
199
  children.push(c);
@@ -297,6 +296,7 @@ function reuseCtx3(a: string, b: string, c: string): TranscriptContext {
297
296
  const TMP_EMIT = ".tmp-verify/acp-usage-accounting";
298
297
  rmSync(TMP_EMIT, { recursive: true, force: true });
299
298
  const recordDir = mkdtempSync(resolve(tmpdir(), "acp-usage-acct-"));
299
+ const gateHome = sandboxAcpGateHome("acp-usage-accounting-home-");
300
300
 
301
301
  try {
302
302
  execFileSync("node_modules/.bin/tsc", ["--outDir", TMP_EMIT, "--rootDir", ".", "--noEmit", "false"], {
@@ -306,6 +306,7 @@ try {
306
306
  mkdirSync(promptsOut, { recursive: true });
307
307
  copyFileSync("pi-extensions/lib/acp/prompts/engraving.md", resolve(promptsOut, "engraving.md"));
308
308
  const backend = (await import(pathToFileURL(resolve(TMP_EMIT, "pi-extensions/lib/acp/backend.js")).href)) as any;
309
+ await assertOverlayInSandbox(TMP_EMIT, gateHome);
309
310
 
310
311
  // ----------------------------------------------------------------------
311
312
  // CELL 1 — the turn aggregate never reaches pi's per-request fields.
@@ -1062,6 +1063,7 @@ try {
1062
1063
  // non-empty or already gone — fine either way
1063
1064
  }
1064
1065
  rmSync(recordDir, { recursive: true, force: true });
1066
+ gateHome.restore();
1065
1067
  }
1066
1068
 
1067
1069
  /** Distinct-but-uninteresting token partition for the cost-focused cells. */
@@ -322,25 +322,35 @@ exit "\${FAKE_CODEX_EXIT:-0}"
322
322
  );
323
323
  }
324
324
  {
325
+ // The classifier's INDETERMINATE domain, as one claim: two unidentifiable paths, both
326
+ // reaching the launcher through the one `indeterminate)` arm. A path that exists and is
327
+ // not a socket must read indeterminate, not stale, so the launcher refuses instead of
328
+ // clobbering it. A symlink that is NOT Codex's exact rendezvous alias must be refused with
329
+ // the product classifier's own reason, which is what pins the WIRING: a launcher that grew
330
+ // its own symlink rule again would refuse there too, but not in those words. Both runs are
331
+ // made before the one assertion, so a regression on that shared arm fails HERE, at this
332
+ // claim, and not at an earlier unnamed cell. The exact alias's accept path is certified on
333
+ // `checkCodexSocketFile` (check-codex-native-push), not here: this gate has no seam to put
334
+ // Codex's socket directory in a sandbox.
325
335
  const fileHome = path.join(root, "file-home");
326
336
  const fileSock = resolveCodexDefaultSocketPath({ CODEX_HOME: fileHome });
327
337
  mkdirSync(path.dirname(fileSock), { recursive: true });
328
338
  writeFileSync(fileSock, "");
329
- const r = launch([], { CODEX_HOME: fileHome });
330
- ok(
331
- "a path that exists and is not a socket is indeterminate, not stale — the launcher refuses instead of clobbering something it cannot identify",
332
- r.status !== 0 && r.out.includes("codex-app-server-socket-indeterminate") && r.args.length === 0,
333
- );
334
- }
335
- {
339
+ const regular = launch([], { CODEX_HOME: fileHome });
336
340
  const linkHome = path.join(root, "link-home");
337
341
  const linkSock = resolveCodexDefaultSocketPath({ CODEX_HOME: linkHome });
338
342
  mkdirSync(path.dirname(linkSock), { recursive: true });
339
343
  symlinkSync(path.join(root, "nowhere.sock"), linkSock);
340
- const r = launch([], { CODEX_HOME: linkHome });
344
+ const link = launch([], { CODEX_HOME: linkHome });
341
345
  ok(
342
- "[QK:CODEX-APP-SERVER-REFUSES-INDETERMINATE-SOCKET] a symlinked control socket is refused by name — the same classification the delivery rail's socket check uses",
343
- r.status !== 0 && r.out.includes("codex-app-server-socket-indeterminate") && r.args.length === 0,
346
+ "[QK:CODEX-APP-SERVER-REFUSES-INDETERMINATE-SOCKET] an unidentifiable path is refused, never launched over: a path that exists and is not a socket, and a symlink that is not Codex's rendezvous alias (refused with the classifier's own reason) — the same classification the delivery rail's socket check uses",
347
+ regular.status !== 0 &&
348
+ regular.out.includes("codex-app-server-socket-indeterminate") &&
349
+ regular.args.length === 0 &&
350
+ link.status !== 0 &&
351
+ link.out.includes("codex-app-server-socket-indeterminate") &&
352
+ link.out.includes("is not Codex's rendezvous alias") &&
353
+ link.args.length === 0,
344
354
  );
345
355
  }
346
356
  {
@@ -358,17 +368,50 @@ exit "\${FAKE_CODEX_EXIT:-0}"
358
368
  // Hard Rule 15: an unrecognised reading is the one case where proceeding is unsafe,
359
369
  // because every branch above is a decision about whether this launch would clobber a
360
370
  // running server. The stimulus is a sandbox `python3` that exits 0 while printing
361
- // something nobody wrote — the exact shape a silent fall-through needs.
371
+ // something nobody wrote — the exact shape a silent fall-through needs. The launcher runs
372
+ // that connect probe only on an endpoint the classifier verified as a socket, so the cell
373
+ // puts a (dead) socket file there; without one the probe would never be asked.
374
+ const oddHome = path.join(root, "odd-home");
375
+ const oddSock = resolveCodexDefaultSocketPath({ CODEX_HOME: oddHome });
376
+ mkdirSync(path.dirname(oddSock), { recursive: true });
377
+ spawnSync("python3", ["-c", "import socket,sys;s=socket.socket(socket.AF_UNIX);s.bind(sys.argv[1])", oddSock]);
362
378
  const oddBin = path.join(root, "odd-probe-bin");
363
379
  mkdirSync(oddBin, { recursive: true });
364
380
  const oddPython = path.join(oddBin, "python3");
365
381
  writeFileSync(oddPython, "#!/usr/bin/env bash\ncat >/dev/null\necho 'unexpected-probe-status'\nexit 0\n");
366
382
  chmodSync(oddPython, 0o755);
367
- const r = launch([], { PATH: `${oddBin}:${bin}:${process.env.PATH ?? ""}` });
383
+ const r = launch([], { CODEX_HOME: oddHome, PATH: `${oddBin}:${bin}:${process.env.PATH ?? ""}` });
384
+ // The second stimulus is the CLASSIFIER answering a word it never gives. `stale` is the
385
+ // discriminating one: it is a valid verdict of the connect probe, so a launcher that passed
386
+ // unknown classifier words through would read it as a dead socket and LAUNCH. A sandbox
387
+ // `node` answers for the classifier's basename only and hands every other invocation to
388
+ // the real Node — a test fixture on this gate's PATH, not a seam the product reads.
389
+ const oddClassifierHome = path.join(root, "odd-classifier-home");
390
+ const oddClassifierBin = path.join(root, "odd-classifier-bin");
391
+ mkdirSync(oddClassifierBin, { recursive: true });
392
+ const oddNode = path.join(oddClassifierBin, "node");
393
+ writeFileSync(
394
+ oddNode,
395
+ "#!/usr/bin/env bash\n" +
396
+ 'for a in "$@"; do case "$a" in */codex-socket-classify.js) printf \'stale\'; exit 0 ;; esac; done\n' +
397
+ `exec ${JSON.stringify(process.execPath)} "$@"\n`,
398
+ );
399
+ chmodSync(oddNode, 0o755);
400
+ const c = launch([], {
401
+ CODEX_HOME: oddClassifierHome,
402
+ PATH: `${oddClassifierBin}:${bin}:${process.env.PATH ?? ""}`,
403
+ });
368
404
  ok(
369
405
  "[QK:CODEX-APP-SERVER-REFUSES-UNRECOGNISED-PROBE] a socket classifier that exits 0 with a reading nobody wrote REFUSES instead of falling through to the exec",
370
406
  r.status !== 0 && r.out.includes("codex-app-server-socket-probe-unrecognised") && r.args.length === 0,
371
407
  );
408
+ ok(
409
+ `[QK:CODEX-APP-SERVER-REFUSES-UNRECOGNISED-CLASSIFIER] a classifier word the classifier never gives is refused before it can be read as one of the probe's own verdicts — 'stale' would otherwise launch (status ${c.status}, ${c.args.length} vendor args)`,
410
+ c.status !== 0 &&
411
+ c.out.includes("codex-app-server-socket-probe-unrecognised") &&
412
+ c.out.includes("the socket classifier answered\n 'stale'") &&
413
+ c.args.length === 0,
414
+ );
372
415
  }
373
416
 
374
417
  // ── 5. refusals that keep this from becoming something it is not ────────────