@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,204 @@
1
+ /**
2
+ * codex-caller-seat — the ONE resolution of a Codex caller's `threadId` into the tmux pane its
3
+ * TUI is sitting in. Narrow leaf of the fresh-call composition (#95 lane B); it owns the title
4
+ * anchor and the pane count and NOTHING else — it never runs tmux (the runner is injected),
5
+ * never phrases a placement decision, never reads a record, and has no fallback seat.
6
+ *
7
+ * Same shape and same discipline as `resolve-tmux-session.ts` and `classify-tmux-cwd.ts`: this
8
+ * file imports nothing at all, not even a node builtin, so it stays deletable on its own and
9
+ * cannot acquire an opinion about mux, entwurf, identity or delivery. The injected runner is
10
+ * matched STRUCTURALLY to `mux-placement.TmuxRun` rather than by a type import, for the same
11
+ * reason.
12
+ *
13
+ * ── WHAT THIS IS NOT ──
14
+ *
15
+ * A pane title is OPERATOR-WRITABLE and forgeable: any process in any pane can emit the same
16
+ * OSC 0 string. So this leaf's answer is a PLACEMENT INPUT and nothing else — the `$session`
17
+ * it returns may only reach a `-t` target. Identity, delivery and liveness keep the record +
18
+ * `_meta` join they already have (AGENTS.md Hard Rule 16). Nothing here reads screen text,
19
+ * sends keys, or infers that a citizen is alive.
20
+ *
21
+ * ── THE ANCHOR, AND WHY IT IS NOT THE BARE UUID ──
22
+ *
23
+ * With `thread-id` in `[tui].terminal_title` (the `entwurf install-codex-terminal-title` atom)
24
+ * the vendor renders the thread UUID into the terminal title — but TRUNCATED. `[측정]
25
+ * 2026-09-16, thinkpad, codex-cli 0.153.4: a live TUI's `#{pane_title}` read back
26
+ * `tmp | 01a0a7f9-ed9c-7aa2-a4dd-b1a39...`, not the 36-char id. Source at `rust-v0.153.4`:
27
+ * `codex-rs/tui/src/chatwidget/status_surfaces.rs:892-894` renders `TerminalTitleItem::SessionId`
28
+ * through `truncate_terminal_title_part(value, 32)`, and `:1027-1043` keeps 29 graphemes and
29
+ * appends `...`. So the anchor set is TWO strings: the truncated form the vendor emits today,
30
+ * and the full id — accepted so that a vendor which later stops truncating passes unchanged
31
+ * rather than silently resolving nothing.
32
+ *
33
+ * `[측정]` 29 leading chars of a thread UUID are ASCII (`01a09ec6-e6de-7643-8571-f3261`, 29
34
+ * code points, 29 bytes — terra#1 2026-09-16 item 8), so counting code points here and
35
+ * graphemes there is the same count for every id this rail will ever see.
36
+ *
37
+ * `[측정]` `rust-v0.154.0` is unchanged on all three axes — the `thread-id` item
38
+ * (`title_setup.rs:81-83`), the 32-char truncation (`status_surfaces.rs:930-934`) and the
39
+ * ` | ` separator (`title_setup.rs:187-195`) — so this anchor is not pinned to one release.
40
+ *
41
+ * ── WHY TOKENS AND NOT ` | ` SEGMENTS ──
42
+ *
43
+ * The obvious rule is "one ` | `-separated segment equals the anchor". It has a vendor hole.
44
+ * `title_setup.rs:183-193 separator_from_previous` joins adjacent items with ` | ` EXCEPT when
45
+ * either side is the `activity` item (`Spinner`, `title_setup.rs:47-48`), which gets a plain
46
+ * space — and `status_surfaces.rs:330-343` computes that `previous` from the last RENDERED
47
+ * item, skipping any that resolved to `None`. So on a host whose operator list ends in
48
+ * `activity`, the installed atom appends `thread-id` right after it and a WORKING TUI renders
49
+ * `<spinner text> 01a0a7f9-…`: one segment, two values. Splitting each segment on a space and
50
+ * comparing TOKENS closes that hole and cannot open a new one — a false positive would need
51
+ * some other title item to render a string byte-identical to this thread's own id.
52
+ *
53
+ * ── WHY THE COUNT IS OVER PANES, NOT TOKENS ──
54
+ *
55
+ * One pane can legitimately show the same thread twice: an operator whose `terminal_title` also
56
+ * carries `thread-title` sees the full 36-char id there whenever the thread is unnamed
57
+ * (`status_surfaces.rs:776-786`), beside our truncated one. Two tokens, one pane, one thread —
58
+ * not an ambiguity. Ambiguity is TWO PANES claiming the same thread, and that is refused
59
+ * because picking either would seat a sibling by guess.
60
+ */
61
+
62
+ /** Why a Codex caller's seat could not be resolved. Two stable literals — the consuming
63
+ * composition widens its own reject union with this type, so the strings are contract. */
64
+ export type CodexCallerSeatRejectReason = "codex-caller-seat-unresolved" | "codex-caller-seat-ambiguous";
65
+
66
+ /** The repair text for each refusal, owned by the leaf that decides it so the sentence an
67
+ * operator reads cannot drift away from the predicate that produced it (the same rule the
68
+ * Copilot/OMP/Codex preflight hints follow). */
69
+ export const CODEX_CALLER_SEAT_HINT: Record<CodexCallerSeatRejectReason, string> = {
70
+ "codex-caller-seat-unresolved":
71
+ "no pane on this agent's own tmux server shows this Codex thread in its title, so there is no caller seat to open a sibling beside — the TUI may be on another tmux server or outside tmux entirely, its config may not carry `thread-id` in [tui].terminal_title (run `entwurf install-codex-terminal-title`, then `entwurf doctor-codex-terminal-title`), or that server may have `allow-set-title off`, which replaces every pane title with the hostname and hides the id [측정 2026-09-16]",
72
+ "codex-caller-seat-ambiguous":
73
+ "more than one pane on this agent's own tmux server shows this Codex thread in its title, so which one is the caller cannot be decided — nothing is opened rather than guessing a seat; close the stale duplicate and call again",
74
+ };
75
+
76
+ /** What the injected runner returns. Structurally identical to `mux-placement.TmuxRun`; kept as
77
+ * its own declaration so this leaf imports nothing. */
78
+ export interface CodexCallerSeatRun {
79
+ status: number | null;
80
+ stdout: string;
81
+ stderr: string;
82
+ }
83
+
84
+ /** The pane a Codex caller is sitting in. `sessionId` is the only member a placement may use;
85
+ * `paneId` travels for receipts and diagnosis, never as an address. */
86
+ export interface CodexCallerSeat {
87
+ paneId: string;
88
+ sessionId: string;
89
+ source: "codex-title-anchor";
90
+ }
91
+
92
+ export type CodexCallerSeatResult =
93
+ | { ok: true; seat: CodexCallerSeat }
94
+ | { ok: false; reason: CodexCallerSeatRejectReason };
95
+
96
+ /** The vendor's per-item ceiling for `thread-id` (`status_surfaces.rs:892-894`). */
97
+ export const CODEX_TITLE_ITEM_MAX_CHARS = 32;
98
+
99
+ /** The two separators a rendered title can put between items: ` | ` for an ordinary pair, and a
100
+ * bare space when either neighbour is the `activity` indicator (`title_setup.rs:183-193`). */
101
+ const TITLE_SEGMENT_SEPARATOR = " | ";
102
+ const TITLE_TOKEN_SEPARATOR = " ";
103
+
104
+ /**
105
+ * The vendor's `truncate_terminal_title_part`, reproduced (`status_surfaces.rs:1027-1043`).
106
+ * Code points stand in for graphemes — measured identical for every thread id (see header).
107
+ */
108
+ export function truncateTerminalTitlePart(value: string, maxChars: number): string {
109
+ const chars = [...value];
110
+ if (chars.length <= maxChars || maxChars <= 3) return chars.slice(0, maxChars).join("");
111
+ return `${chars.slice(0, maxChars - 3).join("")}...`;
112
+ }
113
+
114
+ /**
115
+ * Every string a title may legitimately carry for this thread. The truncated form is what the
116
+ * vendor emits today; the full id is accepted so a future vendor that stops truncating keeps
117
+ * working without a code change here.
118
+ */
119
+ export function codexTitleAnchors(threadId: string): string[] {
120
+ if (threadId.length === 0) {
121
+ throw new Error("codex-caller-seat: refusing to build an anchor for an empty threadId");
122
+ }
123
+ const truncated = truncateTerminalTitlePart(threadId, CODEX_TITLE_ITEM_MAX_CHARS);
124
+ return truncated === threadId ? [threadId] : [threadId, truncated];
125
+ }
126
+
127
+ /** Does this ONE pane title name the thread? Segment first, then token — see the header for the
128
+ * `activity`-adjacency hole a segment-only rule leaves open. */
129
+ export function titleNamesThread(title: string, anchors: readonly string[]): boolean {
130
+ for (const segment of title.split(TITLE_SEGMENT_SEPARATOR)) {
131
+ for (const token of segment.split(TITLE_TOKEN_SEPARATOR)) {
132
+ if (anchors.includes(token)) return true;
133
+ }
134
+ }
135
+ return false;
136
+ }
137
+
138
+ /**
139
+ * The lookup argv. `-a` is the whole server because a Codex TUI is not required to be in the
140
+ * caller's own session — that asymmetry is exactly what this leaf exists to remove. The three
141
+ * fields are tab-separated so a title containing spaces (a project name, the spinner text)
142
+ * cannot be read as a new column.
143
+ */
144
+ export function buildCodexCallerSeatArgs(): string[] {
145
+ return ["list-panes", "-a", "-F", "#{pane_id}\t#{session_id}\t#{pane_title}"];
146
+ }
147
+
148
+ /** One `list-panes` line back into its three fields, or `null` when it is not one. The title
149
+ * takes EVERYTHING after the second tab: splitting on every tab would truncate a title that
150
+ * ever carried one. */
151
+ function parsePaneLine(line: string): { paneId: string; sessionId: string; title: string } | null {
152
+ const firstTab = line.indexOf("\t");
153
+ if (firstTab < 0) return null;
154
+ const secondTab = line.indexOf("\t", firstTab + 1);
155
+ if (secondTab < 0) return null;
156
+ const paneId = line.slice(0, firstTab);
157
+ const sessionId = line.slice(firstTab + 1, secondTab);
158
+ if (paneId.length === 0 || sessionId.length === 0) return null;
159
+ return { paneId, sessionId, title: line.slice(secondTab + 1) };
160
+ }
161
+
162
+ /**
163
+ * Resolve the pane a Codex caller's thread is displayed in, on whatever server the runner's
164
+ * environment names.
165
+ *
166
+ * ONE BOUNDED IMPRECISION, STATED RATHER THAN LAUNDERED (the same one
167
+ * `resolve-tmux-session.ts` carries): rc≠0 also covers "no server running on this socket". This
168
+ * leaf reads every rc≠0 as `codex-caller-seat-unresolved`, so a server that died between the
169
+ * caller's context proof and this lookup is reported under the narrower word. That is safe —
170
+ * both readings are refusals that mutate nothing, and the hint above names both — and it is
171
+ * preferred over matching tmux's own stderr text, which would pin this leaf to one vendor
172
+ * version's wording.
173
+ */
174
+ export function resolveCodexCallerSeat(
175
+ threadId: string,
176
+ run: (args: string[]) => CodexCallerSeatRun,
177
+ ): CodexCallerSeatResult {
178
+ const anchors = codexTitleAnchors(threadId);
179
+ const result = run(buildCodexCallerSeatArgs());
180
+ // A signalled call is not tmux answering — it carries no information about any pane at all,
181
+ // so it must never be read as "the caller's TUI is not here".
182
+ if (result.status === null) {
183
+ throw new Error(`codex-caller-seat: the pane listing was killed by a signal: ${result.stderr.trim()}`);
184
+ }
185
+ if (result.status !== 0) return { ok: false, reason: "codex-caller-seat-unresolved" };
186
+
187
+ const matches: CodexCallerSeat[] = [];
188
+ for (const line of result.stdout.split("\n")) {
189
+ if (line.length === 0) continue;
190
+ const pane = parsePaneLine(line);
191
+ // A line this leaf cannot read is NOT a pane it may skip quietly: the listing is the
192
+ // whole evidence base for "exactly one", and a dropped line could be the second match
193
+ // that should have refused.
194
+ if (pane === null) {
195
+ throw new Error(`codex-caller-seat: tmux printed a pane line this leaf cannot read: ${JSON.stringify(line)}`);
196
+ }
197
+ if (titleNamesThread(pane.title, anchors)) {
198
+ matches.push({ paneId: pane.paneId, sessionId: pane.sessionId, source: "codex-title-anchor" });
199
+ }
200
+ }
201
+ if (matches.length === 0) return { ok: false, reason: "codex-caller-seat-unresolved" };
202
+ if (matches.length > 1) return { ok: false, reason: "codex-caller-seat-ambiguous" };
203
+ return { ok: true, seat: matches[0] };
204
+ }
@@ -29,7 +29,40 @@ export const CODEX_PREFLIGHT_HINT: Record<CodexPreflightRejectReason, string> =
29
29
  "codex-visible-identity-missing":
30
30
  "Codex tui.status_line does not include thread-title; run `entwurf install-codex-statusline`, then `entwurf doctor-codex-statusline`",
31
31
  "codex-app-server-unavailable":
32
- "the operator-owned Codex app-server default socket is absent or unsafe; start `codex app-server --listen unix://$CODEX_HOME/app-server-control/app-server-control.sock` and retry",
32
+ "the operator-owned Codex app-server default socket is absent or unsafe; start it with `entwurf codex-app-server` from a detached tmux session (one that is NOT where you work), then retry",
33
+ };
34
+
35
+ /**
36
+ * THE CALLER AXIS, AND IT IS NOT THE ONE ABOVE. Everything above asks "can a Codex sibling be
37
+ * OPENED on this host" — birth unit, vendor trust, MCP hand, visible identity, app-server. This
38
+ * asks the opposite question: "can the Codex citizen DOING the opening be located", which is a
39
+ * fact about the CALLER's own config and is required no matter which backend it opens (#95
40
+ * lane B). Keeping them apart is the point: a Pi caller opening a Codex sibling needs the five
41
+ * above and none of this, and a Codex caller opening a Pi sibling needs this and none of those.
42
+ * Folding either into the other would refuse one operator for the other's missing repair.
43
+ */
44
+ export type CodexCallerPreflightRejectReason = "codex-caller-title-missing";
45
+
46
+ /**
47
+ * THE LAUNCH-DIRECTORY AXIS, AND IT IS A THIRD QUESTION AGAIN. The target axis asks "can a Codex
48
+ * sibling be opened on this host", the caller axis asks "can the Codex citizen doing the opening
49
+ * be located", and this asks "will the vendor START A TURN in the directory this launch names" —
50
+ * a fact about ONE directory rather than about the host or the caller, so it is the only axis
51
+ * that needs the resolved launch cwd and the only one that can pass for one call and refuse the
52
+ * next on an unchanged host.
53
+ */
54
+ export type CodexLaunchCwdPreflightRejectReason = "codex-launch-cwd-undecided" | "codex-launch-cwd-untrusted-ancestor";
55
+
56
+ export const CODEX_LAUNCH_CWD_PREFLIGHT_HINT: Record<CodexLaunchCwdPreflightRejectReason, string> = {
57
+ "codex-launch-cwd-undecided":
58
+ "Codex has recorded NO decision for the directory this sibling would start in, so the window would open on the vendor's folder-consent screen and WAIT for a human instead of running its first turn — no rollout, no callback, and nothing to address; either launch in a directory this Codex has already been answered for, or open a plain `codex -C <dir>` there once and answer it, then retry",
59
+ "codex-launch-cwd-untrusted-ancestor":
60
+ "this directory sits inside a project the operator marked `untrusted`, and on this rail the vendor does not offer a consent screen for that — it refuses the startup outright with `pass the repository root explicitly with --cd`, so answering a prompt at this directory would only reproduce that error; launch at that untrusted project's own root instead, or change that project's recorded decision",
61
+ };
62
+
63
+ export const CODEX_CALLER_PREFLIGHT_HINT: Record<CodexCallerPreflightRejectReason, string> = {
64
+ "codex-caller-title-missing":
65
+ "this Codex caller's tui.terminal_title does not include thread-id, so the multiplexer reports no pane title naming this thread and there is no caller seat to open the sibling beside; run `entwurf install-codex-terminal-title`, then `entwurf doctor-codex-terminal-title` (an explicit placement.tmuxSession skips this check entirely, because it never needs the seat)",
33
66
  };
34
67
 
35
68
  export interface CodexUnitPaths {
@@ -250,6 +283,190 @@ function visibleIdentityMissing(config: Record<string, unknown>): boolean {
250
283
  return !Array.isArray(statusLine) || !statusLine.includes("thread-title");
251
284
  }
252
285
 
286
+ /** `[tui].terminal_title` membership, the exact axis `entwurf doctor-codex-terminal-title`
287
+ * judges. Same shape as `visibleIdentityMissing` and a DIFFERENT key: `status_line` is what a
288
+ * human reads inside the TUI, `terminal_title` is what the multiplexer reports back as
289
+ * `#{pane_title}`. Neither substitutes for the other. */
290
+ function callerTitleMissing(config: Record<string, unknown>): boolean {
291
+ const tui = config.tui;
292
+ if (tui == null || typeof tui !== "object" || Array.isArray(tui)) return true;
293
+ const terminalTitle = (tui as Record<string, unknown>).terminal_title;
294
+ return !Array.isArray(terminalTitle) || !terminalTitle.includes("thread-id");
295
+ }
296
+
297
+ /**
298
+ * The CALLER-side capability, pre-mutation and synchronous.
299
+ *
300
+ * Synchronous because it reads one config file and nothing else: there is no app-server axis
301
+ * here and there must not be one — whether the operator's app-server is up says nothing about
302
+ * whether a caller's pane can be found, and asking would make a placement check fail for a
303
+ * delivery reason.
304
+ *
305
+ * Call it only when the anchor will actually be USED — a codex caller that named an explicit
306
+ * `placement` never consults the title, so refusing it for a missing `thread-id` would be a
307
+ * refusal for an unused capability.
308
+ */
309
+ export function codexCallerFreshPreflight(env: NodeJS.ProcessEnv): CodexCallerPreflightRejectReason | null {
310
+ const config = readConfig(env);
311
+ if (config === null || callerTitleMissing(config)) return "codex-caller-title-missing";
312
+ return null;
313
+ }
314
+
315
+ /**
316
+ * The launch directory's own capability, pre-mutation and synchronous — and DELIBERATELY NARROWER
317
+ * than the vendor's own decision, because the leaf that decides here reads one TOML file while the
318
+ * vendor asks its app-server. Read this whole comment as the scope statement it is: what follows
319
+ * says what this answers, and then says what it refuses to claim.
320
+ *
321
+ * WHY THE AXIS EXISTS. `[source rust-v0.153.4]` a fresh call always passes `--remote`, so the TUI
322
+ * resolves `AppServerTarget::Remote` and startup ALWAYS runs `check_directory_trust` on the `-C`
323
+ * value (`tui/src/lib.rs:1699-1725`). Nothing on that path consults the approval or sandbox policy
324
+ * (`tui/src/onboarding/directory_trust.rs:33-130`), which is why the
325
+ * `--dangerously-bypass-approvals-and-sandbox` token this argv already carries does NOT cover it:
326
+ * approvals and folder consent are two different gates. A directory with no recorded decision
327
+ * renders the consent screen and blocks, and a blocked TUI has started no turn — no rollout, no
328
+ * birth, no callback, and the caller waits out its whole timeout on a window that is merely
329
+ * waiting to be answered. `[측정 2026-09-16]` the two release-gate runs that failed launched in
330
+ * `…-2zznHl` and `…-kSsoAn`, neither of which had an entry; the two that passed launched in
331
+ * `…-db65N2` and `…-pBXxOJ`, both of which did. Unattended, the failure is silent.
332
+ *
333
+ * THE THREE VENDOR OUTCOMES THIS LEAF MODELS, in the vendor's own order
334
+ * (`tui/src/config_update.rs:290-371`, `ProjectTrustHost::Remote`):
335
+ *
336
+ * 1. A DIRECT decision on the exact cwd starts a turn, and BOTH recorded answers do. `trusted`
337
+ * returns before any screen (`:346-354`); a saved `untrusted` is explicitly skipped for a
338
+ * remote target — `if target.uses_remote_workspace() && trust_level == Some(Untrusted) {
339
+ * continue; }` (`onboarding/directory_trust.rs:94-96`), and `uses_remote_workspace()` is
340
+ * exactly `matches!(self, Self::Remote { .. })` (`tui/src/lib.rs:307-309`). Refusing a
341
+ * deliberate `untrusted` would be entwurf inventing a policy the vendor does not have.
342
+ * The key is the exact cwd — `cwd_keys = vec![cwd_key]` (`:290-296`), no root marker, no git
343
+ * root, no parent, all three of which exist only on the Local host. `[측정 2026-09-16]`
344
+ * `~/.codex/config.toml` carried a trusted `/tmp` and still recorded two separate
345
+ * `/tmp/entwurf-codex-fresh-live-*` entries, one per run a human answered.
346
+ * 2. With NO direct decision, an enabled PROJECT LAYER consents on the directory's behalf
347
+ * (`trust_level.is_none() && disabled_project.is_none() && project_layers.any(no
348
+ * disabledReason)` → `Ok(None)`, `:346-354`). Those layers come from the app-server's
349
+ * `ConfigRead { include_layers: true }` answer, which this leaf does not have. A layer that
350
+ * is present but DISABLED is a fourth outcome — the vendor preserves an unknown or untrusted
351
+ * project layer as disabled rather than dropping it, and that path falls through to the
352
+ * screen — and this leaf cannot tell the two apart either. So wherever a layer could exist at
353
+ * all it answers `null`, which folds both outcomes into "proceed".
354
+ * 3. With no direct decision and NO layers, a cwd inside an explicitly `untrusted` ancestor is
355
+ * not a consent screen at all: the remote branch returns an ERROR
356
+ * (`"remote project directory is inside an explicitly untrusted project; pass the repository
357
+ * root explicitly with --cd"`, `:357-371`). That is a different failure with a different
358
+ * repair, so it gets its own reason — answering `Trust` at the child would only reproduce the
359
+ * same vendor error. Note the vendor's own precondition there is `project_layers.is_empty()`,
360
+ * which is why this reason is only reachable after step 2 has found no layer anywhere.
361
+ *
362
+ * WHAT THIS LEAF IS FOR, AND WHAT IT IS NOT. It is NOT equivalent to the vendor's judgment and
363
+ * must not be described as asking "the same question": it reads the operator's own `config.toml`,
364
+ * while the vendor reads an EFFECTIVE config — system, managed and cloud layers merged around that
365
+ * user layer (`config/src/loader/mod.rs:258-290`, `:430-460`) — through its app-server.
366
+ *
367
+ * That gap is affordable because NOTHING HERE REFUSES A LAUNCH. `freshCall` prints what this leaf
368
+ * saw and opens the window anyway: the consent screen is self-repairing when a human is there, and
369
+ * one answer teaches the vendor the directory for good. The one caller that treats the answer as a
370
+ * precondition is `smoke-codex-fresh-live`, where nobody is at the keyboard and a named
371
+ * precondition is worth more than a callback timeout — that gate owns one directory, answered
372
+ * once.
373
+ *
374
+ * So read a non-null answer as "the vendor will probably stop here, and this is the repair", never
375
+ * as a verdict. Absence is not evidence either way, and every case this leaf cannot see resolves
376
+ * to `null`.
377
+ */
378
+ export function codexLaunchCwdFreshPreflight(
379
+ env: NodeJS.ProcessEnv,
380
+ launchCwd: string,
381
+ ): CodexLaunchCwdPreflightRejectReason | null {
382
+ // THREE NON-ANSWERS, AND EVERY ONE OF THEM PROCEEDS. Each is a case where this leaf holds no
383
+ // evidence about what the vendor will do, and a refusal without evidence is exactly the false
384
+ // refusal this axis promises not to produce:
385
+ //
386
+ // - A RELATIVE directory. It cannot match a stored key here, but the vendor does not give up
387
+ // on one — it asks its app-server for a cwd and joins (`config_update.rs:203-224`), so the
388
+ // joined path may well be answered. In production this branch is unreachable anyway: the
389
+ // shared cwd leaf already refuses a non-absolute request as `cwd-not-absolute`, which is a
390
+ // better reason than anything this axis could give.
391
+ // - NO READABLE USER CONFIG. A missing `config.toml` is not "no decisions": the vendor loads
392
+ // an empty user table and merges system, managed and cloud layers around it
393
+ // (`config/src/loader/mod.rs:258-290`, `:430-460`, `:520-610`), any of which can carry the
394
+ // decision or the layer that starts the turn.
395
+ // - NO `projects` TABLE. Same reason: it says the USER layer records nothing, not that the
396
+ // effective config does.
397
+ if (!path.isAbsolute(launchCwd)) return null;
398
+ const config = readConfig(env);
399
+ if (config === null) return null;
400
+ const projects = config.projects;
401
+ if (projects == null || typeof projects !== "object" || Array.isArray(projects)) {
402
+ return null;
403
+ }
404
+ const table = projects as Record<string, unknown>;
405
+ const levelOf = (key: string): string | null => {
406
+ const entry = table[key];
407
+ if (entry == null || typeof entry !== "object" || Array.isArray(entry)) return null;
408
+ const level = (entry as Record<string, unknown>).trust_level;
409
+ // An unrecognised value leaves `trust_level` as `None` on the vendor side too, and `None`
410
+ // with no project layer is precisely the case that renders the screen.
411
+ return level === "trusted" || level === "untrusted" ? level : null;
412
+ };
413
+ // Outcome 1: a direct decision, either answer.
414
+ if (levelOf(launchCwd) !== null) return null;
415
+ // Outcome 2: a PROJECT LAYER may consent with no entry at all, and this leaf cannot enumerate
416
+ // layers — they come from the app-server's `ConfigRead { include_layers: true }` answer. What
417
+ // it CAN decide is the negative: a `.codex` anywhere from the directory upward is the only
418
+ // place such a layer comes from, so when none exists the vendor's `project_layers` really is
419
+ // empty and the two remaining outcomes below are computable. Where one could exist, answer
420
+ // `null`.
421
+ //
422
+ // EXISTENCE, not ownership, and the weaker predicate is the load-bearing one. The vendor
423
+ // admits a layer on its own terms — an unknown or untrusted one is preserved as a DISABLED
424
+ // layer, not dropped — and none of that consults owner/mode/symlink safety. Asking for a
425
+ // safely-owned directory here would let a layer the vendor sees go unseen by this leaf, which
426
+ // would then synthesise an `untrusted-ancestor` refusal for a launch the vendor was going to
427
+ // run. That is the false refusal this axis must never produce, so the check is the widest
428
+ // thing that still means "a layer could live here".
429
+ //
430
+ // The operator's own CODEX HOME is excluded, and excluding it is what keeps this axis from
431
+ // being a no-op: `~/.codex` is an ancestor of nearly every directory anyone launches a sibling
432
+ // in, and it is the USER config root rather than a project layer — the vendor names those
433
+ // apart (`layer.name.dotCodexFolder` for a project, the home for the user layer). Counting it
434
+ // would answer `null` for every path under `$HOME` and the check would never fire in real use.
435
+ const codexHome = path.resolve(env.CODEX_HOME?.trim() || path.join(env.HOME ?? "", ".codex"));
436
+ for (let dir = launchCwd; ; ) {
437
+ const candidate = path.join(dir, ".codex");
438
+ if (candidate !== codexHome && fs.existsSync(candidate)) return null;
439
+ const parent = path.dirname(dir);
440
+ if (parent === dir) break;
441
+ dir = parent;
442
+ }
443
+ // Outcome 3: no layer anywhere and an explicitly untrusted ancestor — the vendor's own
444
+ // precondition for that branch is exactly `project_layers.is_empty()`, which the loop above
445
+ // has now established. It is reported as its own failure because its repair is to launch at
446
+ // that root, never to answer a prompt at the child.
447
+ //
448
+ // The MATCH IS NOT THE VENDOR'S and is not claimed to be. The vendor compares path URIs
449
+ // (`LegacyAppPathString` → `PathUri::starts_with`, segment-aware, fail-closed on encoded
450
+ // separators); this compares plain strings on a separator boundary. Where the two could
451
+ // disagree — a key or a cwd that is not a plain POSIX path — this answers `undecided` rather
452
+ // than `untrusted-ancestor`. Be exact about what that buys: BOTH are refusals, so this is NOT
453
+ // the launch-permissive direction. What it weakens is the SPECIFIC-ERROR claim — the ancestor
454
+ // reason names another directory as the repair, and naming the wrong one is worse than saying
455
+ // "no decision here". Neither vendor outcome on this branch starts a turn, so the guarantee
456
+ // above is untouched either way.
457
+ const plainPosixPath = (value: string): boolean =>
458
+ !value.includes("%") && !value.includes("\\") && !value.split("/").some((seg) => seg === "." || seg === "..");
459
+ if (plainPosixPath(launchCwd)) {
460
+ for (const key of Object.keys(table)) {
461
+ if (levelOf(key) !== "untrusted" || !plainPosixPath(key)) continue;
462
+ if (launchCwd === key || launchCwd.startsWith(key.endsWith("/") ? key : `${key}/`)) {
463
+ return "codex-launch-cwd-untrusted-ancestor";
464
+ }
465
+ }
466
+ }
467
+ return "codex-launch-cwd-undecided";
468
+ }
469
+
253
470
  const DEFAULT_APP_SERVER_TIMEOUT_MS = 5_000;
254
471
 
255
472
  function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
@@ -12,8 +12,9 @@
12
12
  *
13
13
  * Why a result type richer than the receipt: the carry-over contracts from 5c demand it.
14
14
  * - N3 (5c-2b): a dead-path re-resolve `rejected` carries the resolver's `rejectReason`
15
- * (dormant-fire-forget-unsupported / mailbox-undeliverable / …). The runner carries it
16
- * verbatim so the surface distinguishes "in-band refusal" from "no live route".
15
+ * (dormant-fire-forget-unsupported / mailbox-undeliverable / …), while an in-band
16
+ * refusal carries its supplied receiver error. The runner carries either verbatim;
17
+ * receiver evidence is not reclassified as resolver taxonomy.
17
18
  * - N1 (5c-2a): a non-`failed` outcome whose `releaseLock` then threw is a
18
19
  * `SendDeliveredReleaseFailedError` — the delivery HAPPENED, the lock is dirty, a
19
20
  * re-send would double-deliver. The runner surfaces this as `execution-failed` with
@@ -101,12 +101,12 @@ export interface ControlSocketSendDeps {
101
101
 
102
102
  export interface ControlSocketSendResult {
103
103
  outcome: SendFinalOutcome;
104
- /** Present ONLY on a `rejected` outcome that came from the dead-path re-resolve
105
- * (5c-2b): the resolver's machine-readable reason (dormant-fire-forget-unsupported /
106
- * mailbox-undeliverable / indeterminate-no-spawn / bad-target / target-address-conflict).
107
- * An in-band RPC refusal carries NO reason (there is no resolver taxonomy for it). The
108
- * 5d runner carries this verbatim so the surface can tell "in-band refusal" from
109
- * "no live route" the N3 carry-over the hand boundary used to drop. */
104
+ /** Present ONLY on a `rejected` outcome. It carries either the dead-path re-resolver's
105
+ * machine-readable reason (dormant-fire-forget-unsupported / mailbox-undeliverable /
106
+ * indeterminate-no-spawn / bad-target / target-address-conflict), or the non-empty
107
+ * error returned by an in-band RPC/mailbox refusal. The latter is receiver evidence,
108
+ * not a resolver taxonomy and is never invented when the completed result has no error.
109
+ * The 5d runner carries this verbatim to the sender-visible surface. */
110
110
  rejectReason?: string;
111
111
  /** #98 R, fallback leg: the `.msg` a dead-socket re-resolve enqueued. Present ONLY
112
112
  * when the fallback actually routed to the mailbox and the enqueue succeeded — a
@@ -120,7 +120,7 @@ export interface ControlSocketSendResult {
120
120
 
121
121
  // A drive step's verdict: the terminal outcome, plus the original error to RETHROW on
122
122
  // a `failed` (the hand releases first, then rethrows — never swallows the failure), plus
123
- // the optional resolver reject reason to carry on a re-resolve `rejected` (N3).
123
+ // the optional resolver or in-band receiver reason to carry on a `rejected` (N3).
124
124
  interface SendDrive {
125
125
  outcome: SendFinalOutcome;
126
126
  error?: unknown;
@@ -177,8 +177,9 @@ async function driveSend(plan: ControlSocketPlan, lock: LockClaim, deps: Control
177
177
  // dead ⇒ proven non-delivery ⇒ same-lock one-shot re-resolve (lock still held).
178
178
  return await driveDeadFallback(plan, lock, deps);
179
179
  }
180
- // A completed RPC: ack ⇒ sent; in-band refusal ⇒ rejected, NO fallback.
181
- return { outcome: result.success ? "sent" : "rejected" };
180
+ // A completed RPC: ack ⇒ sent; in-band refusal ⇒ rejected, NO fallback. Preserve
181
+ // its receiver error verbatim when supplied; do not make a reason up when absent.
182
+ return result.success ? { outcome: "sent" } : inBandRejected(result);
182
183
  }
183
184
 
184
185
  /**
@@ -187,6 +188,10 @@ async function driveSend(plan: ControlSocketPlan, lock: LockClaim, deps: Control
187
188
  * re-enter the fallback, it finalizes as failed). The hand only executes; the resolver
188
189
  * decided.
189
190
  */
191
+ function inBandRejected(result: RpcSendResult): SendDrive {
192
+ return { outcome: "rejected", rejectReason: result.error === "" ? undefined : result.error };
193
+ }
194
+
190
195
  async function driveDeadFallback(
191
196
  plan: ControlSocketPlan,
192
197
  lock: LockClaim,
@@ -219,7 +224,7 @@ async function driveDeadFallback(
219
224
  // connect failure finalizes as failed (no further fallback).
220
225
  try {
221
226
  const r = await deps.sendOverSocket(rePlan);
222
- return { outcome: r.success ? "fallback-sent" : "rejected" };
227
+ return r.success ? { outcome: "fallback-sent" } : inBandRejected(r);
223
228
  } catch (err) {
224
229
  return { outcome: "failed", error: err };
225
230
  }
@@ -231,7 +236,7 @@ async function driveDeadFallback(
231
236
  // #98 R: this leg writes a `.msg` exactly like the primary mailbox rail, so it
232
237
  // owes the sender the same per-message receipt. Carried only on success — a
233
238
  // `rejected` enqueue wrote no file to name.
234
- return r.success ? { outcome: "fallback-sent", messagePath: r.messagePath } : { outcome: "rejected" };
239
+ return r.success ? { outcome: "fallback-sent", messagePath: r.messagePath } : inBandRejected(r);
235
240
  } catch (err) {
236
241
  return { outcome: "failed", error: err };
237
242
  }