@junghanacs/entwurf 0.20.1 → 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 (111) hide show
  1. package/AGENTS.md +85 -190
  2. package/BASELINE.md +6 -3
  3. package/CHANGELOG.md +473 -14
  4. package/CONTRIBUTING.md +1 -1
  5. package/DELIVERY.md +332 -60
  6. package/README.md +100 -22
  7. package/VERIFY.md +93 -7
  8. package/docs/acp-backend-rail.md +0 -1
  9. package/docs/external-mcp-host.md +64 -33
  10. package/docs/setup-clean-host.md +151 -17
  11. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +130 -114
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-caller-seat.js +174 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +519 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +22 -2
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +3 -1
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +6 -7
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +30 -17
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +7 -1
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -2
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +8 -4
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +7 -4
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +20 -11
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +227 -1
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +189 -19
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +10 -7
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/codex-ws-client.js +403 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +4 -4
  28. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-codex.js +325 -0
  29. package/mcp/entwurf-bridge/dist/scripts/codex-socket-path.js +30 -0
  30. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +6 -1
  31. package/mcp/entwurf-bridge/src/index.ts +154 -116
  32. package/mcp/entwurf-bridge/tsconfig.build.json +10 -0
  33. package/package.json +13 -4
  34. package/pi-extensions/entwurf-control.ts +71 -19
  35. package/pi-extensions/lib/codex-caller-seat.ts +204 -0
  36. package/pi-extensions/lib/codex-fresh-preflight.ts +580 -0
  37. package/pi-extensions/lib/compaction-send-guard.ts +80 -0
  38. package/pi-extensions/lib/entwurf-fact-provider.ts +29 -3
  39. package/pi-extensions/lib/entwurf-peers-render.ts +4 -1
  40. package/pi-extensions/lib/entwurf-v2-contract.ts +6 -7
  41. package/pi-extensions/lib/entwurf-v2-native-push.ts +35 -18
  42. package/pi-extensions/lib/entwurf-v2-production.ts +10 -3
  43. package/pi-extensions/lib/entwurf-v2-runner.ts +3 -2
  44. package/pi-extensions/lib/entwurf-v2-send.ts +16 -11
  45. package/pi-extensions/lib/entwurf-v2-surface.ts +7 -4
  46. package/pi-extensions/lib/meta-mailbox-body.ts +22 -13
  47. package/pi-extensions/lib/meta-sender-identity.ts +305 -0
  48. package/pi-extensions/lib/mux-fresh-call.ts +233 -29
  49. package/pi-extensions/lib/native-push/adapter.ts +21 -24
  50. package/pi-extensions/lib/native-push/codex-ws-client.ts +506 -0
  51. package/pi-extensions/lib/native-push/register.ts +7 -9
  52. package/pi-extensions/meta-bridge-hook-codex.ts +371 -0
  53. package/run.sh +342 -28
  54. package/scripts/check-agy-sender-identity.ts +1 -1
  55. package/scripts/check-codex-app-server-launch.ts +445 -0
  56. package/scripts/check-codex-birth-hook.ts +264 -0
  57. package/scripts/check-codex-bridge-identity.ts +179 -0
  58. package/scripts/check-codex-native-push.ts +386 -0
  59. package/scripts/check-codex-sender-identity.ts +495 -0
  60. package/scripts/check-compaction-send-guard.ts +130 -0
  61. package/scripts/check-copilot-receive-arm.ts +4 -1
  62. package/scripts/check-entwurf-fact-provider.ts +38 -0
  63. package/scripts/check-entwurf-peers-surface.ts +13 -1
  64. package/scripts/check-entwurf-self-address.ts +15 -16
  65. package/scripts/check-entwurf-v2-contract.ts +4 -3
  66. package/scripts/check-entwurf-v2-decider.ts +7 -5
  67. package/scripts/check-entwurf-v2-native-push.ts +35 -7
  68. package/scripts/check-entwurf-v2-production.ts +245 -12
  69. package/scripts/check-entwurf-v2-runner.ts +1 -1
  70. package/scripts/check-entwurf-v2-send.ts +26 -7
  71. package/scripts/check-entwurf-v2-surface.ts +1 -1
  72. package/scripts/check-gate-qualification.ts +8 -3
  73. package/scripts/check-harness-admission-parity.ts +0 -1
  74. package/scripts/check-mux-launch-tmux.ts +345 -4
  75. package/scripts/check-native-push-adapter.ts +20 -16
  76. package/scripts/check-native-push-register.ts +5 -1
  77. package/scripts/check-release-gate-outcomes.ts +47 -1
  78. package/scripts/check-setup-qualification.sh +3 -1
  79. package/scripts/codex-app-server-launch.sh +275 -0
  80. package/scripts/codex-birth-doctor.sh +276 -0
  81. package/scripts/codex-birth-install.sh +414 -0
  82. package/scripts/codex-birth-uninstall.sh +170 -0
  83. package/scripts/codex-mcp-config.py +435 -0
  84. package/scripts/codex-socket-path.ts +33 -0
  85. package/scripts/codex-statusline-config.py +434 -0
  86. package/scripts/codex-terminal-title-config.py +500 -0
  87. package/scripts/codex_toml_io.py +653 -0
  88. package/scripts/lib/codex-fresh-live-protocol.ts +121 -0
  89. package/scripts/lib/codex-fresh-source-receipts.ts +426 -0
  90. package/scripts/lib/launch-receipt-windows.ts +46 -0
  91. package/scripts/lib/tmux-coordinate-row.ts +58 -0
  92. package/scripts/meta-bridge-fresh-cut.ts +6 -1
  93. package/scripts/mutants/codex-app-server-launch.json +157 -0
  94. package/scripts/mutants/codex-caller-seat.json +336 -0
  95. package/scripts/mutants/codex-native.json +838 -0
  96. package/scripts/mutants/compaction-send-guard.json +103 -0
  97. package/scripts/mutants/entwurf-peers.json +19 -0
  98. package/scripts/mutants/mux-fresh-call.json +93 -10
  99. package/scripts/mutants/omp-fresh.json +6 -4
  100. package/scripts/mutants/release-gate.json +13 -0
  101. package/scripts/mutants/v2-surface.json +75 -1
  102. package/scripts/raw-async-delivery/README.md +2 -1
  103. package/scripts/raw-codex-measure/README.md +114 -46
  104. package/scripts/smoke-agy-native-push-live.ts +3 -1
  105. package/scripts/smoke-codex-birth.sh +347 -0
  106. package/scripts/smoke-codex-config-state.sh +700 -0
  107. package/scripts/smoke-codex-fresh-live.ts +1426 -0
  108. package/scripts/smoke-codex-native-push-live.ts +75 -0
  109. package/scripts/smoke-entwurf-chain-live.ts +50 -0
  110. package/scripts/smoke-setup-verdict.sh +125 -10
  111. package/scripts/tsconfig.json +1 -0
@@ -466,7 +466,7 @@ try {
466
466
  "probe alive → replyable true",
467
467
  (await probeNativeSenderAlive(
468
468
  identity,
469
- adapterWith(async () => ({ status: "alive", route: { lsAddress: "127.0.0.1:1" } })),
469
+ adapterWith(async () => ({ status: "alive", route: { backend: "antigravity", lsAddress: "127.0.0.1:1" } })),
470
470
  )) === true,
471
471
  );
472
472
 
@@ -0,0 +1,445 @@
1
+ /**
2
+ * check-codex-app-server-launch — deterministic gate for the managed Codex app-server
3
+ * launch (`entwurf codex-app-server`, #95). Hermetic: no Codex CLI, no app-server, no
4
+ * network, no model turn, no write outside its own temp root.
5
+ *
6
+ * WHAT IS UNDER TEST is a process replacement, so the oracle is a FAKE VENDOR: a real
7
+ * executable placed on a sandbox PATH under the real name `codex`, which reports the argv,
8
+ * pid and cwd it was handed and then exits. Everything is asserted from that report, never
9
+ * from reading the launcher's source. The launcher is driven through its PUBLIC address
10
+ * (`run.sh codex-app-server`), because the dispatcher's own argv handling is part of the
11
+ * contract: the verb must not reach the vendor.
12
+ *
13
+ * THE ADDRESS ORACLE, AND WHY IT IS SHAPED LIKE THIS NOW. The first version of this launcher
14
+ * re-derived the socket path in bash, and this gate compared the two spellings over four
15
+ * ASCII-normal inputs. They agreed on those four and diverged elsewhere: `[측정 2026-09-16,
16
+ * independent review]` `CODEX_HOME=$'\ufeff'` trims to nothing in JS and keeps its byte in a
17
+ * POSIX `[:space:]` trim, so the launcher would have started a server at
18
+ * `<BOM>/app-server-control/app-server-control.sock` while delivery looked at `$HOME/.codex`.
19
+ * A matrix can only ever hold the inputs somebody thought of, so the second spelling was
20
+ * removed rather than widened — the launcher now ASKS `run.sh codex-socket-path`, which prints
21
+ * what `resolveCodexDefaultSocketPath` computes.
22
+ *
23
+ * That makes the cells below a WIRING oracle rather than a transcription oracle, and they are
24
+ * written to fail if the wiring is ever replaced by arithmetic again: the matrix keeps the
25
+ * ASCII cases AND carries the hostile inputs that caught the divergence, with the expectation
26
+ * computed by the real TS function on the same environment. The mutant that matters is not
27
+ * "drop CODEX_HOME" any more; it is "derive the path here instead of asking".
28
+ */
29
+
30
+ import assert from "node:assert/strict";
31
+ import { spawn, spawnSync } from "node:child_process";
32
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
33
+ import * as net from "node:net";
34
+ import { tmpdir } from "node:os";
35
+ import * as path from "node:path";
36
+ import { fileURLToPath } from "node:url";
37
+
38
+ import { resolveCodexDefaultSocketPath } from "../pi-extensions/lib/native-push/codex-ws-client.ts";
39
+
40
+ const REPO = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
41
+
42
+ let passed = 0;
43
+ function ok(label: string, cond: boolean): void {
44
+ assert.ok(cond, label);
45
+ console.log(` ok ${label}`);
46
+ passed++;
47
+ }
48
+
49
+ const root = mkdtempSync(path.join(tmpdir(), "entwurf-codex-app-server-launch."));
50
+ const servers: net.Server[] = [];
51
+ try {
52
+ const home = path.join(root, "home");
53
+ const bin = path.join(root, "bin");
54
+ for (const d of [home, bin]) mkdirSync(d, { recursive: true });
55
+
56
+ // The fake vendor. `printf '%s\n'` per element keeps empty strings and embedded spaces
57
+ // visible as themselves, which is the only way to assert byte preservation.
58
+ const vendor = path.join(bin, "codex");
59
+ writeFileSync(
60
+ vendor,
61
+ `#!/usr/bin/env bash
62
+ echo "CWD=$PWD"
63
+ echo "PID=$$"
64
+ echo "PPID=$PPID"
65
+ echo "PISESSION=[\${PI_SESSION_ID-<unset>}]"
66
+ echo "PIAGENT=[\${PI_AGENT_ID-<unset>}]"
67
+ echo "KEEP=\${ENTWURF_FIXTURE_KEEP-0}"
68
+ for a in "$@"; do printf 'ARG<%s>\\n' "$a"; done
69
+ exit "\${FAKE_CODEX_EXIT:-0}"
70
+ `,
71
+ );
72
+ chmodSync(vendor, 0o755);
73
+
74
+ interface Run {
75
+ status: number | null;
76
+ out: string;
77
+ args: string[];
78
+ cwd: string;
79
+ pid: string;
80
+ ppid: string;
81
+ piSession: string;
82
+ piAgent: string;
83
+ }
84
+
85
+ // A PATH with no `codex` anywhere on it — built by dropping every real PATH entry that
86
+ // actually holds one, rather than by emptying PATH (the launcher still needs python3,
87
+ // readlink and friends).
88
+ const pathWithoutVendor = (process.env.PATH ?? "")
89
+ .split(":")
90
+ .filter((d) => d !== "" && !existsSync(path.join(d, "codex")))
91
+ .join(":");
92
+
93
+ function launch(args: string[], extraEnv: Record<string, string | undefined> = {}, cwd = root): Run {
94
+ const r = spawnSync("bash", [path.join(REPO, "run.sh"), "codex-app-server", ...args], {
95
+ cwd,
96
+ encoding: "utf8",
97
+ env: {
98
+ ...process.env,
99
+ HOME: home,
100
+ CODEX_HOME: undefined as unknown as string,
101
+ TMUX: undefined as unknown as string,
102
+ PATH: `${bin}:${process.env.PATH ?? ""}`,
103
+ ENTWURF_CODEX_APP_SERVER_ACTIVE: undefined as unknown as string,
104
+ ...extraEnv,
105
+ } as NodeJS.ProcessEnv,
106
+ });
107
+ const out = `${r.stdout ?? ""}${r.stderr ?? ""}`;
108
+ const argv: string[] = [];
109
+ for (const line of out.split("\n")) {
110
+ const m = /^ARG<([\s\S]*)>$/.exec(line);
111
+ if (m) argv.push(m[1]);
112
+ }
113
+ return {
114
+ status: r.status,
115
+ out,
116
+ args: argv,
117
+ // Line-anchored, NOT `[\s\S]*`: a greedy any-character match would run past this
118
+ // line's delimiter and capture everything down to the last matching line.
119
+ cwd: /^CWD=(.*)$/m.exec(out)?.[1] ?? "",
120
+ pid: /^PID=(.*)$/m.exec(out)?.[1] ?? "",
121
+ ppid: /^PPID=(.*)$/m.exec(out)?.[1] ?? "",
122
+ piSession: /^PISESSION=\[(.*)\]$/m.exec(out)?.[1] ?? "<no-launch>",
123
+ piAgent: /^PIAGENT=\[(.*)\]$/m.exec(out)?.[1] ?? "<no-launch>",
124
+ };
125
+ }
126
+
127
+ // ── 1. the address, taken from the leaf the product reads ───────────────────
128
+ // Two groups, and the second is the load-bearing one. The ASCII cells are the environment
129
+ // shapes `resolveCodexHome` distinguishes at all. The HOSTILE cells are the inputs on which
130
+ // a bash transcription was MEASURED to diverge from it — a BOM-only CODEX_HOME (JS `trim`
131
+ // strips U+FEFF, a POSIX `[:space:]` trim does not) and the two `path.join` normalizations.
132
+ // They are here so that replacing the `codex-socket-path` call with arithmetic goes red
133
+ // instead of passing on well-behaved paths, which is exactly how the first version passed.
134
+ {
135
+ const explicit = path.join(root, "explicit-codex-home");
136
+ const matrix: Array<{ label: string; env: Record<string, string | undefined> }> = [
137
+ { label: "HOME only", env: {} },
138
+ { label: "explicit CODEX_HOME", env: { CODEX_HOME: explicit } },
139
+ { label: "whitespace CODEX_HOME falls back to HOME", env: { CODEX_HOME: " " } },
140
+ { label: "CODEX_HOME with surrounding whitespace is trimmed", env: { CODEX_HOME: ` ${explicit} ` } },
141
+ { label: "BOM-only CODEX_HOME is not a value and falls back to HOME", env: { CODEX_HOME: "\ufeff" } },
142
+ { label: "a trailing slash is normalized away", env: { CODEX_HOME: `${explicit}/` } },
143
+ { label: "a .. segment is normalized", env: { CODEX_HOME: `${explicit}/sub/..` } },
144
+ ];
145
+ for (const cell of matrix) {
146
+ const r = launch([], cell.env);
147
+ const want = `unix://${resolveCodexDefaultSocketPath({
148
+ HOME: home,
149
+ CODEX_HOME: cell.env.CODEX_HOME,
150
+ })}`;
151
+ ok(
152
+ `[QK:CODEX-APP-SERVER-ADDRESS-MATCHES-PRODUCT-LEAF] ${cell.label}: the vendor is handed exactly the address resolveCodexDefaultSocketPath computes (want ${want}, got ${JSON.stringify(r.args)})`,
153
+ r.status === 0 && JSON.stringify(r.args) === JSON.stringify(["app-server", "--listen", want]),
154
+ );
155
+ }
156
+ }
157
+
158
+ {
159
+ // Asking one authority for the address does not make the ANSWER safe to act on. The
160
+ // resolver returns `CODEX_HOME` faithfully, absolute or not, and this launcher is the one
161
+ // surface that CREATES a directory at that path and binds it. A relative address is a
162
+ // different file for every process that resolves it — and the bridge that will look for
163
+ // this socket is the app-server's MCP child, with its own cwd.
164
+ const cwdProbe = path.join(root, "relative-cwd");
165
+ mkdirSync(cwdProbe, { recursive: true });
166
+ const r = launch([], { CODEX_HOME: "relative-control-home" }, cwdProbe);
167
+ ok(
168
+ "[QK:CODEX-APP-SERVER-REFUSES-RELATIVE-SOCKET] a relative resolved address refuses before the vendor AND before anything is created on disk",
169
+ r.status !== 0 &&
170
+ r.out.includes("codex-app-server-socket-path-not-absolute") &&
171
+ r.args.length === 0 &&
172
+ !existsSync(path.join(cwdProbe, "relative-control-home")),
173
+ );
174
+ }
175
+ {
176
+ // ABSOLUTE on purpose, so this cell is not shadowed by the one above: the two refusals
177
+ // are separate guards and a mutant that removes only this one must still go red.
178
+ const weird = path.join(root, "ctrl-home\nsecond-line");
179
+ const r = launch([], { CODEX_HOME: weird });
180
+ ok(
181
+ "[QK:CODEX-APP-SERVER-REFUSES-CONTROL-CHAR-SOCKET] an absolute address carrying a control character refuses rather than creating or binding it",
182
+ r.status !== 0 && r.out.includes("codex-app-server-socket-path-untrusted") && r.args.length === 0,
183
+ );
184
+ }
185
+
186
+ // ── 2. the subcommand, and the operator's argv after it ─────────────────────
187
+ {
188
+ const r = launch(["--config", "a=b", "", "two words"]);
189
+ const want = `unix://${resolveCodexDefaultSocketPath({ HOME: home })}`;
190
+ ok(
191
+ `[QK:CODEX-APP-SERVER-FORWARDS-OPERATOR-ARGV] operator arguments follow the injected address byte-identical, empty strings and spaces intact (got ${JSON.stringify(r.args)})`,
192
+ r.status === 0 &&
193
+ JSON.stringify(r.args) === JSON.stringify(["app-server", "--listen", want, "--config", "a=b", "", "two words"]),
194
+ );
195
+ }
196
+ {
197
+ const r = launch([]);
198
+ ok(
199
+ "the dispatcher verb never reaches the vendor — a stray `codex-app-server` argument would arrive as a vendor subcommand",
200
+ !r.args.includes("codex-app-server"),
201
+ );
202
+ }
203
+ {
204
+ // A second `--listen` is refused rather than appended. Two listen addresses let one
205
+ // win silently, and the silent winner is an endpoint no record points at.
206
+ const equals = launch(["--listen=unix:///tmp/mine.sock"]);
207
+ const spaced = launch(["--listen", "unix:///tmp/mine.sock"]);
208
+ ok(
209
+ "[QK:CODEX-APP-SERVER-REFUSES-SECOND-LISTEN] an operator --listen is a named refusal, in both spellings, and never reaches the vendor",
210
+ equals.status !== 0 &&
211
+ spaced.status !== 0 &&
212
+ equals.out.includes("codex-app-server-listen-override") &&
213
+ spaced.out.includes("codex-app-server-listen-override") &&
214
+ equals.args.length === 0 &&
215
+ spaced.args.length === 0,
216
+ );
217
+ }
218
+
219
+ // ── 3. exec, not fork ───────────────────────────────────────────────────────
220
+ {
221
+ // `exec` is what makes Ctrl-C, the exit status and the process identity the vendor's.
222
+ // A forking launcher would leave run.sh sitting between the operator and the server:
223
+ // signals would hit the wrapper, and the thing the operator thinks they killed would
224
+ // not be the thing that dies.
225
+ //
226
+ // The oracle is the vendor's PARENT, which is the only side of this that a single run
227
+ // can measure honestly. This gate spawns bash directly, so an unbroken exec chain
228
+ // (run.sh -> launcher -> vendor) leaves the vendor as THIS process's own child. Any
229
+ // fork anywhere along the way inserts a bash between them, and the reported parent
230
+ // stops being this gate. Comparing a pid across two separate runs would prove nothing.
231
+ const r = launch([]);
232
+ ok(
233
+ `[QK:CODEX-APP-SERVER-EXECS-NOT-FORKS] the vendor runs AS the launcher process, not as a child of it — its parent is this gate itself (ppid ${r.ppid}, gate ${process.pid})`,
234
+ r.status === 0 && r.pid !== "" && r.ppid === String(process.pid),
235
+ );
236
+ }
237
+ {
238
+ const r = launch([], { FAKE_CODEX_EXIT: "37" });
239
+ ok(
240
+ "the vendor's exit status is the caller's exit status — an app-server that dies on a bad config must not read as a successful launch",
241
+ r.status === 37,
242
+ );
243
+ }
244
+ {
245
+ const r = launch([], {}, home);
246
+ ok("the vendor inherits the caller's cwd — no subshell, no cd", r.status === 0 && r.cwd === home);
247
+ }
248
+
249
+ // ── 4. the socket is somebody else's until proven otherwise ─────────────────
250
+ {
251
+ // A LIVE socket is the case that matters: a second server would either lose the bind
252
+ // race or replace the endpoint every existing record points at. The fixture is a real
253
+ // listening AF_UNIX socket, so the launcher's own connect probe is what decides.
254
+ const liveHome = path.join(root, "live-home");
255
+ const liveSock = resolveCodexDefaultSocketPath({ CODEX_HOME: liveHome });
256
+ mkdirSync(path.dirname(liveSock), { recursive: true });
257
+ const server = net.createServer();
258
+ servers.push(server);
259
+ server.listen(liveSock);
260
+ const r = launch([], { CODEX_HOME: liveHome });
261
+ ok(
262
+ "[QK:CODEX-APP-SERVER-REFUSES-LIVE-SOCKET] a live control socket is a named refusal and the vendor is never reached",
263
+ r.status !== 0 && r.out.includes("codex-app-server-already-listening") && r.args.length === 0,
264
+ );
265
+ ok(
266
+ "with nothing on this host spelling that socket, the refusal SAYS so rather than naming a holder it cannot see",
267
+ r.out.includes("What /proc reports about it:") && r.out.includes("read, not inferred") && !/pid \d+:/.test(r.out),
268
+ );
269
+ server.close();
270
+ servers.pop();
271
+ }
272
+ {
273
+ // The other half, and the pair is what makes either one discriminating. The first
274
+ // version asserted "fallback text OR a pid line", which every run satisfied through the
275
+ // fallback branch — deleting the scan entirely would have passed it. So this cell puts a
276
+ // process on the host whose cmdline really does carry the socket path and requires the
277
+ // refusal to name THAT pid. A launcher that stopped reading /proc now goes red here, and
278
+ // a launcher that invented an owner goes red in the cell above.
279
+ const ownedHome = path.join(root, "owned-home");
280
+ const ownedSock = resolveCodexDefaultSocketPath({ CODEX_HOME: ownedHome });
281
+ mkdirSync(path.dirname(ownedSock), { recursive: true });
282
+ const server = net.createServer();
283
+ servers.push(server);
284
+ server.listen(ownedSock);
285
+ // A decoy whose ARGV carries the path. It does not hold the socket, and it must not: the
286
+ // launcher reports what `/proc/*/cmdline` says, which is a READING, and this cell pins
287
+ // exactly that reading rather than a claim about socket ownership the kernel never made.
288
+ const holder = spawn("python3", ["-c", "import time; time.sleep(120)", ownedSock], {
289
+ stdio: "ignore",
290
+ detached: false,
291
+ });
292
+ try {
293
+ const r = launch([], { CODEX_HOME: ownedHome });
294
+ ok(
295
+ `[QK:CODEX-APP-SERVER-READS-PROC-HOLDER] the refusal names the pid whose cmdline actually carries that socket (want pid ${holder.pid})`,
296
+ r.status !== 0 &&
297
+ r.out.includes("codex-app-server-already-listening") &&
298
+ r.out.includes(`pid ${holder.pid}:`) &&
299
+ !r.out.includes("read, not inferred"),
300
+ );
301
+ } finally {
302
+ holder.kill("SIGKILL");
303
+ }
304
+ server.close();
305
+ servers.pop();
306
+ }
307
+ {
308
+ // A DEAD socket file is the ordinary leftover of a hard kill: the file survives, the
309
+ // listener does not. The vendor replaces it, so this is a FACT LINE and not a refusal —
310
+ // and the separation matters, because folding it into the live case would make every
311
+ // crashed server a permanent block on restarting one. The fixture binds an AF_UNIX
312
+ // socket in a process that then exits without unlinking, which is exactly the on-disk
313
+ // state a killed app-server leaves.
314
+ const staleHome = path.join(root, "stale-home");
315
+ const staleSock = resolveCodexDefaultSocketPath({ CODEX_HOME: staleHome });
316
+ mkdirSync(path.dirname(staleSock), { recursive: true });
317
+ spawnSync("python3", ["-c", "import socket,sys;s=socket.socket(socket.AF_UNIX);s.bind(sys.argv[1])", staleSock]);
318
+ const r = launch([], { CODEX_HOME: staleHome });
319
+ ok(
320
+ `[QK:CODEX-APP-SERVER-LAUNCHES-OVER-DEAD-SOCKET] a socket file with no listener is reported and LAUNCHED over, not refused (status ${r.status})`,
321
+ r.status === 0 && r.out.includes("a dead control socket is already at") && r.args.length === 3,
322
+ );
323
+ }
324
+ {
325
+ const fileHome = path.join(root, "file-home");
326
+ const fileSock = resolveCodexDefaultSocketPath({ CODEX_HOME: fileHome });
327
+ mkdirSync(path.dirname(fileSock), { recursive: true });
328
+ 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
+ {
336
+ const linkHome = path.join(root, "link-home");
337
+ const linkSock = resolveCodexDefaultSocketPath({ CODEX_HOME: linkHome });
338
+ mkdirSync(path.dirname(linkSock), { recursive: true });
339
+ symlinkSync(path.join(root, "nowhere.sock"), linkSock);
340
+ const r = launch([], { CODEX_HOME: linkHome });
341
+ 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,
344
+ );
345
+ }
346
+ {
347
+ // An absent socket is the normal first launch, and the control directory is created
348
+ // for it — that mkdir is what makes this ONE command instead of two.
349
+ const freshHome = path.join(root, "fresh-home");
350
+ const r = launch([], { CODEX_HOME: freshHome });
351
+ ok(
352
+ "a first launch on a host with no control directory creates it and reaches the vendor",
353
+ r.status === 0 && existsSync(path.dirname(resolveCodexDefaultSocketPath({ CODEX_HOME: freshHome }))),
354
+ );
355
+ }
356
+
357
+ {
358
+ // Hard Rule 15: an unrecognised reading is the one case where proceeding is unsafe,
359
+ // because every branch above is a decision about whether this launch would clobber a
360
+ // 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.
362
+ const oddBin = path.join(root, "odd-probe-bin");
363
+ mkdirSync(oddBin, { recursive: true });
364
+ const oddPython = path.join(oddBin, "python3");
365
+ writeFileSync(oddPython, "#!/usr/bin/env bash\ncat >/dev/null\necho 'unexpected-probe-status'\nexit 0\n");
366
+ chmodSync(oddPython, 0o755);
367
+ const r = launch([], { PATH: `${oddBin}:${bin}:${process.env.PATH ?? ""}` });
368
+ ok(
369
+ "[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
+ r.status !== 0 && r.out.includes("codex-app-server-socket-probe-unrecognised") && r.args.length === 0,
371
+ );
372
+ }
373
+
374
+ // ── 5. refusals that keep this from becoming something it is not ────────────
375
+ {
376
+ const r = launch([], { PATH: pathWithoutVendor });
377
+ ok(
378
+ "no codex on PATH is a named refusal that names the repair, not a silent no-op",
379
+ r.status !== 0 && r.out.includes("no 'codex' executable found on PATH"),
380
+ );
381
+ }
382
+ {
383
+ const r = launch([], { ENTWURF_CODEX_APP_SERVER_ACTIVE: "1" });
384
+ ok(
385
+ "[QK:CODEX-APP-SERVER-REFUSES-RECURSION] an already-set launch sentinel refuses instead of spinning a launch loop",
386
+ r.status !== 0 && r.out.includes("recursive managed launch detected"),
387
+ );
388
+ }
389
+ {
390
+ // The self-exec fence, with the sentinel deliberately absent: a `codex` on PATH that
391
+ // resolves back to our own entrypoint is a loop the sentinel alone would not catch if
392
+ // it were ever stripped between hops.
393
+ const loopBin = path.join(root, "loop-bin");
394
+ mkdirSync(loopBin, { recursive: true });
395
+ symlinkSync(path.join(REPO, "scripts", "codex-app-server-launch.sh"), path.join(loopBin, "codex"));
396
+ const r = launch([], { PATH: `${loopBin}:${pathWithoutVendor}` });
397
+ ok(
398
+ "a PATH `codex` that resolves to entwurf's own launcher is refused as a launch loop",
399
+ r.status !== 0 && r.out.includes("launch loop"),
400
+ );
401
+ }
402
+
403
+ // ── 6. the tmux line is a fact, and the identity carriers are not ───────────
404
+ {
405
+ const inside = launch([], { TMUX: "/tmp/tmux-1000/default,1234,0" });
406
+ const outside = launch([]);
407
+ ok(
408
+ "[QK:CODEX-APP-SERVER-REPORTS-TMUX-SEAT] the tmux seat is REPORTED in both directions and refuses neither — running outside tmux is an operator choice with a consequence, not an error",
409
+ inside.status === 0 &&
410
+ outside.status === 0 &&
411
+ inside.out.includes("/tmp/tmux-1000/default,1234,0") &&
412
+ outside.out.includes("(none — not inside tmux)") &&
413
+ outside.out.includes("caller-seat lookups"),
414
+ );
415
+ }
416
+ {
417
+ // The app-server is the parent of every bridge child, so a pi identity inherited here
418
+ // would be inherited by all of them. Both carriers go together: clearing one only
419
+ // changes the wording of a later failure while leaving a carrier for a partial reader.
420
+ const both = launch([], { PI_SESSION_ID: "pi-session-fixture", PI_AGENT_ID: "pi-agent-fixture" });
421
+ const onlySession = launch([], { PI_SESSION_ID: "pi-session-fixture" });
422
+ const onlyAgent = launch([], { PI_AGENT_ID: "pi-agent-fixture" });
423
+ ok(
424
+ `[QK:CODEX-APP-SERVER-STRIPS-IDENTITY-CARRIERS] neither PI_SESSION_ID nor PI_AGENT_ID survives into the server every bridge child inherits from (got "${both.piSession}"/"${both.piAgent}", "${onlySession.piSession}", "${onlyAgent.piAgent}")`,
425
+ both.piSession === "<unset>" &&
426
+ both.piAgent === "<unset>" &&
427
+ onlySession.piSession === "<unset>" &&
428
+ onlyAgent.piAgent === "<unset>",
429
+ );
430
+ }
431
+ {
432
+ // The strip is identity-only. A launch that also swallowed the operator's own
433
+ // environment would be a different, quieter defect.
434
+ const r = launch(["--config", "x=1"], { PI_SESSION_ID: "x", ENTWURF_FIXTURE_KEEP: "1" });
435
+ ok(
436
+ "the strip touches ONLY the two identity carriers — the operator's argv and unrelated environment survive it",
437
+ r.status === 0 && r.args.includes("--config") && r.args.includes("x=1") && r.out.includes("KEEP=1"),
438
+ );
439
+ }
440
+
441
+ console.log(`\n[check-codex-app-server-launch] PASS (${passed} assertions)`);
442
+ } finally {
443
+ for (const s of servers) s.close();
444
+ rmSync(root, { recursive: true, force: true });
445
+ }