@junghanacs/entwurf 0.21.0 → 0.22.0

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 (45) hide show
  1. package/AGENTS.md +2 -2
  2. package/BASELINE.md +3 -1
  3. package/CHANGELOG.md +278 -0
  4. package/DELIVERY.md +156 -26
  5. package/README.md +64 -13
  6. package/VERIFY.md +67 -11
  7. package/docs/external-mcp-host.md +16 -6
  8. package/docs/setup-clean-host.md +63 -22
  9. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +40 -16
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-caller-seat.js +174 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +194 -1
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -2
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +8 -4
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +160 -25
  15. package/mcp/entwurf-bridge/dist/scripts/codex-socket-path.js +30 -0
  16. package/mcp/entwurf-bridge/src/index.ts +50 -16
  17. package/mcp/entwurf-bridge/tsconfig.build.json +9 -0
  18. package/package.json +2 -2
  19. package/pi-extensions/entwurf-control.ts +4 -4
  20. package/pi-extensions/lib/codex-caller-seat.ts +204 -0
  21. package/pi-extensions/lib/codex-fresh-preflight.ts +218 -1
  22. package/pi-extensions/lib/entwurf-v2-runner.ts +3 -2
  23. package/pi-extensions/lib/entwurf-v2-send.ts +16 -11
  24. package/pi-extensions/lib/mux-fresh-call.ts +196 -37
  25. package/run.sh +127 -3
  26. package/scripts/check-codex-app-server-launch.ts +445 -0
  27. package/scripts/check-entwurf-v2-production.ts +42 -1
  28. package/scripts/check-entwurf-v2-send.ts +26 -7
  29. package/scripts/check-gate-qualification.ts +4 -2
  30. package/scripts/check-mux-launch-tmux.ts +331 -35
  31. package/scripts/codex-app-server-launch.sh +275 -0
  32. package/scripts/codex-socket-path.ts +33 -0
  33. package/scripts/codex-terminal-title-config.py +500 -0
  34. package/scripts/codex_toml_io.py +121 -0
  35. package/scripts/lib/codex-fresh-live-protocol.ts +11 -3
  36. package/scripts/lib/codex-fresh-source-receipts.ts +29 -2
  37. package/scripts/mutants/codex-app-server-launch.json +157 -0
  38. package/scripts/mutants/codex-caller-seat.json +336 -0
  39. package/scripts/mutants/codex-native.json +3 -3
  40. package/scripts/mutants/mux-fresh-call.json +86 -14
  41. package/scripts/mutants/v2-surface.json +22 -0
  42. package/scripts/smoke-codex-config-state.sh +192 -3
  43. package/scripts/smoke-codex-fresh-live.ts +277 -37
  44. package/scripts/smoke-entwurf-chain-live.ts +50 -0
  45. package/scripts/smoke-setup-verdict.sh +13 -11
@@ -1,16 +1,40 @@
1
1
  /**
2
- * First-admission LIVE acceptance for Codex visible fresh.
2
+ * First-admission LIVE acceptance for Codex visible fresh — reshaped for the #95 lane B caller
3
+ * seat.
3
4
  *
4
- * The operator owns the existing exact `codex` tmux home and Codex app-server. This smoke neither
5
- * creates nor supervises them: an explicit ENTWURF_CODEX_APP_SERVER_PID names the already-running
6
- * process whose /proc environment must corroborate the home. It starts from a DIFFERENT caller
7
- * session; omitted Codex placement selects the home by exact name. The attached TUI pane is never guessed.
5
+ * The operator owns the Codex app-server and the tmux session it sits in (#95 D1 retired the fixed
6
+ * `codex` home). This smoke neither creates nor supervises them: an explicit
7
+ * ENTWURF_CODEX_APP_SERVER_PID names the already-running process whose /proc environment must
8
+ * corroborate its seat. The attached TUI pane is never guessed.
9
+ *
10
+ * ── THE TOPOLOGY, AND WHY IT IS DELIBERATELY SPLIT (#95 lane B) ──
11
+ *
12
+ * Four coordinates on ONE tmux server, in TWO sessions:
13
+ *
14
+ * A = wherever the operator started their app-server, and it must NOT be S. This is the
15
+ * session the app-server's inherited `TMUX` names — the value a Codex MCP child would
16
+ * fall back to. Its NAME does not matter: #95 D1 retired the fixed `codex` home, so the
17
+ * operator seats the app-server wherever they like and Entwurf never asks which room.
18
+ * S = the fixture's own session. The initial Pi caller opens here.
19
+ *
20
+ * initial Pi → S (the fixture's own seat)
21
+ * fresh Codex → S (hop 1, omitted placement: the caller's own session)
22
+ * outbound Pi → S (hop 2, omitted placement — and this is the claim)
23
+ *
24
+ * Hop 2 is the whole point. Its seat is decided by the Codex caller's own pane title
25
+ * (`codex-title-anchor`), and the proof is that it lands in S: the app-server's environment
26
+ * points at A, so an env fallback — the pre-#95 behaviour — would have put it there. S is
27
+ * reachable only through the anchor. A receipt that says `codex-title-anchor` AND a session
28
+ * that is not A are two independent halves of the same claim, and both are asserted.
29
+ *
30
+ * A ≠ S is therefore a PRECONDITION of this card, not a convenience: run it with the app-server
31
+ * in the fixture's own session and hop 2 proves nothing, because both rules would answer S.
8
32
  *
9
33
  * RELEASE MUST once Codex is admitted. LIVE!=1 is the only host-prerequisite SKIP. With LIVE=1,
10
- * missing system birth, user MCP/status config, app-server, models, runtime, bridge, or tmux seat
11
- * is a FAIL. The smoke drives only public MCP calls, then joins Pi's native toolCall/toolResult
12
- * rows and Codex app-server thread events as receipt authority; screen text and model-reformatted
13
- * reports are never oracles.
34
+ * missing system birth, user MCP/status/terminal-title config, app-server, models, runtime,
35
+ * bridge, or tmux seat is a FAIL. The smoke drives only public MCP calls, then joins Pi's native
36
+ * toolCall/toolResult rows and Codex app-server thread events as receipt authority; screen text
37
+ * and model-reformatted reports are never oracles.
14
38
  *
15
39
  * A record-backed self-fetch fixture opens a real visible Pi caller through public fresh_call.
16
40
  * That Pi opens Codex, correlates Codex's raw callback, and addresses Codex through v2. Codex then
@@ -24,7 +48,14 @@ import { createHash } from "node:crypto";
24
48
  import * as fs from "node:fs";
25
49
  import * as os from "node:os";
26
50
  import * as path from "node:path";
27
- import { CODEX_PREFLIGHT_HINT, codexFreshPreflight } from "../pi-extensions/lib/codex-fresh-preflight.ts";
51
+ import {
52
+ CODEX_CALLER_PREFLIGHT_HINT,
53
+ CODEX_LAUNCH_CWD_PREFLIGHT_HINT,
54
+ CODEX_PREFLIGHT_HINT,
55
+ codexCallerFreshPreflight,
56
+ codexFreshPreflight,
57
+ codexLaunchCwdFreshPreflight,
58
+ } from "../pi-extensions/lib/codex-fresh-preflight.ts";
28
59
  import {
29
60
  defaultMetaMailboxDir,
30
61
  defaultMetaSessionsDir,
@@ -35,7 +66,7 @@ import {
35
66
  writeMetaReceiverMarker,
36
67
  writeMetaSenderMarker,
37
68
  } from "../pi-extensions/lib/meta-session.ts";
38
- import { CODEX_HOME_TMUX_SESSION, FRESH_CALL_BACKENDS } from "../pi-extensions/lib/mux-fresh-call.ts";
69
+ import { FRESH_CALL_BACKENDS } from "../pi-extensions/lib/mux-fresh-call.ts";
39
70
  import {
40
71
  readCodexThread,
41
72
  realCodexProtocolOpener,
@@ -211,6 +242,68 @@ function inspectTmuxCoordinate(label: string, env: NodeJS.ProcessEnv): TmuxCoord
211
242
  };
212
243
  }
213
244
 
245
+ /**
246
+ * The session NAME behind a coordinate. Read on its own rather than added to the shared
247
+ * `TMUX_COORDINATE_FORMAT`: that row's field count is a pinned contract with its own
248
+ * deterministic oracle, and this card needs a name for exactly two reasons — to hand the fresh
249
+ * hand the fresh call a seat it can address, and to record which rooms A and S actually were.
250
+ */
251
+ function tmuxSessionName(label: string, env: NodeJS.ProcessEnv, paneId: string): string {
252
+ const inspected = spawnSync("tmux", ["display-message", "-p", "-t", paneId, "#{session_name}"], {
253
+ env,
254
+ encoding: "utf8",
255
+ });
256
+ ok(
257
+ `${label} session name answers through its own environment`,
258
+ inspected.status === 0,
259
+ String(inspected.stderr ?? ""),
260
+ );
261
+ const name = String(inspected.stdout ?? "").replace(/\r?\n$/, "");
262
+ ok(`${label} session name is non-empty`, name.length > 0, `name=${JSON.stringify(name)}`);
263
+ return name;
264
+ }
265
+
266
+ /**
267
+ * The DIRECTORY behind a pane, read from tmux itself. Its own reader for the same reason the
268
+ * session name has one: this card needs it for exactly one claim (#95 lane C — where a sibling
269
+ * actually started), and the pinned coordinate row stays a fixed field count.
270
+ *
271
+ * `[측정 2026-09-16]` this value is read from the child's `/proc` at QUERY time and tmux chdirs
272
+ * in that child AFTER forking it, so a read taken in the same millisecond as a launch can still
273
+ * answer the SERVER's directory. Every call here happens after that citizen's own callback has
274
+ * already arrived, which is long past exec — the deterministic gate that reads it straight after
275
+ * a launch is the one that has to wait (`scripts/check-mux-launch-tmux.ts`).
276
+ */
277
+ function tmuxPaneCwd(label: string, env: NodeJS.ProcessEnv, paneId: string): string {
278
+ const inspected = spawnSync("tmux", ["display-message", "-p", "-t", paneId, "#{pane_current_path}"], {
279
+ env,
280
+ encoding: "utf8",
281
+ });
282
+ ok(
283
+ `${label} pane directory answers through its own environment`,
284
+ inspected.status === 0,
285
+ String(inspected.stderr ?? ""),
286
+ );
287
+ const dir = String(inspected.stdout ?? "").replace(/\r?\n$/, "");
288
+ ok(`${label} pane directory is non-empty`, dir.length > 0, `cwd=${JSON.stringify(dir)}`);
289
+ return dir;
290
+ }
291
+
292
+ /** A live process's own working directory, read from the kernel rather than from any report. */
293
+ function processCwd(pid: number): string {
294
+ return fs.readlinkSync(`/proc/${pid}/cwd`);
295
+ }
296
+
297
+ /** The `session_meta` header of a Codex rollout — the VENDOR's own record of where a thread opened. */
298
+ function rolloutSessionMetaCwd(transcriptPath: string): string {
299
+ const first = fs.readFileSync(transcriptPath, "utf8").split("\n", 1)[0] ?? "";
300
+ const row = JSON.parse(first) as { type?: unknown; payload?: { cwd?: unknown } };
301
+ if (row.type !== "session_meta") throw new Error(`rollout row 1 is ${JSON.stringify(row.type)}, not session_meta`);
302
+ const cwd = row.payload?.cwd;
303
+ if (typeof cwd !== "string" || cwd.length === 0) throw new Error("rollout session_meta carries no cwd string");
304
+ return cwd;
305
+ }
306
+
214
307
  function cleanupWindows(): string[] {
215
308
  const failures: string[] = [];
216
309
  if (!appServerEnv) return failures;
@@ -629,15 +722,32 @@ async function run(): Promise<void> {
629
722
  const appServerCoordinate = inspectTmuxCoordinate("Codex app-server/MCP inherited seat", appServerEnv);
630
723
  const fixtureCoordinate = inspectTmuxCoordinate("fixture/initial-Pi caller seat", process.env);
631
724
  ok(
632
- "the fixture/initial-Pi seat and Codex home share one tmux server but are different sessions",
725
+ "the fixture/initial-Pi seat S and the app-server seat A share one tmux server but are different sessions",
633
726
  fixtureCoordinate.serverPid === appServerCoordinate.serverPid &&
634
727
  fixtureCoordinate.sessionId !== appServerCoordinate.sessionId,
635
728
  `fixture=${fixtureCoordinate.serverPid}/${fixtureCoordinate.sessionId} app-server=${appServerCoordinate.serverPid}/${appServerCoordinate.sessionId}`,
636
729
  );
730
+ // A and S, by NAME — recorded so the receipt says which rooms this run actually used. The
731
+ // load-bearing fact is A ≠ S, already asserted above: "the env fallback would have said A"
732
+ // is only a proof when A is a session the anchor could not have chosen. Since #95 D1 there
733
+ // is no required NAME for either — the operator seats the app-server wherever they like.
734
+ const appServerSessionName = tmuxSessionName(
735
+ "Codex app-server/MCP inherited seat",
736
+ appServerEnv,
737
+ appServerCoordinate.paneId,
738
+ );
739
+ const fixtureSessionName = tmuxSessionName("fixture/initial-Pi caller seat", process.env, fixtureCoordinate.paneId);
740
+ ok(
741
+ "the app-server's own session is a DIFFERENT room from the fixture's, which is what makes hop 2 decisive",
742
+ appServerSessionName !== fixtureSessionName,
743
+ `app-server=${JSON.stringify(appServerSessionName)} fixture=${JSON.stringify(fixtureSessionName)}`,
744
+ );
637
745
  receipts["1-codex-app-server-mcp-coordinate"] =
638
746
  `pid=${appServerPid}\nargv=${argv.join(" ")}\nserver=${appServerCoordinate.serverPid}\n` +
639
- `session=${appServerCoordinate.sessionId}\nwindow=${appServerCoordinate.windowId}\npane=${appServerCoordinate.paneId}\n` +
640
- `fixture-session=${fixtureCoordinate.sessionId}\nfixture-window=${fixtureCoordinate.windowId}`;
747
+ `session=${appServerCoordinate.sessionId}\nname=${appServerSessionName}\n` +
748
+ `window=${appServerCoordinate.windowId}\npane=${appServerCoordinate.paneId}\n` +
749
+ `fixture-session=${fixtureCoordinate.sessionId}\nfixture-name=${fixtureSessionName}\n` +
750
+ `fixture-window=${fixtureCoordinate.windowId}`;
641
751
 
642
752
  const codexModel = process.env.ENTWURF_CODEX_FRESH_MODEL?.trim() ?? "";
643
753
  const piModel = process.env.ENTWURF_CODEX_FRESH_PI_MODEL?.trim() ?? "";
@@ -654,6 +764,16 @@ async function run(): Promise<void> {
654
764
  missing === null,
655
765
  missing === null ? "" : `${missing}: ${CODEX_PREFLIGHT_HINT[missing]}`,
656
766
  );
767
+ // The CALLER axis, and a different fact from the five above: hop 2 is made BY a Codex
768
+ // citizen, so this host's `[tui].terminal_title` must carry `thread-id` or that citizen has
769
+ // no findable pane. Asserted here, before any window opens, because the alternative is
770
+ // discovering it three model turns later as a seat refusal.
771
+ const callerMissing = codexCallerFreshPreflight(appServerEnv);
772
+ ok(
773
+ "the installed Codex CALLER seat config is green — tui.terminal_title carries thread-id",
774
+ callerMissing === null,
775
+ callerMissing === null ? "" : `${callerMissing}: ${CODEX_CALLER_PREFLIGHT_HINT[callerMissing]}`,
776
+ );
657
777
 
658
778
  // The fixture and every bridge it talks to must resolve the same real stores the app-server
659
779
  // forwards into Codex MCP children. This is not an isolated lookalike store.
@@ -663,8 +783,37 @@ async function run(): Promise<void> {
663
783
  else delete process.env[key];
664
784
  }
665
785
 
666
- const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-codex-fresh-live-"));
786
+ // A STABLE directory, not a fresh `mkdtemp` per run, and that is a folder-consent fact rather
787
+ // than a tidiness preference. `[측정 2026-09-16]` codex keys its project trust to the exact
788
+ // launch directory on this rail, so a new random scratch is an UNDECIDED folder every time:
789
+ // the sibling opens on the vendor's trust screen, starts no turn, writes no rollout and never
790
+ // calls back — which is exactly how the two unattended release-gate runs failed while the two
791
+ // runs a human sat through passed. One path, answered `Trust` once, keeps the operator's
792
+ // config from growing an entry per run. The path stays OUTSIDE the repo and is asserted
793
+ // different from the app-server's own directory below, which is all lane C needs of it.
794
+ //
795
+ // It is PRINTED rather than documented as a literal, because `os.tmpdir()` is a host fact:
796
+ // this process's `TMPDIR` when it has one and `/tmp` when it does not, and the trust key is
797
+ // the exact string either way. A doc that spelled it `$TMPDIR/...` would send an operator
798
+ // whose TMPDIR is unset to `/entwurf-codex-fresh-live` — a different directory, in the root
799
+ // of the filesystem, that the vendor would then ask about separately.
800
+ const scratch = path.join(os.tmpdir(), "entwurf-codex-fresh-live");
801
+ fs.mkdirSync(scratch, { recursive: true, mode: 0o700 });
667
802
  fixtureArtifacts.push(scratch);
803
+ console.log(` launch-cwd ${scratch}`);
804
+ // Named HERE, before anything is launched, because the same refusal arriving 20 assertions
805
+ // later reads as a callback timeout — the failure mode this whole comment exists to retire.
806
+ // The detail carries the REPAIR COMMAND with the resolved path already in it, so the answer
807
+ // cannot be given for a near-miss directory.
808
+ const untrustedScratch = codexLaunchCwdFreshPreflight(appServerEnv, scratch);
809
+ ok(
810
+ "the Codex launch directory carries the vendor's own trust receipt, so a sibling opened there starts its first turn",
811
+ untrustedScratch === null,
812
+ untrustedScratch === null
813
+ ? ""
814
+ : `${untrustedScratch}: ${CODEX_LAUNCH_CWD_PREFLIGHT_HINT[untrustedScratch]}\n` +
815
+ ` repair (this host, exact directory): codex -C ${scratch} → answer \`Trust\``,
816
+ );
668
817
  fs.mkdirSync(path.join(REPO, ".probe-artifacts"), { recursive: true, mode: 0o700 });
669
818
  evidenceDir = fs.mkdtempSync(path.join(REPO, ".probe-artifacts", "codex-fresh-live-"));
670
819
  fs.chmodSync(evidenceDir, 0o700);
@@ -755,12 +904,12 @@ async function run(): Promise<void> {
755
904
  openedWindows.push(initialPiWindow);
756
905
  ok("the initial Pi LAUNCH receipt carries its exact callback nonce", initialPiNonce.length > 0);
757
906
  ok(
758
- "the initial Pi caller is visible in the fixture's non-Codex session",
907
+ "the initial Pi caller is visible in the fixture's own session S, not the app-server's A",
759
908
  initialPiTmuxSession === fixtureCoordinate.sessionId && initialPiTmuxSession !== appServerCoordinate.sessionId,
760
- `initial-pi=${initialPiTmuxSession} fixture=${fixtureCoordinate.sessionId} codex-home=${appServerCoordinate.sessionId}`,
909
+ `initial-pi=${initialPiTmuxSession} fixture=${fixtureCoordinate.sessionId} app-server=${appServerCoordinate.sessionId}`,
761
910
  );
762
911
  receipts["3-initial-caller-pi-coordinate"] =
763
- `session=${initialPiTmuxSession}\nwindow=${initialPiWindow}\ncodex-home-session=${appServerCoordinate.sessionId}`;
912
+ `session=${initialPiTmuxSession}\nwindow=${initialPiWindow}\napp-server-session=${appServerCoordinate.sessionId}`;
764
913
 
765
914
  let drainedCount = 0;
766
915
  const drain = (): void => {
@@ -849,7 +998,6 @@ async function run(): Promise<void> {
849
998
  finalToken,
850
999
  callerGid,
851
1000
  piModel,
852
- scratch,
853
1001
  });
854
1002
  const initialPiInstruction = buildInitialPiPhaseOne({
855
1003
  initialPiWaitToken,
@@ -880,7 +1028,12 @@ async function run(): Promise<void> {
880
1028
  selectExactSourceToolReceipt(
881
1029
  piSourceToolReceipts(readInitialPiEntries()),
882
1030
  "entwurf_fresh_call",
883
- { backend: "codex", model: codexModel, cwd: scratch, task: codexTask },
1031
+ {
1032
+ backend: "codex",
1033
+ model: codexModel,
1034
+ cwd: scratch,
1035
+ task: codexTask,
1036
+ },
884
1037
  "initial Pi Codex fresh call",
885
1038
  ),
886
1039
  );
@@ -897,15 +1050,21 @@ async function run(): Promise<void> {
897
1050
  );
898
1051
  if (!openedWindows.includes(codexWindow)) openedWindows.push(codexWindow);
899
1052
  ok("the source-owned Codex LAUNCH receipt carries its exact callback nonce", codexNonce.length > 0);
1053
+ // HOP 1. An omitted seat, which since #95 D1 means the CALLER's own session for every backend
1054
+ // — Codex no longer selects a fixed room. It is the setup rather than the claim: it puts the
1055
+ // Codex TUI in S so hop 2's answer can be told apart from the app-server's inherited A. The
1056
+ // receipt must name NO seat at all: the caller's own session is the absence of a seat rule,
1057
+ // not a rule with a name.
900
1058
  ok(
901
- "omitted placement opened fresh Codex in exact named `codex` home, resolved to the app-server session",
902
- codexLaunchSession === appServerCoordinate.sessionId &&
903
- codexLaunchReceipt.includes(
904
- `seat: ${CODEX_HOME_TMUX_SESSION} (Codex home tmux session, resolved to ${appServerCoordinate.sessionId})`,
905
- ),
906
- `codex=${codexLaunchSession} app-server=${appServerCoordinate.sessionId}`,
1059
+ "omitted placement opened fresh Codex in the caller's own session S, away from the app-server's A",
1060
+ codexLaunchSession === fixtureCoordinate.sessionId &&
1061
+ codexLaunchSession !== appServerCoordinate.sessionId &&
1062
+ !/^\s*seat:/m.test(codexLaunchReceipt),
1063
+ `codex=${codexLaunchSession} fixture=${fixtureCoordinate.sessionId} app-server=${appServerCoordinate.sessionId}`,
907
1064
  );
908
- receipts["7-fresh-codex-coordinate"] = `session=${codexLaunchSession}\nwindow=${codexWindow}`;
1065
+ receipts["7-fresh-codex-coordinate"] =
1066
+ `session=${codexLaunchSession}\nname=${fixtureSessionName}\nwindow=${codexWindow}\n` +
1067
+ `app-server-session=${appServerCoordinate.sessionId}\napp-server-name=${appServerSessionName}`;
909
1068
 
910
1069
  const codexCallbackSender = await awaitOrRecover(
911
1070
  "the exact Codex callback in initial Pi source",
@@ -932,6 +1091,31 @@ async function run(): Promise<void> {
932
1091
  receipts["8-initial-pi-source-codex-callback"] =
933
1092
  `nonce=${codexNonce}\ngarden=${codexGardenId}\nthread=${codexThreadId}`;
934
1093
 
1094
+ // #95 lane C (i). WHERE THE CODEX THREAD OPENED, from four authorities that cannot borrow
1095
+ // from each other: tmux's pane, the vendor's own rollout header, Entwurf's record, and the
1096
+ // directory this call requested. Before the `-C` carrier they disagreed while every receipt
1097
+ // still looked right — the pane sat in the requested scratch and the THREAD opened in the
1098
+ // app-server's repo, which the birth hook then recorded honestly. The app-server's live cwd
1099
+ // is read from the kernel and asserted DIFFERENT, because a scratch that happened to equal it
1100
+ // would make all four agree for the wrong reason.
1101
+ const codexPane = /^\s*pane:\s+(%\d+)/m.exec(codexLaunchReceipt)?.[1] ?? "";
1102
+ ok("the Codex LAUNCH receipt names its pane", /^%\d+$/.test(codexPane), codexLaunchReceipt);
1103
+ const appServerCwd = processCwd(appServerPid);
1104
+ const codexPaneCwd = tmuxPaneCwd("fresh Codex", process.env, codexPane);
1105
+ const codexRolloutCwd =
1106
+ codexIdentity.transcriptPath === null ? "" : rolloutSessionMetaCwd(codexIdentity.transcriptPath);
1107
+ ok(
1108
+ "the fresh Codex thread, its pane, its record and the requested cwd are ONE directory — and it is not the app-server's",
1109
+ codexPaneCwd === scratch &&
1110
+ codexRolloutCwd === scratch &&
1111
+ codexIdentity.cwd === scratch &&
1112
+ appServerCwd !== scratch,
1113
+ `pane=${codexPaneCwd} rollout=${codexRolloutCwd} record=${codexIdentity.cwd} requested=${scratch} app-server=${appServerCwd}`,
1114
+ );
1115
+ receipts["8b-codex-thread-cwd"] =
1116
+ `requested=${scratch}\npane=${codexPaneCwd}\nrollout-session_meta=${codexRolloutCwd}\n` +
1117
+ `record=${codexIdentity.cwd}\napp-server=${appServerCwd}`;
1118
+
935
1119
  const initialPiToCodex = await awaitOrRecover("the initial Pi joined native-push receipt", SOURCE_WAIT_MS, () => {
936
1120
  const piSources = piSourceToolReceipts(readInitialPiEntries());
937
1121
  assertSourceCallScopes(piSources, "entwurf_v2", "target", [callerGid, codexGardenId], "initial Pi v2 roles");
@@ -976,7 +1160,7 @@ async function run(): Promise<void> {
976
1160
  return selectExactSourceToolReceipt(
977
1161
  codexSourceToolReceipts(codexThread),
978
1162
  "entwurf_fresh_call",
979
- { backend: "pi", model: piModel, cwd: scratch, task: outboundPiTask },
1163
+ { backend: "pi", model: piModel, task: outboundPiTask },
980
1164
  "Codex outbound Pi fresh call",
981
1165
  );
982
1166
  },
@@ -993,15 +1177,39 @@ async function run(): Promise<void> {
993
1177
  );
994
1178
  if (!openedWindows.includes(outboundPiWindow)) openedWindows.push(outboundPiWindow);
995
1179
  ok("the source-owned outbound Pi LAUNCH receipt carries its exact nonce", outboundPiNonce.length > 0);
1180
+ // HOP 2 — THE CLAIM (#95 lane B). Two independent halves of one fact, and both are required.
1181
+ // The SEAT: this Pi landed in S, the session the Codex TUI is in. The app-server's inherited
1182
+ // `TMUX` names A, so the pre-#95 env fallback would have put it there; S is reachable only
1183
+ // through the caller's own pane title. The LABEL: the receipt says `codex-title-anchor`, so
1184
+ // the rule that chose it is named rather than inferred from a coincidence of sessions.
1185
+ ok(
1186
+ "omitted placement opened the outbound Pi beside the CODEX TUI in S — not in the app-server's home A",
1187
+ outboundPiSession === codexLaunchSession &&
1188
+ outboundPiSession === fixtureCoordinate.sessionId &&
1189
+ outboundPiSession !== appServerCoordinate.sessionId,
1190
+ `outbound-pi=${outboundPiSession} codex=${codexLaunchSession} fixture=${fixtureCoordinate.sessionId} app-server=${appServerCoordinate.sessionId}`,
1191
+ );
1192
+ ok(
1193
+ "the outbound Pi receipt names the title anchor as the rule that chose that seat, and no session name",
1194
+ /^\s*seat:\s+\$\d+ \(the Codex caller's own pane, found by its thread-id terminal title/m.test(outboundPiReceipt) &&
1195
+ !outboundPiReceipt.includes("requested tmux session"),
1196
+ outboundPiReceipt,
1197
+ );
1198
+ // The four coordinates, stated together so the shape is readable in one place. This claims
1199
+ // the CALLER-seat topology and nothing wider: no arbitrary attached-TUI inference, and no
1200
+ // claim about a Codex TUI this smoke did not open.
996
1201
  ok(
997
- "supported Codex-home topology: a Pi in another session opens Codex in exact home; Codex opens outbound Pi " +
998
- "beside itself and the operator-owned app-server. This does not claim arbitrary attached-TUI seat inference",
999
- initialPiTmuxSession !== appServerCoordinate.sessionId &&
1000
- codexLaunchSession === appServerCoordinate.sessionId &&
1001
- outboundPiSession === appServerCoordinate.sessionId,
1002
- `initial-pi=${initialPiTmuxSession} codex=${codexLaunchSession} app-server=${appServerCoordinate.sessionId} outbound-pi=${outboundPiSession}`,
1202
+ "supported caller-seat topology: initial Pi in S, Codex in S, Codex opens outbound Pi in S, " +
1203
+ "while the operator-owned app-server stays in its own session A",
1204
+ initialPiTmuxSession === fixtureCoordinate.sessionId &&
1205
+ codexLaunchSession === fixtureCoordinate.sessionId &&
1206
+ outboundPiSession === fixtureCoordinate.sessionId &&
1207
+ appServerCoordinate.sessionId !== fixtureCoordinate.sessionId,
1208
+ `initial-pi=${initialPiTmuxSession} codex=${codexLaunchSession} outbound-pi=${outboundPiSession} app-server=${appServerCoordinate.sessionId}`,
1003
1209
  );
1004
- receipts["12-outbound-fresh-pi-coordinate"] = `session=${outboundPiSession}\nwindow=${outboundPiWindow}`;
1210
+ receipts["12-outbound-fresh-pi-coordinate"] =
1211
+ `session=${outboundPiSession}\nwindow=${outboundPiWindow}\ncodex-session=${codexLaunchSession}\n` +
1212
+ `app-server-session=${appServerCoordinate.sessionId}\nseat-source=codex-title-anchor`;
1005
1213
 
1006
1214
  const outboundPiGid = await untilAsync(
1007
1215
  "the exact outbound Pi callback in Codex source",
@@ -1029,6 +1237,33 @@ async function run(): Promise<void> {
1029
1237
  });
1030
1238
  receipts["13-codex-source-outbound-pi-callback"] = `nonce=${outboundPiNonce}\ngarden=${outboundPiGid}`;
1031
1239
 
1240
+ // #95 lane C (ii) — THE CLAIM. This leg named NO cwd, so the only directory the composition
1241
+ // could use is the Codex caller's own record. Its oracle is independent twice over: the pane
1242
+ // answers tmux, and the outbound Pi's RECORD is written by that Pi's own birth from its own
1243
+ // process directory — neither reads the launch receipt. Before lane C this landed in the
1244
+ // app-server's repo, because the bridge is the app-server's MCP child and an omitted cwd
1245
+ // inherited ITS directory; the app-server's live cwd is asserted different so that failure
1246
+ // mode stays visible rather than coincidentally equal.
1247
+ const outboundPiPane = /^\s*pane:\s+(%\d+)/m.exec(outboundPiReceipt)?.[1] ?? "";
1248
+ ok("the outbound Pi LAUNCH receipt names its pane", /^%\d+$/.test(outboundPiPane), outboundPiReceipt);
1249
+ const outboundPiPaneCwd = tmuxPaneCwd("outbound Pi", process.env, outboundPiPane);
1250
+ ok(
1251
+ "with NO cwd requested, the outbound Pi opened in the CODEX CALLER's own record directory — not in the app-server's",
1252
+ outboundPiPaneCwd === codexIdentity.cwd &&
1253
+ outboundPiIdentity.cwd === codexIdentity.cwd &&
1254
+ outboundPiPaneCwd !== appServerCwd,
1255
+ `pane=${outboundPiPaneCwd} outbound-record=${outboundPiIdentity.cwd} codex-record=${codexIdentity.cwd} app-server=${appServerCwd}`,
1256
+ );
1257
+ ok(
1258
+ "the outbound Pi receipt NAMES the caller-record rule rather than calling that directory requested",
1259
+ outboundPiReceipt.includes(`cwd: ${codexIdentity.cwd} (the Codex caller's own record directory`) &&
1260
+ !outboundPiReceipt.includes("requested start directory"),
1261
+ outboundPiReceipt,
1262
+ );
1263
+ receipts["13b-outbound-pi-cwd"] =
1264
+ `requested=<none>\npane=${outboundPiPaneCwd}\nrecord=${outboundPiIdentity.cwd}\n` +
1265
+ `codex-caller-record=${codexIdentity.cwd}\napp-server=${appServerCwd}`;
1266
+
1032
1267
  const expectedFinalMessage =
1033
1268
  `${finalToken}\nPI_LAUNCH_NONCE=${outboundPiNonce}\nPI_CALLBACK_NONCE=${outboundPiNonce}\n` +
1034
1269
  `PI_CALLBACK_FROM=${outboundPiGid}\nPI_SESSION_ID=${outboundPiSession}\nPI_WINDOW_ID=${outboundPiWindow}`;
@@ -1101,7 +1336,12 @@ async function run(): Promise<void> {
1101
1336
  },
1102
1337
  {
1103
1338
  toolName: "entwurf_fresh_call",
1104
- arguments: { backend: "codex", model: codexModel, cwd: scratch, task: codexTask },
1339
+ arguments: {
1340
+ backend: "codex",
1341
+ model: codexModel,
1342
+ cwd: scratch,
1343
+ task: codexTask,
1344
+ },
1105
1345
  },
1106
1346
  {
1107
1347
  toolName: "entwurf_v2",
@@ -1129,7 +1369,7 @@ async function run(): Promise<void> {
1129
1369
  },
1130
1370
  {
1131
1371
  toolName: "entwurf_fresh_call",
1132
- arguments: { backend: "pi", model: piModel, cwd: scratch, task: outboundPiTask },
1372
+ arguments: { backend: "pi", model: piModel, task: outboundPiTask },
1133
1373
  },
1134
1374
  {
1135
1375
  toolName: "entwurf_v2",
@@ -165,6 +165,49 @@ async function main(): Promise<void> {
165
165
  const receiversDir = path.join(world, "receivers");
166
166
  const sendersDir = path.join(world, "senders");
167
167
  for (const d of [sessionsDir, mailboxDir, receiversDir, sendersDir]) await fsp.mkdir(d, { recursive: true });
168
+ // THE WORLD'S STANDING INSTRUCTION — the authority channel for hop 1, and the reason the
169
+ // payload itself says nothing about who authorised it.
170
+ //
171
+ // `[측정 2026-09-17]` a native Claude Code hop declined to forward, twice, and both refusals
172
+ // were CORRECT readings of the operator's own global rules ("entwurf는 GLG가 명시적으로
173
+ // 요청할 때만 사용", "다른 sibling에게 일방적으로 작업을 전달하지 않는다"). The second refusal
174
+ // went further and named a provenance sentence inside the prompt as the tell it is: content
175
+ // that vouches for itself is what an injection looks like, so no amount of asserting inside
176
+ // the payload can settle the question — the more insistent the sentence, the worse it reads.
177
+ //
178
+ // What actually separates this gate from an injection is not the message but the DIRECTORY
179
+ // the turn is started in: a disposable world this gate just minted, whose every
180
+ // `ENTWURF_META_*_DIR` points inside it, created because the operator ran `release-gate` on
181
+ // this host. So the fact is written where a standing operator instruction belongs, and the
182
+ // operator's own global policy is what makes it load-bearing — `agent-config/home/AGENTS.md`
183
+ // says "For repo-specific behavior, obey the nearest AGENTS.md". Both filenames are written
184
+ // because the harnesses that can host a hop do not agree on which one they read.
185
+ //
186
+ // This is not a "do not check your policy" instruction, which would buy the same green by
187
+ // asking the model to stop reading and would keep passing after the chain broke. The hop is
188
+ // free to refuse; what it gains is the one fact it had no other way to have.
189
+ const worldStanding = [
190
+ "# entwurf release-gate scratch world",
191
+ "",
192
+ "This directory was created moments ago by entwurf's release gate `smoke-entwurf-chain-live`,",
193
+ "which the operator (GLG) started on this host by running `release-gate`. It holds fixture",
194
+ "citizens and their mailboxes and nothing else, and it is deleted when the gate ends.",
195
+ "",
196
+ "The gate measures one capability: that a payload handed to a hop reaches a mailbox terminus",
197
+ "through three sibling hops, carrying the right sender identity at each one. A turn started in",
198
+ "this directory IS one of those hops.",
199
+ "",
200
+ "- Inside this directory, forwarding the payload a hop is given to the target it names, with",
201
+ " `entwurf_v2`, is the operator's own request. It is the subject under measurement — not a",
202
+ " delegation the model chose, and not a peer lane opened on the model's initiative.",
203
+ "- Every target is a fixture citizen this gate created in this world; the `ENTWURF_META_*_DIR`",
204
+ " carriers all resolve inside this directory, so nothing outside it is addressed.",
205
+ "- The standing rule that entwurf is used only when GLG asks is satisfied by GLG having",
206
+ " started the gate. This file is the nearest `AGENTS.md`, which the operator's global",
207
+ " `AGENTS.md` defers to for behaviour scoped to a directory.",
208
+ "",
209
+ ].join("\n");
210
+ for (const name of ["AGENTS.md", "CLAUDE.md"]) await fsp.writeFile(path.join(world, name), worldStanding);
168
211
  const worldEnv = {
169
212
  ENTWURF_META_SESSIONS_DIR: sessionsDir,
170
213
  ENTWURF_META_MAILBOX_DIR: mailboxDir,
@@ -295,6 +338,13 @@ async function main(): Promise<void> {
295
338
  hop3,
296
339
  "INNER-END",
297
340
  ].join("\n");
341
+ // The payload carries NO claim about its own authority, and that is deliberate.
342
+ // `[측정 2026-09-17]` a first attempt put the provenance in these very lines — "this prompt is
343
+ // issued by entwurf's release gate, which the operator started" — and the hop named those
344
+ // sentences as the tell: *"컨텐츠가 '이것은 위임이 아니라 승인된 테스트 대상 자체다'라고
345
+ // 스스로를 정당화하는 것도 전형적인 수법입니다."* It was right. A message that vouches for
346
+ // itself is exactly the shape of an injection, so authority cannot ride the payload; it rides
347
+ // the WORLD this turn is started in (see the standing instruction written next to `world`).
298
348
  const hop1Prompt = [
299
349
  "You are the first hop of a delivery chain. Do exactly this and nothing else.",
300
350
  `Call the tool mcp__entwurf-bridge__entwurf_v2 exactly once with target=${gidB}, intent=fire-and-forget,`,
@@ -354,8 +354,8 @@ want "[QK:CODEX-SETUP-NEVER-PRESEEDS-TRUST] S-8c: setup wrote NO vendor trust re
354
354
  "! grep -q 'hooks.state' '$CODEX_HOME_SANDBOX/config.toml' && ! grep -q 'trusted_hash' '$CODEX_HOME_SANDBOX/config.toml'"
355
355
  want "S-8c: detected-incomplete Codex is a named nonzero result, never an absent SKIP [QK:CODEX-SETUP-BIRTH-COSMETIC-PASS]" \
356
356
  "[ '$RC' -ne 0 ] && printf '%s' \"\$OUT\" | grep -q 'codex-birth: FAIL' && ! printf '%s' \"\$OUT\" | grep -q 'codex: SKIP'"
357
- want "S-8c: both user-owned Codex atoms still compose independently as PASS [QK:CODEX-SETUP-BIRTH-INDEPENDENT]" \
358
- "printf '%s' \"\$OUT\" | grep -q 'codex-mcp: PASS' && printf '%s' \"\$OUT\" | grep -q 'codex-statusline: PASS'"
357
+ want "S-8c: all three user-owned Codex atoms still compose independently as PASS [QK:CODEX-SETUP-BIRTH-INDEPENDENT]" \
358
+ "printf '%s' \"\$OUT\" | grep -q 'codex-mcp: PASS' && printf '%s' \"\$OUT\" | grep -q 'codex-statusline: PASS' && printf '%s' \"\$OUT\" | grep -q 'codex-terminal-title: PASS'"
359
359
  want "S-8c: summary is NON-GREEN and attributes it to the untrusted birth unit" \
360
360
  "printf '%s' \"\$OUT\" | grep -q 'NON-GREEN (FAIL: codex-birth)' && ! printf '%s' \"\$OUT\" | grep -q 'result: green'"
361
361
  want "S-8c: the reason names the operator's one-time answer, not a repair setup could have made" \
@@ -364,23 +364,25 @@ want "S-8c: setup PUBLISHED the birth unit — declaration, launcher closure and
364
364
  "[ -f '$CODEX_HOME_SANDBOX/hooks.json' ] && [ -x '$CODEX_BIRTH_UNIT_ROOT/helper/codex-birth-launch.sh' ] && [ -f '$CODEX_BIRTH_UNIT_ROOT/install-state.json' ]"
365
365
  CODEX_MCP_STATE="$XDG_DATA_HOME/entwurf/codex-mcp/install-state.json"
366
366
  CODEX_STATUSLINE_STATE="$XDG_DATA_HOME/entwurf/codex-statusline/install-state.json"
367
- want "S-8c: both user atoms wrote their package-owned install states inside the sandbox" \
368
- "[ -f '$CODEX_MCP_STATE' ] && [ -f '$CODEX_STATUSLINE_STATE' ] && grep -q '\"atom\": \"codex-mcp\"' '$CODEX_MCP_STATE' && grep -q '\"atom\": \"codex-statusline\"' '$CODEX_STATUSLINE_STATE'"
369
- want "S-8c: MCP and visible-identity atoms landed in the sandbox Codex config" \
370
- "grep -q '\\[mcp_servers\\.entwurf-bridge\\]' '$CODEX_HOME_SANDBOX/config.toml' && grep -q 'status_line = \\[\"thread-title\", \"model-with-reasoning\"\\]' '$CODEX_HOME_SANDBOX/config.toml'"
367
+ CODEX_TERMINAL_TITLE_STATE="$XDG_DATA_HOME/entwurf/codex-terminal-title/install-state.json"
368
+ want "S-8c: all three user atoms wrote their package-owned install states inside the sandbox" \
369
+ "[ -f '$CODEX_MCP_STATE' ] && [ -f '$CODEX_STATUSLINE_STATE' ] && [ -f '$CODEX_TERMINAL_TITLE_STATE' ] && grep -q '\"atom\": \"codex-mcp\"' '$CODEX_MCP_STATE' && grep -q '\"atom\": \"codex-statusline\"' '$CODEX_STATUSLINE_STATE' && grep -q '\"atom\": \"codex-terminal-title\"' '$CODEX_TERMINAL_TITLE_STATE'"
370
+ want "S-8c: MCP, visible-identity and caller-seat atoms landed in the sandbox Codex config" \
371
+ "grep -q '\\[mcp_servers\\.entwurf-bridge\\]' '$CODEX_HOME_SANDBOX/config.toml' && grep -q 'status_line = \\[\"thread-title\", \"model-with-reasoning\"\\]' '$CODEX_HOME_SANDBOX/config.toml' && grep -q 'terminal_title = \\[\"activity\", \"project-name\", \"thread-id\"\\]' '$CODEX_HOME_SANDBOX/config.toml'"
371
372
  want "S-8c: unrelated operator config survives both atom writers" \
372
373
  "grep -q 'model = \"operator-model\"' '$CODEX_HOME_SANDBOX/config.toml' && grep -q '\\[mcp_servers\\.operator-owned\\]' '$CODEX_HOME_SANDBOX/config.toml' && grep -q 'command = \"/operator/bin/server\"' '$CODEX_HOME_SANDBOX/config.toml'"
373
374
  CODEX_CONFIG_AFTER="$(sha256sum "$CODEX_HOME_SANDBOX/config.toml" | cut -d' ' -f1)"
374
375
  CODEX_MCP_STATE_AFTER="$(sha256sum "$CODEX_MCP_STATE" | cut -d' ' -f1)"
375
376
  CODEX_STATUSLINE_STATE_AFTER="$(sha256sum "$CODEX_STATUSLINE_STATE" | cut -d' ' -f1)"
377
+ CODEX_TERMINAL_TITLE_STATE_AFTER="$(sha256sum "$CODEX_TERMINAL_TITLE_STATE" | cut -d' ' -f1)"
376
378
  set +e
377
379
  OUT2="$(CODEX_BIN="$FAKE_CODEX/codex" CODEX_HOME="$CODEX_HOME_SANDBOX" PATH="$FAKE_CODEX:$PATH" "$REAL_BASH" "$REPO_DIR/run.sh" setup "$PROJ8C" 2>&1)"
378
380
  RC2=$?
379
381
  set -e
380
- want "S-8c: second setup still reports the untrusted birth FAIL and both user atoms PASS" \
381
- "[ '$RC2' -ne 0 ] && printf '%s' \"\$OUT2\" | grep -q 'codex-birth: FAIL' && printf '%s' \"\$OUT2\" | grep -q 'codex-mcp: PASS' && printf '%s' \"\$OUT2\" | grep -q 'codex-statusline: PASS'"
382
- want "S-8c: second setup is byte-idempotent for config and both ownership receipts" \
383
- "[ \"\$(sha256sum '$CODEX_HOME_SANDBOX/config.toml' | cut -d' ' -f1)\" = '$CODEX_CONFIG_AFTER' ] && [ \"\$(sha256sum '$CODEX_MCP_STATE' | cut -d' ' -f1)\" = '$CODEX_MCP_STATE_AFTER' ] && [ \"\$(sha256sum '$CODEX_STATUSLINE_STATE' | cut -d' ' -f1)\" = '$CODEX_STATUSLINE_STATE_AFTER' ]"
382
+ want "S-8c: second setup still reports the untrusted birth FAIL and all three user atoms PASS" \
383
+ "[ '$RC2' -ne 0 ] && printf '%s' \"\$OUT2\" | grep -q 'codex-birth: FAIL' && printf '%s' \"\$OUT2\" | grep -q 'codex-mcp: PASS' && printf '%s' \"\$OUT2\" | grep -q 'codex-statusline: PASS' && printf '%s' \"\$OUT2\" | grep -q 'codex-terminal-title: PASS'"
384
+ want "S-8c: second setup is byte-idempotent for config and all three ownership receipts" \
385
+ "[ \"\$(sha256sum '$CODEX_HOME_SANDBOX/config.toml' | cut -d' ' -f1)\" = '$CODEX_CONFIG_AFTER' ] && [ \"\$(sha256sum '$CODEX_MCP_STATE' | cut -d' ' -f1)\" = '$CODEX_MCP_STATE_AFTER' ] && [ \"\$(sha256sum '$CODEX_STATUSLINE_STATE' | cut -d' ' -f1)\" = '$CODEX_STATUSLINE_STATE_AFTER' ] && [ \"\$(sha256sum '$CODEX_TERMINAL_TITLE_STATE' | cut -d' ' -f1)\" = '$CODEX_TERMINAL_TITLE_STATE_AFTER' ]"
384
386
  want "S-8c: second setup rechecks rather than faking the missing receipt" \
385
387
  "! printf '%s' \"\$OUT2\" | grep -q 'codex-birth: PASS' && ! grep -q 'trusted_hash' '$CODEX_HOME_SANDBOX/config.toml'"
386
388
 
@@ -398,7 +400,7 @@ want "[QK:CODEX-SETUP-TRUSTED-RERUN-GREEN] S-8c: once the vendor receipt exists
398
400
  want "S-8c: the trusted rerun is byte-idempotent — it neither rewrote the config nor touched the receipt" \
399
401
  "[ \"\$(sha256sum '$CODEX_HOME_SANDBOX/config.toml' | cut -d' ' -f1)\" = '$CODEX_CONFIG_TRUSTED' ]"
400
402
  want_auth_untouched "S-8c"
401
- rm -rf "$HOME/.codex" "$XDG_DATA_HOME/entwurf/codex-mcp" "$XDG_DATA_HOME/entwurf/codex-statusline" "$CODEX_BIRTH_UNIT_ROOT"
403
+ rm -rf "$HOME/.codex" "$XDG_DATA_HOME/entwurf/codex-mcp" "$XDG_DATA_HOME/entwurf/codex-statusline" "$XDG_DATA_HOME/entwurf/codex-terminal-title" "$CODEX_BIRTH_UNIT_ROOT"
402
404
 
403
405
  # ── S-9: rail certification is a SEPARATE axis from install success (#78 D1) ──
404
406
  # The 0.20.0 macOS lane opened the installers to Darwin (meta-bridge-install.sh's