@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
@@ -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
+ }
@@ -9,6 +9,10 @@
9
9
  * (a pi-alive citizen → control-socket execute; acquireLock spy saw {dir: lockDir}).
10
10
  * B. control `sendOverSocket` builds the RpcSendCommand (type/message/mode/wants_reply/
11
11
  * sender) and maps response.success→outcome; the hand releases under `lockDir`.
12
+ * B2. #115 — the SAME closure carries `response.error` onto the RpcSendResult, so a
13
+ * completed-but-refused RPC reaches the caller as `rejected` WITH the receiver's
14
+ * named reason. Dropping that one field renders a reasonless reject, which is the
15
+ * field symptom; no other deterministic gate holds this wiring.
12
16
  * D. the meta-mailbox hand enqueues onto the wired sessionsDir/mailboxDir.
13
17
  * E. Q3 + Q5 — a dead control send re-resolves (claude-code citizen) to the mailbox and
14
18
  * enqueues through the SAME sendViaMailbox instance (same enqueue spy) on the SAME dirs
@@ -60,6 +64,10 @@ const LOCK_DIR = "/fake/locks";
60
64
  const SESSIONS_DIR = "/fake/sessions";
61
65
  const MAILBOX_DIR = "/fake/mailbox";
62
66
  const CONTROL_DIR = "/fake/ctl";
67
+ /** The named in-band refusal a real receiver answers with while it compacts (#115).
68
+ * It is a plain opaque string here on purpose: the production seam must carry WHATEVER
69
+ * reason the receiver named, not a vocabulary this gate recognises. */
70
+ const IN_BAND_REJECT_REASON = "compacting";
63
71
 
64
72
  function identity(backend: MetaIdentity["backend"], gardenId = GID): MetaIdentity {
65
73
  return {
@@ -184,7 +192,10 @@ function makeSpiedFactory(over: {
184
192
  recordExists?: boolean;
185
193
  inspectKind?: TargetSocketInspection["kind"];
186
194
  probe?: "alive" | "dead" | "indeterminate";
187
- rpc?: "success" | "dead-throw";
195
+ /** `in-band-reject` (#115): the RPC COMPLETES and the receiver refuses in band
196
+ * (`{success:false,error:"compacting"}`) — distinct from `dead-throw`, which never
197
+ * reaches a response at all. It is the only arm that carries `response.error`. */
198
+ rpc?: "success" | "dead-throw" | "in-band-reject";
188
199
  classifyDead?: boolean;
189
200
  /** #50 C3 — a caller with no authoritative sender (senderProvider → undefined). */
190
201
  noSender?: boolean;
@@ -267,6 +278,11 @@ function makeSpiedFactory(over: {
267
278
  e.code = "ECONNREFUSED";
268
279
  throw e;
269
280
  }
281
+ if (over.rpc === "in-band-reject") {
282
+ return {
283
+ response: { type: "response", command: command.type, success: false, error: IN_BAND_REJECT_REASON },
284
+ };
285
+ }
270
286
  return { response: { type: "response", command: command.type, success: true } };
271
287
  },
272
288
  enqueue: (o) => {
@@ -531,6 +547,31 @@ async function main(): Promise<void> {
531
547
  );
532
548
  }
533
549
 
550
+ // ── B2: #115 — the production seam CARRIES the receiver's named in-band reason ─
551
+ // B above proves the success half of the map. This is the other half, and it is the
552
+ // half the field symptom lived in: a completed RPC answering `{success:false,
553
+ // error:"compacting"}`. The reason has to cross THREE production hops the fake cannot
554
+ // short-circuit — `response.error` → `RpcSendResult.error` (the factory's own
555
+ // `sendOverSocket` closure) → `driveSend`'s `inBandRejected` → the
556
+ // `executeControlSocketSend` result — so deleting the factory's `error:` wiring alone
557
+ // strands the reason and this cell goes red. `outcome` alone is NOT the assertion:
558
+ // a bare `rejected` is exactly what the defect rendered.
559
+ {
560
+ const { deps, spies } = makeSpiedFactory({ rpc: "in-band-reject" });
561
+ const res = await deps.executor.sendControl(CONTROL_PLAN, lockClaim());
562
+ ok("B2: in-band {success:false} → outcome 'rejected' (not failed, not sent)", res.outcome === "rejected");
563
+ ok(
564
+ "B2: [QK:V2PROD-INBAND-ERROR-WIRED] response.error reaches the send result verbatim",
565
+ res.rejectReason === IN_BAND_REJECT_REASON,
566
+ );
567
+ ok("B2: an in-band reject is a COMPLETED rpc — the socket hand ran once", spies.rpc.length === 1);
568
+ ok(
569
+ "B2: a rejected send still releases under the wired lockDir",
570
+ spies.release.length === 1 && spies.release[0].dir === LOCK_DIR,
571
+ );
572
+ ok("B2: an in-band reject never falls back to the mailbox", spies.enqueue.length === 0);
573
+ }
574
+
534
575
  // ── C2: #50 C3 — the dormant rail carries the caller edge (<sender_info>) ──
535
576
  // ── D: meta-mailbox hand enqueues onto the wired dirs ─────────────────────
536
577
  {
@@ -5,8 +5,8 @@
5
5
  *
6
6
  * 1. ack success → outcome `sent`, release ×1, deadFallback NOT called.
7
7
  * 2. in-band reject (success:false) → outcome `rejected`, release ×1, NO fallback
8
- * (deadFallback + mailbox NOT called — the receiver was reached and refused); NO
9
- * rejectReason (an in-band refusal has no resolver taxonomy N3 boundary).
8
+ * (deadFallback + mailbox NOT called — the receiver was reached and refused); a
9
+ * supplied receiver error is carried verbatim as `rejectReason` (N3 boundary).
10
10
  * 3. dead → re-resolve(control-socket) → success → `fallback-sent`, release ×1,
11
11
  * deadFallback called EXACTLY once and UNDER the still-held lock (before release).
12
12
  * 4. dead → re-resolve reject → `rejected`, release ×1, and the resolver's reason is
@@ -193,16 +193,24 @@ async function main(): Promise<void> {
193
193
 
194
194
  // ── 2: in-band reject → rejected, release once, NO fallback ───────────────
195
195
  {
196
- const { result, trace } = await run({ firstSend: { result: { success: false, error: "refused" } } });
196
+ const { result, trace } = await run({ firstSend: { result: { success: false, error: "compacting" } } });
197
197
  ok("in-band reject → rejected", result.outcome === "rejected");
198
198
  ok("in-band reject → release ×1", trace.releases.length === 1);
199
- // N3 boundary: an in-band RPC refusal has NO resolver reason (only a dead-path
200
- // re-resolve reject carries one) the field stays undefined here.
201
- ok("in-band reject → no rejectReason (in-band has no resolver taxonomy)", result.rejectReason === undefined);
199
+ ok(
200
+ "[QK:V2SEND-INBAND-REJECT-REASON] in-band control reject carries its receiver error",
201
+ result.rejectReason === "compacting",
202
+ );
202
203
  ok(
203
204
  "in-band reject → no deadFallback, no mailbox",
204
205
  trace.deadFallbackCalls === 0 && trace.mailboxSends.length === 0,
205
206
  );
207
+ const unnamed = await run({ firstSend: { result: { success: false } } });
208
+ ok("in-band reject without an error does not invent rejectReason", unnamed.result.rejectReason === undefined);
209
+ const empty = await run({ firstSend: { result: { success: false, error: "" } } });
210
+ ok(
211
+ "in-band reject with an empty error does not expose an empty rejectReason",
212
+ empty.result.rejectReason === undefined,
213
+ );
206
214
  }
207
215
 
208
216
  // ── 3: dead → re-resolve(control) success → fallback-sent, fallback before release
@@ -223,6 +231,16 @@ async function main(): Promise<void> {
223
231
  "dead → deadFallback UNDER held lock (before release)",
224
232
  trace.order.indexOf("deadFallback") < trace.order.indexOf("releaseLock"),
225
233
  );
234
+
235
+ const refused = await run({
236
+ firstSend: { throwCode: "ECONNREFUSED" },
237
+ deadFallback: { kind: "execute", plan: RERESOLVED_CONTROL_PLAN },
238
+ fallbackSend: { result: { success: false, error: "retry-refused" } },
239
+ });
240
+ ok(
241
+ "dead → re-resolve(control) in-band reject carries the retry receiver error",
242
+ refused.result.outcome === "rejected" && refused.result.rejectReason === "retry-refused",
243
+ );
226
244
  }
227
245
 
228
246
  // ── 4: dead → re-resolve reject → rejected ────────────────────────────────
@@ -296,9 +314,10 @@ async function main(): Promise<void> {
296
314
  const refused = await run({
297
315
  firstSend: { throwCode: "ENOENT" },
298
316
  deadFallback: { kind: "execute", plan: MAILBOX_PLAN },
299
- fallbackSend: { result: { success: false } },
317
+ fallbackSend: { result: { success: false, error: "mailbox-refused" } },
300
318
  });
301
319
  ok("dead → mailbox enqueue success:false → rejected", refused.result.outcome === "rejected");
320
+ ok("dead → mailbox enqueue reject carries its receiver error", refused.result.rejectReason === "mailbox-refused");
302
321
  // No file was written, so there is nothing to name — never echo a dep's stray path.
303
322
  ok("dead → rejected enqueue carries NO messagePath", refused.result.messagePath === undefined);
304
323
  }
@@ -832,6 +832,8 @@ let manifestCount: number;
832
832
  "bridge-boot-resume": 3,
833
833
  "bridge-command-boot": 9,
834
834
  "capability-cache": 3,
835
+ "codex-app-server-launch": 9,
836
+ "codex-caller-seat": 28,
835
837
  "codex-native": 68,
836
838
  "compaction-send-guard": 7,
837
839
  "copilot-birth": 19,
@@ -845,7 +847,7 @@ let manifestCount: number;
845
847
  "meta-identity": 4,
846
848
  "meta-retire": 3,
847
849
  "mux-boundary": 16,
848
- "mux-fresh-call": 45,
850
+ "mux-fresh-call": 51,
849
851
  "mux-launcher-fence": 7,
850
852
  "mux-parent-artifact": 3,
851
853
  "pack-install": 2,
@@ -861,7 +863,7 @@ let manifestCount: number;
861
863
  "self-address": 5,
862
864
  "setup-verdict": 14,
863
865
  "source-install": 2,
864
- "v2-surface": 11,
866
+ "v2-surface": 13,
865
867
  "v2-visible-resume": 17,
866
868
  };
867
869
  const laneTally: Record<string, number> = {};