@junghanacs/entwurf 0.35.1 → 0.36.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 (42) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +209 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/DELIVERY.md +15 -0
  5. package/FAQ.md +3 -3
  6. package/README.md +30 -16
  7. package/VERIFY.md +9 -7
  8. package/demo/README.md +1 -1
  9. package/docs/acp-backend-rail.md +1 -1
  10. package/docs/setup-clean-host.md +30 -22
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +4 -3
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/codex-ws-client.js +99 -18
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +3 -3
  14. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +59 -14
  15. package/mcp/entwurf-bridge/dist/scripts/codex-socket-classify.js +44 -0
  16. package/mcp/entwurf-bridge/tsconfig.build.json +5 -0
  17. package/package.json +19 -14
  18. package/pi/pi-durable/bootstrap.mjs +12 -8
  19. package/pi/pi-durable/carrier/runtime.js +52 -2
  20. package/pi/pi-durable/carrier/subagent.js +6 -1
  21. package/pi/pi-durable/carrier/tui.js +31 -4
  22. package/pi/pi-durable/carrier-relocation.mjs +2 -2
  23. package/pi/pi-durable/overlay/upstream-pin.json +79 -36
  24. package/pi-extensions/lib/codex-fresh-preflight.ts +4 -3
  25. package/pi-extensions/lib/native-push/codex-ws-client.ts +129 -18
  26. package/pi-extensions/lib/pi-durable-runtime.ts +4 -4
  27. package/pi-extensions/meta-bridge-pi-durable.ts +77 -21
  28. package/run.sh +112 -38
  29. package/scripts/check-codex-app-server-launch.ts +55 -12
  30. package/scripts/check-codex-native-push.ts +231 -12
  31. package/scripts/check-gate-qualification.ts +3 -3
  32. package/scripts/codex-app-server-launch.sh +54 -29
  33. package/scripts/codex-socket-classify.ts +46 -0
  34. package/scripts/emit-pi-durable-carrier.ts +159 -168
  35. package/scripts/mutants/codex-app-server-launch.json +39 -12
  36. package/scripts/mutants/codex-native.json +15 -0
  37. package/scripts/mutants/control-send-receipt.json +1 -1
  38. package/scripts/mutants/pack-install.json +3 -3
  39. package/scripts/mutants/pi-durable.json +165 -16
  40. package/scripts/mutants/pi-floor.json +5 -5
  41. package/scripts/pi-durable-contact-host.mjs +1 -1
  42. package/pi/pi-durable/overlay/runtime-contacts.patch +0 -283
@@ -46,7 +46,7 @@ import { ToolExecutionComponent, } from "entwurf-pi-dist:mode
46
46
  import { UserMessageComponent } from "entwurf-pi-dist:modes/interactive/components/user-message.js";
47
47
  import { getEditorTheme, getMarkdownTheme, initTheme, theme } from "entwurf-pi-dist:modes/interactive/theme/theme.js";
48
48
  import { InteractiveThemeController } from "entwurf-pi-dist:modes/interactive/theme/theme-controller.js";
49
- import { agentOf, } from "./runtime.js";
49
+ import { agentOf, } from "./runtime.js";
50
50
 
51
51
  const SELECT_THEME = {
52
52
  selectedPrefix: (text) => theme.fg("accent", text),
@@ -152,6 +152,26 @@ class CompactionComponent extends Box {
152
152
 
153
153
 
154
154
 
155
+ /**
156
+ * The lines above the editor: first what stays true for as long as it is true, the session's failure and the
157
+ * compaction failure a conversation keeps until a compaction produces a summary, then the latest notices.
158
+ */
159
+ export function durableNotices(view ) {
160
+ const lines = [];
161
+ if (view.failure !== undefined) {
162
+ const cause = view.failure.error === undefined ? "" : `: ${view.failure.error}`;
163
+ lines.push({
164
+ level: "error",
165
+ message: `Session failed${cause}. It takes no more input, and a tool still running may take a while to stop. Close this session, then reopen it with --continue.`,
166
+ });
167
+ }
168
+ const compaction = ((view.conversation.docs["pi.live"] ?? {}) ).compactionFailure;
169
+ if (compaction !== undefined) {
170
+ lines.push({ level: "error", message: `Compaction failed (${compaction.reason}): ${compaction.error}` });
171
+ }
172
+ return [...lines, ...view.notices.slice(-4)];
173
+ }
174
+
155
175
  /**
156
176
  * The live background-task badge: the dock's first row, never shrunk, so live background work stays on
157
177
  * screen however small the terminal gets. It counts the graph's background tasks whatever their kind and
@@ -212,7 +232,13 @@ export function durableLayout(transcript , badge , dock
212
232
  minSize: 1,
213
233
  visible: (viewport) => badge.count === 0 || viewport.height > 1,
214
234
  },
215
- { component: new VStack([{ component: badge, shrink: 0 }, ...dock]), basis: "auto", grow: 0, shrink: 1, minSize: 1 },
235
+ {
236
+ component: new VStack([{ component: badge, shrink: 0 }, ...dock]),
237
+ basis: "auto",
238
+ grow: 0,
239
+ shrink: 1,
240
+ minSize: 1,
241
+ },
216
242
  ]);
217
243
  }
218
244
 
@@ -368,7 +394,8 @@ class DurableTui {
368
394
  this.#syncQueue((view.conversation.docs["pi.inbox"] ?? { items: [] }) );
369
395
  this.#syncNotices(view);
370
396
  this.#editor.borderColor = theme.getThinkingBorderColor(agentOf(view.conversation).thinkingLevel ?? "off");
371
- this.#syncStatus(live);
397
+ // A failed session's live state is its last one; nothing is working any more.
398
+ this.#syncStatus(view.failure === undefined ? live : {});
372
399
  this.#syncFooter(view);
373
400
  if (this.#rebuilt) this.#transcript.scrollToEnd();
374
401
  this.#ui.requestRender(this.#rebuilt);
@@ -409,7 +436,7 @@ class DurableTui {
409
436
 
410
437
  #syncNotices(view ) {
411
438
  this.#notices.clear();
412
- for (const item of view.notices.slice(-4)) {
439
+ for (const item of durableNotices(view)) {
413
440
  const color = item.level === "error" ? "error" : item.level === "warning" ? "warning" : "muted";
414
441
  this.#notices.addChild(new TruncatedText(theme.fg(color, item.message), 1, 0));
415
442
  }
@@ -3,8 +3,8 @@
3
3
  // rewrite the emitter performs and the resolution the runtime accepts cannot drift apart.
4
4
  //
5
5
  // The durable app is source-only upstream: published `@earendil-works/pi-coding-agent` ships no
6
- // `dist/experimental`, and its exports map exposes no deep `dist/` path. The carrier is the pinned
7
- // upstream app files, type-stripped, with exactly two rewrites:
6
+ // `dist/experimental`, and its exports map exposes no deep `dist/` path. The carrier is Entwurf's
7
+ // owned app source (pi/pi-durable/src, #141), type-stripped, with exactly two rewrites:
8
8
  //
9
9
  // "./<sibling>.ts" → "./<sibling>.js" a carrier file importing another carrier file
10
10
  // "../../<path>.ts" → entwurf-pi-dist:<path>.js the app importing a pi-coding-agent internal
@@ -1,64 +1,107 @@
1
1
  {
2
- "schemaVersion": 2,
3
- "comment": "The one upstream commit the pi-durable carrier is emitted from, the patch it applies, the emitted files and the published SDK set they run against. The experimental durable app is source-only upstream (published pi-coding-agent ships no dist/experimental), so Entwurf ships it as a CARRIER: the pinned app files, type-stripped, with the relocation in pi/pi-durable/carrier-relocation.mjs. scripts/emit-pi-durable-carrier.ts is the only writer of the generated fields (blob, sha256, emitter.node) and the only reader of upstream source; an installed package never reads source. Model data rides the published @earendil-works/pi-ai.",
4
- "repository": "https://github.com/earendil-works/pi",
5
- "tag": "v1.1.0",
6
- "commit": "abe508e1b89912adde45528136c3221eb69acdd7",
7
- "patches": ["runtime-contacts.patch"],
2
+ "schemaVersion": 3,
3
+ "comment": "The pi-durable carrier: what it is emitted from, what it is, and the published SDK set it runs against. The experimental durable app is source-only upstream (published pi-coding-agent ships no dist/experimental), so Entwurf owns its thin application/TUI composition as canonical source (pi/pi-durable/src) and ships it as a CARRIER: those files, type-stripped, with the relocation in pi/pi-durable/carrier-relocation.mjs. The SDK engine (execution, scheduler, queues, storage/transcript, compaction, cancellation/recovery, providers, auth) stays the published packages in `sdk`. scripts/emit-pi-durable-carrier.ts is the only writer of the generated fields (carrier blob, sha256, emitter.node); an installed package never reads source. Model data rides the published @earendil-works/pi-ai.",
4
+ "origin": {
5
+ "comment": "Frozen history, never an input: the upstream commit the owned source was first taken from, its objects as recorded then, the overlay patch that carried Entwurf's changes until it was folded into the source (#141), and the carrier bytes the last patch-built release shipped. Neither install nor emit reads this; `emit-pi-durable-carrier --origin <clone>` checks it as provenance only.",
6
+ "repository": "https://github.com/earendil-works/pi",
7
+ "tag": "v1.1.0",
8
+ "commit": "abe508e1b89912adde45528136c3221eb69acdd7",
9
+ "license": {
10
+ "path": "LICENSE",
11
+ "blob": "b0a8e9b81083294360c69b4ec45d3d39a2b28197"
12
+ },
13
+ "files": [
14
+ {
15
+ "path": "packages/coding-agent/src/experimental/durable/runtime.ts",
16
+ "blob": "fcd6e5239e999ebf17dea0335fb3dc9cd7f7ce45"
17
+ },
18
+ {
19
+ "path": "packages/coding-agent/src/experimental/durable/tui.ts",
20
+ "blob": "397b276cbeb1c90e1fbc96c3cc59e6a25f92dad7"
21
+ },
22
+ {
23
+ "path": "packages/coding-agent/src/experimental/durable/harness-setup.ts",
24
+ "blob": "4d6a4be5333ebb8e0f17eac1987f18ea35a91fc7"
25
+ },
26
+ {
27
+ "path": "packages/coding-agent/src/experimental/durable/prompt.ts",
28
+ "blob": "8e2f5d5273fba58da8b314766956ed62951c3814"
29
+ },
30
+ {
31
+ "path": "packages/coding-agent/src/experimental/durable/sessions.ts",
32
+ "blob": "34f9ceeeaeaf874f7478d56115da2e4f726c6a85"
33
+ },
34
+ {
35
+ "path": "packages/coding-agent/src/experimental/durable/subagent.ts",
36
+ "blob": "e767d54e0bf2908e0f4f41c6933b55c00562eb25"
37
+ }
38
+ ],
39
+ "retiredOverlay": {
40
+ "name": "runtime-contacts.patch",
41
+ "sha256": "5b1bd9b48d4ff58ad223006d81cc12c4e0b18959cca5cda5ab3ad89f68bbe898"
42
+ },
43
+ "baselineCarrier": {
44
+ "release": "0.35.1",
45
+ "node": "v24.18.1",
46
+ "files": {
47
+ "runtime.js": "bfd88844fae61e8bd32d57b5b986f48056c64eebb5466d9826318a0783ac9005",
48
+ "tui.js": "f61cc68e5d32552a8196785093ae9e2f4a946088fe71582bb05690517db8c5b4",
49
+ "harness-setup.js": "712b630145dc6f5c655a85b01b1e20299c962542e63c5494d2202bb7bf414a15",
50
+ "prompt.js": "ff1ff765fa313e5cb1c3d7537c901c23adb0562ee433bd050fd35eb3d892134c",
51
+ "sessions.js": "1bd1a04ece66f5ab3ca22860c64dba9abc848334d11a27a9afe543f1365c7254",
52
+ "subagent.js": "8a75d934ef87efbb0bb2006a2141f1ea932560851f1718b991ed947d6efdb54c",
53
+ "LICENSE": "0457f5bcec3b3b211605dfb5d1a49042fd638f3686a410fe099c24a25af13c48"
54
+ }
55
+ }
56
+ },
8
57
  "carrier": {
9
- "comment": "files: the six app files bootstrap.mjs needs (main.ts is replaced by the bootstrap). distTargets: every pi-coding-agent internal the stripped files import, as paths under the published dist/; the emitter refuses a set that differs and the resolver answers no other.",
58
+ "comment": "files: the six app files bootstrap.mjs needs (main.ts is replaced by the bootstrap), each emitted from its owned `source`. distTargets: every pi-coding-agent internal the stripped files import, as paths under the published dist/; the emitter refuses a set that differs and the resolver answers no other.",
10
59
  "emitter": {
11
60
  "tool": "node:module.stripTypeScriptTypes",
12
61
  "mode": "strip",
13
62
  "node": "v24.18.1"
14
63
  },
15
64
  "license": {
16
- "source": "LICENSE",
65
+ "source": "pi/pi-durable/src/LICENSE",
17
66
  "blob": "b0a8e9b81083294360c69b4ec45d3d39a2b28197",
18
67
  "sha256": "0457f5bcec3b3b211605dfb5d1a49042fd638f3686a410fe099c24a25af13c48"
19
68
  },
20
69
  "files": [
21
70
  {
22
71
  "name": "runtime.js",
23
- "source": "packages/coding-agent/src/experimental/durable/runtime.ts",
24
- "patched": true,
25
- "blob": "fcd6e5239e999ebf17dea0335fb3dc9cd7f7ce45",
26
- "sha256": "bfd88844fae61e8bd32d57b5b986f48056c64eebb5466d9826318a0783ac9005"
72
+ "source": "pi/pi-durable/src/experimental/durable/runtime.ts",
73
+ "blob": "36c352291620ab86184172a1b4a0df4562ab9581",
74
+ "sha256": "60c2965eadbdbbe78fce1b7eeb89d97eb3104a30b7d58ebca87009d2e9118403"
27
75
  },
28
76
  {
29
77
  "name": "tui.js",
30
- "source": "packages/coding-agent/src/experimental/durable/tui.ts",
31
- "patched": true,
32
- "blob": "397b276cbeb1c90e1fbc96c3cc59e6a25f92dad7",
33
- "sha256": "f61cc68e5d32552a8196785093ae9e2f4a946088fe71582bb05690517db8c5b4"
78
+ "source": "pi/pi-durable/src/experimental/durable/tui.ts",
79
+ "blob": "dab99cd4778ae1db7f062e64db2d0c068f7e342d",
80
+ "sha256": "0f9ebaf8fbb2a532ccd1a7198e1a25533a99720b5fedbf03f42d5c381db3abc5"
34
81
  },
35
82
  {
36
83
  "name": "harness-setup.js",
37
- "source": "packages/coding-agent/src/experimental/durable/harness-setup.ts",
38
- "patched": false,
84
+ "source": "pi/pi-durable/src/experimental/durable/harness-setup.ts",
39
85
  "blob": "4d6a4be5333ebb8e0f17eac1987f18ea35a91fc7",
40
86
  "sha256": "712b630145dc6f5c655a85b01b1e20299c962542e63c5494d2202bb7bf414a15"
41
87
  },
42
88
  {
43
89
  "name": "prompt.js",
44
- "source": "packages/coding-agent/src/experimental/durable/prompt.ts",
45
- "patched": false,
90
+ "source": "pi/pi-durable/src/experimental/durable/prompt.ts",
46
91
  "blob": "8e2f5d5273fba58da8b314766956ed62951c3814",
47
92
  "sha256": "ff1ff765fa313e5cb1c3d7537c901c23adb0562ee433bd050fd35eb3d892134c"
48
93
  },
49
94
  {
50
95
  "name": "sessions.js",
51
- "source": "packages/coding-agent/src/experimental/durable/sessions.ts",
52
- "patched": false,
96
+ "source": "pi/pi-durable/src/experimental/durable/sessions.ts",
53
97
  "blob": "34f9ceeeaeaf874f7478d56115da2e4f726c6a85",
54
98
  "sha256": "1bd1a04ece66f5ab3ca22860c64dba9abc848334d11a27a9afe543f1365c7254"
55
99
  },
56
100
  {
57
101
  "name": "subagent.js",
58
- "source": "packages/coding-agent/src/experimental/durable/subagent.ts",
59
- "patched": false,
60
- "blob": "e767d54e0bf2908e0f4f41c6933b55c00562eb25",
61
- "sha256": "8a75d934ef87efbb0bb2006a2141f1ea932560851f1718b991ed947d6efdb54c"
102
+ "source": "pi/pi-durable/src/experimental/durable/subagent.ts",
103
+ "blob": "3fa9aebbe93e82cfff436e7b9d4c9877e0ed0e27",
104
+ "sha256": "1cb2390b673a0d914117bc07708c39b004fb10e48b31436bf9d9d2b953a64220"
62
105
  }
63
106
  ],
64
107
  "distTargets": [
@@ -89,17 +132,17 @@
89
132
  ]
90
133
  },
91
134
  "sdk": {
92
- "comment": "The published library set the carrier runs against, derived from the 1.1.0 declarations. members: the @earendil-works packages the carrier imports and every @earendil-works package those declare. direct: a third-party package a carrier file imports itself (sessions.ts: proper-lockfile, at the version pi-coding-agent 1.1.0 pins). Each is an exact production dependency in package.json; the members' own edges are caret ranges, so the installed physical edges, not semver, are what must equal these versions.",
135
+ "comment": "The published library set the carrier runs against, derived from the 1.2.0 declarations. members: the @earendil-works packages the carrier imports and every @earendil-works package those declare. direct: a third-party package a carrier file imports itself (sessions.ts: proper-lockfile, at the version pi-coding-agent 1.2.0 pins). Each is an exact production dependency in package.json; the members' own edges are caret ranges, so the installed physical edges, not semver, are what must equal these versions.",
93
136
  "members": {
94
- "@earendil-works/chord": "1.1.0",
95
- "@earendil-works/pi-agent-core": "1.1.0",
96
- "@earendil-works/pi-ai": "1.1.0",
97
- "@earendil-works/pi-codemode": "1.1.0",
98
- "@earendil-works/pi-coding-agent": "1.1.0",
99
- "@earendil-works/pi-durable": "1.1.0",
100
- "@earendil-works/pi-mcp": "1.1.0",
101
- "@earendil-works/pi-telemetry": "1.1.0",
102
- "@earendil-works/pi-tui": "1.1.0"
137
+ "@earendil-works/chord": "1.2.0",
138
+ "@earendil-works/pi-agent-core": "1.2.0",
139
+ "@earendil-works/pi-ai": "1.2.0",
140
+ "@earendil-works/pi-codemode": "1.2.0",
141
+ "@earendil-works/pi-coding-agent": "1.2.0",
142
+ "@earendil-works/pi-durable": "1.2.0",
143
+ "@earendil-works/pi-mcp": "1.2.0",
144
+ "@earendil-works/pi-telemetry": "1.2.0",
145
+ "@earendil-works/pi-tui": "1.2.0"
103
146
  },
104
147
  "direct": {
105
148
  "proper-lockfile": "4.1.2"
@@ -624,11 +624,12 @@ export async function codexFreshPreflight(
624
624
  if (config === null || trustReceiptMissing(config, key)) return "codex-birth-trust-missing";
625
625
  if (mcpMissing(config)) return "codex-mcp-hand-missing";
626
626
  if (visibleIdentityMissing(config)) return "codex-visible-identity-missing";
627
- const socketPath = resolveCodexDefaultSocketPath(env);
628
- const socket = (deps.checkSocket ?? checkCodexSocketFile)(socketPath);
627
+ const socket = (deps.checkSocket ?? checkCodexSocketFile)(resolveCodexDefaultSocketPath(env));
629
628
  if (!socket.ok) return "codex-app-server-unavailable";
629
+ // The protocol exchange goes to the endpoint the check verified, never the advertised path
630
+ // again (on Codex 0.162.1 that path is an alias; see `checkCodexSocketFile`).
630
631
  const live = await appServerIsLive(
631
- socketPath,
632
+ socket.connectPath,
632
633
  deps.openProtocol ?? realCodexProtocolOpener,
633
634
  deps.appServerTimeoutMs ?? DEFAULT_APP_SERVER_TIMEOUT_MS,
634
635
  );
@@ -23,30 +23,135 @@ export function resolveCodexDefaultSocketPath(env: CodexSocketEnv): string {
23
23
  return path.join(resolveCodexHome(env), "app-server-control", "app-server-control.sock");
24
24
  }
25
25
 
26
- export type CodexSocketFileCheck = { ok: true } | { ok: false; status: "dead" | "indeterminate"; reason: string };
26
+ /**
27
+ * What the advertised control-socket path is, and the one endpoint a caller may connect to.
28
+ *
29
+ * `connectPath` is a transport endpoint for one exchange: the socket this check verified. It is
30
+ * not an address, a record field or a replacement for `resolveCodexDefaultSocketPath`. `dead`
31
+ * names which absence it is, because the managed launcher treats them differently: an absent
32
+ * path is a first launch, and a dangling rendezvous alias is the leftover of a crashed server
33
+ * that the vendor removes on its next start.
34
+ */
35
+ export type CodexSocketFileCheck =
36
+ | { ok: true; connectPath: string }
37
+ | { ok: false; status: "dead"; layout: "absent" | "dangling-alias"; reason: string }
38
+ | { ok: false; status: "indeterminate"; reason: string };
39
+
40
+ /** Test seam only: the gate points these at a sandbox. Production callers pass nothing. */
41
+ export interface CodexSocketFileDeps {
42
+ readonly daemonRoot?: string;
43
+ readonly euid?: number;
44
+ }
27
45
 
28
- export function checkCodexSocketFile(socketPath: string): CodexSocketFileCheck {
46
+ /**
47
+ * The advertised path is exactly one of two layouts, and anything else is refused.
48
+ *
49
+ * (A) A plain socket owned by the effective uid: Codex up to 0.153.4 binds the advertised
50
+ * path itself. `connectPath` is that path.
51
+ * (B) Codex's rendezvous alias. From 0.162.1 the advertised path "is only an alias": the
52
+ * server binds a 0600 socket under `realpath(/tmp)/codex-daemon-<euid>/` named by the
53
+ * sha256 of the canonical advertised path, then symlinks the advertised path to it
54
+ * (`[source]` rust-v0.162.1 `app-server-transport/src/transport/unix_socket.rs:43-131,
55
+ * 290-304`; `uds/src/daemon_directory.rs:1-38`). Upstream's reason is a privileged
56
+ * rendezvous root that sandboxes hide; the alias also keeps an over-long advertised path
57
+ * usable (`uds/src/lib.rs:167-174`). The alias is accepted only when every hop is the
58
+ * vendor's own: the link is owned by the effective uid; its target is BYTE-EQUAL to the
59
+ * path Codex derives (a relative, foreign, re-hashed or chained target is refused); the
60
+ * daemon directory is a real directory owned by the effective uid with `mode & 0o777`
61
+ * exactly 0700, the vendor's own rule; and the target is a socket owned by the effective
62
+ * uid that no group or other user can open. `connectPath` is the verified target, so the
63
+ * alias is never re-resolved between this check and the connect. A same-euid process can
64
+ * still replace the verified socket file itself; that residual is named, not locked.
65
+ *
66
+ * Only `lstat` and one `readlink` touch the alias; it is never followed or `realpath`ed.
67
+ * The effective uid is the vendor's (`libc::geteuid`), not the real uid.
68
+ */
69
+ export function checkCodexSocketFile(socketPath: string, deps: CodexSocketFileDeps = {}): CodexSocketFileCheck {
70
+ const euid = deps.euid ?? process.geteuid?.();
71
+ let stat: fs.Stats;
29
72
  try {
30
- const stat = fs.lstatSync(socketPath);
31
- if (stat.isSymbolicLink()) {
32
- return { ok: false, status: "indeterminate", reason: `app-server socket is a symlink: ${socketPath}` };
33
- }
34
- if (!stat.isSocket()) {
35
- return { ok: false, status: "indeterminate", reason: `app-server path is not a socket: ${socketPath}` };
73
+ stat = fs.lstatSync(socketPath);
74
+ } catch (error) {
75
+ const code = (error as NodeJS.ErrnoException).code;
76
+ if (code === "ENOENT") {
77
+ return { ok: false, status: "dead", layout: "absent", reason: `app-server socket is absent: ${socketPath}` };
36
78
  }
37
- if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
79
+ return { ok: false, status: "indeterminate", reason: error instanceof Error ? error.message : String(error) };
80
+ }
81
+ if (stat.isSymbolicLink()) return checkCodexRendezvousAlias(socketPath, stat, euid, deps);
82
+ if (!stat.isSocket()) {
83
+ return { ok: false, status: "indeterminate", reason: `app-server path is not a socket: ${socketPath}` };
84
+ }
85
+ if (euid !== undefined && stat.uid !== euid) {
86
+ return {
87
+ ok: false,
88
+ status: "indeterminate",
89
+ reason: `app-server socket is owned by uid ${stat.uid}, not the effective uid ${euid}`,
90
+ };
91
+ }
92
+ return { ok: true, connectPath: socketPath };
93
+ }
94
+
95
+ function checkCodexRendezvousAlias(
96
+ socketPath: string,
97
+ link: fs.Stats,
98
+ euid: number | undefined,
99
+ deps: CodexSocketFileDeps,
100
+ ): CodexSocketFileCheck {
101
+ const refuse = (reason: string): CodexSocketFileCheck => ({ ok: false, status: "indeterminate", reason });
102
+ if (euid === undefined) {
103
+ return refuse(`app-server socket is a symlink and this platform has no effective uid to verify it: ${socketPath}`);
104
+ }
105
+ if (link.uid !== euid) {
106
+ return refuse(`app-server socket symlink is owned by uid ${link.uid}, not the effective uid ${euid}`);
107
+ }
108
+ let daemonRoot: string;
109
+ let expected: string;
110
+ let target: string;
111
+ try {
112
+ daemonRoot = deps.daemonRoot ?? path.join(fs.realpathSync("/tmp"), `codex-daemon-${euid}`);
113
+ const canonical = path.join(fs.realpathSync(path.dirname(socketPath)), path.basename(socketPath));
114
+ expected = path.join(daemonRoot, crypto.createHash("sha256").update(canonical).digest("hex"));
115
+ target = fs.readlinkSync(socketPath);
116
+ } catch (error) {
117
+ return refuse(
118
+ `could not verify the app-server socket symlink ${socketPath}: ${error instanceof Error ? error.message : String(error)}`,
119
+ );
120
+ }
121
+ if (target !== expected) {
122
+ return refuse(`app-server socket is a symlink that is not Codex's rendezvous alias: ${socketPath} -> ${target}`);
123
+ }
124
+ let directory: fs.Stats;
125
+ try {
126
+ directory = fs.lstatSync(daemonRoot);
127
+ } catch (error) {
128
+ return refuse(`Codex's socket directory cannot be read: ${error instanceof Error ? error.message : String(error)}`);
129
+ }
130
+ if (!directory.isDirectory() || directory.uid !== euid || (directory.mode & 0o777) !== 0o700) {
131
+ return refuse(
132
+ `Codex's socket directory ${daemonRoot} must be a real directory owned by uid ${euid} with mode 0700`,
133
+ );
134
+ }
135
+ let physical: fs.Stats;
136
+ try {
137
+ physical = fs.lstatSync(expected);
138
+ } catch (error) {
139
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
38
140
  return {
39
141
  ok: false,
40
- status: "indeterminate",
41
- reason: `app-server socket is owned by uid ${stat.uid}, not ${process.getuid()}`,
142
+ status: "dead",
143
+ layout: "dangling-alias",
144
+ reason: `app-server socket is a dangling rendezvous alias: ${socketPath} -> ${expected}`,
42
145
  };
43
146
  }
44
- return { ok: true };
45
- } catch (error) {
46
- const code = (error as NodeJS.ErrnoException).code;
47
- if (code === "ENOENT") return { ok: false, status: "dead", reason: `app-server socket is absent: ${socketPath}` };
48
- return { ok: false, status: "indeterminate", reason: error instanceof Error ? error.message : String(error) };
147
+ return refuse(error instanceof Error ? error.message : String(error));
49
148
  }
149
+ if (!physical.isSocket() || physical.uid !== euid || (physical.mode & 0o077) !== 0) {
150
+ return refuse(
151
+ `Codex's physical socket ${expected} must be a socket owned by uid ${euid} that no group or other user can open`,
152
+ );
153
+ }
154
+ return { ok: true, connectPath: expected };
50
155
  }
51
156
 
52
157
  export interface CodexRpcProtocol {
@@ -315,6 +420,7 @@ async function initialize(protocol: CodexRpcProtocol): Promise<void> {
315
420
 
316
421
  export interface CodexRoute {
317
422
  readonly backend: "codex";
423
+ /** The endpoint `checkCodexSocketFile` verified for this probe, which the send also uses. Not an address. */
318
424
  readonly socketPath: string;
319
425
  }
320
426
 
@@ -359,9 +465,14 @@ export async function probeCodexThread(
359
465
  nativeSessionId: string,
360
466
  deps: CodexAdapterDeps = {},
361
467
  ): Promise<CodexProbeResult> {
362
- const socketPath = resolveCodexDefaultSocketPath(deps.env ?? process.env);
363
- const socketCheck = (deps.checkSocket ?? checkCodexSocketFile)(socketPath);
468
+ const socketCheck = (deps.checkSocket ?? checkCodexSocketFile)(
469
+ resolveCodexDefaultSocketPath(deps.env ?? process.env),
470
+ );
364
471
  if (!socketCheck.ok) return { status: socketCheck.status, reason: socketCheck.reason };
472
+ // The probe, and the queue send its route carries, both use the endpoint just verified. The
473
+ // advertised path is not resolved again: on 0.162.1 it is an alias that could be retargeted
474
+ // between the check and a second transport.
475
+ const socketPath = socketCheck.connectPath;
365
476
  let protocol: CodexRpcProtocol;
366
477
  try {
367
478
  protocol = await (deps.openProtocol ?? realCodexProtocolOpener).open(socketPath);
@@ -3,9 +3,9 @@
3
3
  * under (#129 W, #130).
4
4
  *
5
5
  * The experimental durable app is source-only upstream: published `@earendil-works/pi-coding-agent`
6
- * ships no `dist/experimental`. Entwurf therefore ships the app itself as a CARRIER — the pinned
7
- * upstream files, type-stripped and relocated (pi/pi-durable/carrier/, emitted only by the
8
- * maintainer's scripts/emit-pi-durable-carrier.ts) — and declares the published SDK set it runs
6
+ * ships no `dist/experimental`. Entwurf therefore ships the app itself as a CARRIER — its owned
7
+ * source (pi/pi-durable/src, #141), type-stripped and relocated (pi/pi-durable/carrier/, emitted only
8
+ * by scripts/emit-pi-durable-carrier.ts) — and declares the published SDK set it runs
9
9
  * against as exact production dependencies. Nothing is provisioned beside the package: there is no
10
10
  * runtime directory, no source checkout and no model data to copy (it rides the published pi-ai).
11
11
  *
@@ -47,7 +47,7 @@ export type PiDurableRuntimeRejectReason =
47
47
  export interface PiDurablePackageLayout {
48
48
  readonly root: string;
49
49
  readonly overlayDir: string;
50
- /** The emitted upstream app files and their license. */
50
+ /** The app files emitted from the owned source, and their license. */
51
51
  readonly carrierDir: string;
52
52
  /** The `--import` resolver the launcher runs the bootstrap under. */
53
53
  readonly resolver: string;