@junghanacs/entwurf 0.35.0 → 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 (91) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +338 -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 +14 -11
  8. package/demo/README.md +1 -1
  9. package/docs/acp-backend-rail.md +29 -3
  10. package/docs/setup-clean-host.md +31 -23
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +46 -2
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/model-ids.js +67 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +9 -20
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +4 -3
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +11 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +27 -1
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +2 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/codex-ws-client.js +99 -18
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +3 -3
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +13 -4
  22. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +178 -25
  23. package/mcp/entwurf-bridge/dist/scripts/codex-socket-classify.js +44 -0
  24. package/mcp/entwurf-bridge/tsconfig.build.json +5 -0
  25. package/package.json +20 -15
  26. package/pi/pi-durable/bootstrap.mjs +12 -8
  27. package/pi/pi-durable/carrier/runtime.js +52 -2
  28. package/pi/pi-durable/carrier/subagent.js +6 -1
  29. package/pi/pi-durable/carrier/tui.js +31 -4
  30. package/pi/pi-durable/carrier-relocation.mjs +2 -2
  31. package/pi/pi-durable/overlay/upstream-pin.json +79 -36
  32. package/pi-extensions/lib/acp/backend-adapter.ts +52 -2
  33. package/pi-extensions/lib/acp/model-ids.js +77 -0
  34. package/pi-extensions/lib/acp/models.ts +15 -22
  35. package/pi-extensions/lib/codex-fresh-preflight.ts +4 -3
  36. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +12 -0
  37. package/pi-extensions/lib/fresh-call-dispatch.ts +25 -1
  38. package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
  39. package/pi-extensions/lib/mux-fresh-call.ts +3 -0
  40. package/pi-extensions/lib/native-push/codex-ws-client.ts +129 -18
  41. package/pi-extensions/lib/pi-durable-runtime.ts +4 -4
  42. package/pi-extensions/lib/resume-launch-identity.ts +12 -4
  43. package/pi-extensions/meta-bridge-pi-durable.ts +230 -34
  44. package/run.sh +193 -58
  45. package/scripts/check-acp-backend-preflight.ts +10 -2
  46. package/scripts/check-acp-cortex.ts +7 -6
  47. package/scripts/check-acp-prompt-lifecycle.ts +2 -2
  48. package/scripts/check-acp-provider-surface.ts +1 -1
  49. package/scripts/check-acp-session-reuse.ts +5 -5
  50. package/scripts/check-acp-stop-reason.ts +2 -2
  51. package/scripts/check-acp-stream-hooks.ts +2 -2
  52. package/scripts/check-acp-usage-accounting.ts +2 -2
  53. package/scripts/check-codex-app-server-launch.ts +55 -12
  54. package/scripts/check-codex-native-push.ts +231 -12
  55. package/scripts/check-fresh-call-dispatch.ts +47 -4
  56. package/scripts/check-gate-qualification.ts +8 -6
  57. package/scripts/check-probe-ordering.ts +13 -6
  58. package/scripts/check-resume-launch-identity.ts +38 -0
  59. package/scripts/codex-app-server-launch.sh +54 -29
  60. package/scripts/codex-socket-classify.ts +46 -0
  61. package/scripts/emit-pi-durable-carrier.ts +159 -168
  62. package/scripts/mutants/acp-cortex.json +17 -8
  63. package/scripts/mutants/acp-set-model-readback.json +28 -0
  64. package/scripts/mutants/codex-app-server-launch.json +39 -12
  65. package/scripts/mutants/codex-native.json +15 -0
  66. package/scripts/mutants/control-send-receipt.json +1 -1
  67. package/scripts/mutants/fresh-call-dispatch.json +11 -0
  68. package/scripts/mutants/herdr-dev-checkout.json +70 -0
  69. package/scripts/mutants/pack-install.json +3 -3
  70. package/scripts/mutants/pi-durable.json +282 -16
  71. package/scripts/mutants/pi-floor.json +5 -5
  72. package/scripts/mutants/resume-launch-identity.json +15 -0
  73. package/scripts/mutants/v2-visible-resume.json +11 -0
  74. package/scripts/pi-durable-contact-host.mjs +1 -1
  75. package/scripts/resolve-acp-bridge.ts +1 -1
  76. package/scripts/smoke-acp-bundled-mcp-live.ts +1 -1
  77. package/scripts/smoke-acp-carrier-augment-live.ts +1 -1
  78. package/scripts/smoke-acp-long-turn-live.ts +1 -1
  79. package/scripts/smoke-acp-mcp-live.ts +1 -1
  80. package/scripts/smoke-acp-memory-containment-live.ts +1 -1
  81. package/scripts/smoke-acp-ordering-probe-live.ts +1 -1
  82. package/scripts/smoke-acp-overlay-live.ts +1 -1
  83. package/scripts/smoke-acp-provider-live.ts +1 -1
  84. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  85. package/scripts/smoke-acp-session-reuse-live.ts +1 -1
  86. package/scripts/smoke-acp-skill-live.ts +1 -1
  87. package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
  88. package/scripts/smoke-acp-v2-send-live.ts +1 -1
  89. package/scripts/smoke-entwurf-chain-live.ts +1 -1
  90. package/scripts/smoke-mux-lifecycle-live.ts +1 -1
  91. package/pi/pi-durable/overlay/runtime-contacts.patch +0 -283
@@ -0,0 +1,77 @@
1
+ // ACP plugin — the curated model IDs, and nothing else (#128).
2
+ //
3
+ // This leaf imports NOTHING. `models.ts` builds the registry rows from the pi-ai
4
+ // catalog; the launch seams (`fresh-call-dispatch.ts`, `resume-launch-identity.ts`)
5
+ // only need to know which IDs exist, and they ride the pi-free bridge closure, so
6
+ // they read the IDs here rather than load a vendor catalog to learn them.
7
+ //
8
+ // WHY `.js`: the same reason as `protocol.js`. Both the emit-capable root program
9
+ // (allowJs) and Node --experimental-strip-types resolve an explicit `.js` import
10
+ // literally to this file, so `models.ts` (root fence, `.js` specifiers) and the
11
+ // strip-types gates that load `models.ts` directly reach one SSOT. A `.ts` leaf
12
+ // would need a `.ts` specifier the root config cannot allow.
13
+ //
14
+ // WHY THE LAUNCH SEAMS ASK AT ALL. Pi's `--model` resolver falls back to a
15
+ // case-insensitive substring match among the provider's rows and prefers the
16
+ // lexically later id. Under one `entwurf` provider a retired `claude-sonnet-5`
17
+ // is a substring of `cortex-claude-sonnet-5`, so a launch Entwurf builds would
18
+ // silently open on the Snowflake Cortex rail instead of the Claude subscription.
19
+ // An exact match against this list is the only admission; nothing is aliased,
20
+ // substituted or rewritten.
21
+
22
+ /** The single ACP provider id under which every curated row is registered. */
23
+ export const PROVIDER_ID = "entwurf";
24
+
25
+ // The curated Claude ids. Adding one here is a commitment to verify it across
26
+ // both axes (protocol smoke + agent interview) — do not extend casually.
27
+ export const SUPPORTED_ANTHROPIC_MODEL_IDS = /** @type {const} */ ([
28
+ "claude-sonnet-5-5",
29
+ "claude-opus-5-5",
30
+ "claude-fable-5-1",
31
+ ]);
32
+
33
+ /** The anchor model whose absence is a hard registry regression, not a soft skip. */
34
+ export const CURATED_ANCHOR_MODEL_ID = "claude-opus-5-5";
35
+
36
+ // Cortex ids carry the reserved `cortex-` prefix; the rest of each id is the
37
+ // Snowflake-native model name, which is why Cortex still says `claude-opus-5`.
38
+ export const CORTEX_MODEL_PREFIX = "cortex-";
39
+ export const SUPPORTED_CORTEX_MODEL_IDS = /** @type {const} */ ([
40
+ "cortex-auto",
41
+ "cortex-claude-opus-5",
42
+ "cortex-claude-sonnet-5",
43
+ "cortex-openai-gpt-5.4",
44
+ ]);
45
+
46
+ /** @type {ReadonlySet<string>} */
47
+ const CURATED_ACP_MODEL_IDS = new Set([...SUPPORTED_ANTHROPIC_MODEL_IDS, ...SUPPORTED_CORTEX_MODEL_IDS]);
48
+
49
+ /**
50
+ * Exact membership only — no trim, no case folding, no prefix.
51
+ * @param {string} model
52
+ * @returns {boolean}
53
+ */
54
+ export function isCuratedAcpModelId(model) {
55
+ return CURATED_ACP_MODEL_IDS.has(model);
56
+ }
57
+
58
+ /** The named refusal both launch seams answer with. */
59
+ export const ACP_MODEL_NOT_CURATED = /** @type {const} */ ("acp-model-not-curated");
60
+
61
+ export const ACP_MODEL_NOT_CURATED_HINT =
62
+ `an ${PROVIDER_ID} model must be one exact curated ACP id (${[...CURATED_ACP_MODEL_IDS].join(", ")}); ` +
63
+ "Pi would otherwise pick another row by substring — a retired claude-sonnet-5 lands on cortex-claude-sonnet-5 — " +
64
+ "so nothing was opened, and nothing is aliased or rewritten";
65
+
66
+ /** Carries `reason` so callers match a FIELD, never the wording. */
67
+ export class AcpModelNotCuratedError extends Error {
68
+ /** @param {string} model */
69
+ constructor(model) {
70
+ super(`${ACP_MODEL_NOT_CURATED}: ${JSON.stringify(model)} is not an exact curated ${PROVIDER_ID} model id`);
71
+ this.name = "AcpModelNotCuratedError";
72
+ /** @readonly */
73
+ this.reason = ACP_MODEL_NOT_CURATED;
74
+ /** @readonly */
75
+ this.model = model;
76
+ }
77
+ }
@@ -33,9 +33,18 @@
33
33
  // re-export of `getBuiltinModels`. When pi removes compat we migrate to whatever
34
34
  // the loader then exposes.
35
35
  import { getModels } from "@earendil-works/pi-ai/compat";
36
+ import { CURATED_ANCHOR_MODEL_ID, SUPPORTED_ANTHROPIC_MODEL_IDS } from "./model-ids.js";
36
37
 
37
- /** Provider id — current pre-rename surface; S1 renames this load-bearing id to `entwurf`. */
38
- export const PROVIDER_ID = "entwurf";
38
+ // The provider id and the curated ID lists live in the dependency-free
39
+ // `model-ids.js` leaf, shared with the launch seams (#128); this file is the only
40
+ // one that loads the upstream catalog to turn them into registry rows.
41
+ export {
42
+ CORTEX_MODEL_PREFIX,
43
+ CURATED_ANCHOR_MODEL_ID,
44
+ PROVIDER_ID,
45
+ SUPPORTED_ANTHROPIC_MODEL_IDS,
46
+ SUPPORTED_CORTEX_MODEL_IDS,
47
+ } from "./model-ids.js";
39
48
 
40
49
  // #26 auth-boundary sentinel. `pi.registerProvider` requires an apiKey when a
41
50
  // provider defines custom models, but the ACP plugin consumes NO key: backend
@@ -49,17 +58,8 @@ export const PROVIDER_ID = "entwurf";
49
58
  // check-auth-boundary gate pins this.
50
59
  export const ENTWURF_ACP_NO_AUTH_SENTINEL = "entwurf-no-auth";
51
60
 
52
- // The curated Claude ids. Adding one here is a commitment to verify it across
53
- // both axes (protocol smoke + agent interview) — do not extend casually.
54
- // Exported so the claude backend adapter (backend-adapter.ts) can answer
55
- // `routeModel` without re-deriving the set from curatedClaudeModels().
56
- export const SUPPORTED_ANTHROPIC_MODEL_IDS = ["claude-sonnet-5", "claude-opus-5", "claude-fable-5-1"] as const;
57
-
58
- /** The anchor model whose absence is a hard registry regression, not a soft skip. */
59
- export const CURATED_ANCHOR_MODEL_ID = "claude-opus-5";
60
-
61
- // Anthropic's registry reports 1M for Sonnet 5, Opus 5 and Fable 5.1, and the
62
- // entwurf surface exposes the full 1M for all three. Sonnet 5's 1M window is the
61
+ // Anthropic's registry reports 1M for Sonnet 5.5, Opus 5.5 and Fable 5.1, and the
62
+ // entwurf surface exposes the full 1M for all three. Sonnet 5's 1M window was the
63
63
  // whole point of the 0.12.3 bump — it is the compact-free long-context floor the
64
64
  // earlier 200K Sonnet cap could not provide. We still clamp to a 1M ceiling so a
65
65
  // future registry value can't silently inflate the surface past what we verify.
@@ -92,7 +92,7 @@ function claudeContextWindow(model: { id: string; contextWindow: number }): numb
92
92
  // carries the reserved `cortex-` prefix: the prefix is the SINGLE routing
93
93
  // authority (backend-adapter.ts `routeModel`) and keeps the ids off the Claude
94
94
  // native ids Cortex routes to (`cortex-claude-sonnet-5` vs the unprefixed
95
- // `claude-sonnet-5` the claude adapter owns). `routeModel` strips the prefix to
95
+ // `claude-sonnet-5-5` the claude adapter owns). `routeModel` strips the prefix to
96
96
  // recover the native id, which is enforced per-turn via
97
97
  // session/set_config_option — never a launch `-m` pin (CP0-M).
98
98
  //
@@ -101,14 +101,7 @@ function claudeContextWindow(model: { id: string; contextWindow: number }): numb
101
101
  // the running cortex no longer serves fails LOUD at set-model, before the
102
102
  // prompt (measured: `Unsupported model: …` names the live set). Adding an id
103
103
  // here is the same verify-both-axes commitment as the Claude set — do not
104
- // extend casually.
105
- export const CORTEX_MODEL_PREFIX = "cortex-";
106
- export const SUPPORTED_CORTEX_MODEL_IDS = [
107
- "cortex-auto",
108
- "cortex-claude-opus-5",
109
- "cortex-claude-sonnet-5",
110
- "cortex-openai-gpt-5.4",
111
- ] as const;
104
+ // extend casually. The prefix and the four ids are declared in model-ids.js.
112
105
 
113
106
  // Hand-set conservative context window. Cortex reports the LIVE window via its
114
107
  // own ACP session config, not this curated metadata — this is a floor for the
@@ -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
  );
@@ -47,6 +47,7 @@
47
47
  * outcome for this module to claim.
48
48
  */
49
49
 
50
+ import { ACP_MODEL_NOT_CURATED_HINT, AcpModelNotCuratedError } from "./acp/model-ids.js";
50
51
  import { buildResumePiArgs } from "./entwurf-resume-args.ts";
51
52
  import {
52
53
  type AcquireLockResult,
@@ -93,6 +94,7 @@ export const RESUME_OBSERVE_INTERVAL_MS = 500;
93
94
  export type VisibleResumeRejectReason =
94
95
  | "target-invalid"
95
96
  | "target-not-pi"
97
+ | "acp-model-not-curated"
96
98
  | "target-locked"
97
99
  | "target-live"
98
100
  | "target-indeterminate"
@@ -227,6 +229,15 @@ export async function visibleResume(targetGardenId: string, deps: VisibleResumeD
227
229
  try {
228
230
  identity = deps.resolveIdentity(targetGardenId);
229
231
  } catch (err) {
232
+ // #128: the recorded model is not an exact curated ACP id, so Pi would pick another row
233
+ // by substring. Same shape as `target-not-pi`: a named refusal matched on its FIELD.
234
+ if (err instanceof AcpModelNotCuratedError) {
235
+ return {
236
+ ok: false,
237
+ reason: err.reason,
238
+ detail: `${targetGardenId} would launch model ${JSON.stringify(err.model)} under the entwurf provider (the resolved launch pair, which may differ from the recorded provider)`,
239
+ };
240
+ }
230
241
  if (err instanceof ResumeBackendUnsupportedError) {
231
242
  return {
232
243
  ok: false,
@@ -321,6 +332,7 @@ export async function observeSameGidSocket(
321
332
  const REJECT_HINT: Record<VisibleResumeRejectReason, string> = {
322
333
  "target-invalid": "no citizen has that id; discover targets with entwurf_peers",
323
334
  "target-not-pi": "that citizen's backend has no same-id resume — reach it with entwurf_v2, or open a fresh sibling",
335
+ "acp-model-not-curated": ACP_MODEL_NOT_CURATED_HINT,
324
336
  "target-locked": "another resume or dispatch holds this citizen's lock right now",
325
337
  "target-live": "that citizen is already running — this verb only reopens dormant ones",
326
338
  "target-indeterminate": "the socket probe could not prove the citizen is dormant, so nothing was started",
@@ -25,8 +25,9 @@
25
25
  */
26
26
 
27
27
  import { spawn as spawnChildProcess } from "node:child_process";
28
+ import { ACP_MODEL_NOT_CURATED, isCuratedAcpModelId, PROVIDER_ID } from "./acp/model-ids.js";
28
29
  import { codexCallerFreshPreflight, codexFreshPreflight } from "./codex-fresh-preflight.ts";
29
- import { mintNonce } from "./fresh-call-composition.ts";
30
+ import { isSafeFreshCallModel, mintNonce } from "./fresh-call-composition.ts";
30
31
  import {
31
32
  createHerdrRunner,
32
33
  type HerdrFreshCallResult,
@@ -67,6 +68,23 @@ export type DispatchedFreshCall =
67
68
  | { readonly rail: "tmux"; readonly result: FreshCallResult }
68
69
  | { readonly rail: "herdr"; readonly result: HerdrFreshCallResult };
69
70
 
71
+ /**
72
+ * #128: a pi sibling asked for under the `entwurf` provider must name an EXACT curated ACP id. Pi
73
+ * resolves `--model` by substring among that provider's rows, so a retired `entwurf/claude-sonnet-5`
74
+ * would open on `cortex-claude-sonnet-5` — another vendor and billing rail — with a receipt that
75
+ * still names the requested id. Asked here, at the one root both rails share, so neither rail can
76
+ * forget it. It reads the model the way the rails will (trimmed), and only a model that already
77
+ * passes their grammar: a malformed one keeps its own `model-invalid` answer from the rail.
78
+ */
79
+ export function isUncuratedEntwurfPiModel(backend: string, model: string): boolean {
80
+ if (backend !== "pi") return false;
81
+ const trimmed = model.trim();
82
+ if (!isSafeFreshCallModel(trimmed)) return false;
83
+ const slash = trimmed.indexOf("/");
84
+ if (slash < 0 || trimmed.slice(0, slash) !== PROVIDER_ID) return false;
85
+ return !isCuratedAcpModelId(trimmed.slice(slash + 1));
86
+ }
87
+
70
88
  /**
71
89
  * Open a sibling on whichever rail this process is actually standing in.
72
90
  *
@@ -88,6 +106,12 @@ export async function dispatchFreshCall(
88
106
  spawn?: SpawnFn,
89
107
  nonce: string = mintNonce(),
90
108
  ): Promise<DispatchedFreshCall> {
109
+ // Before either rail is asked anything: both would open a pane on the wrong row.
110
+ if (isUncuratedEntwurfPiModel(request.backend, request.model)) {
111
+ return selectFreshCallRail(env) === "herdr"
112
+ ? { rail: "herdr", result: { ok: false, reason: ACP_MODEL_NOT_CURATED } }
113
+ : { rail: "tmux", result: { ok: false, reason: ACP_MODEL_NOT_CURATED } };
114
+ }
91
115
  if (selectFreshCallRail(env) === "herdr") {
92
116
  const context = resolveHerdrContext(env);
93
117
  if (!context.ok) {
@@ -119,6 +119,7 @@ export type HerdrFreshCallRejectReason =
119
119
  // The caller-facing input contract, shared with the tmux rail so a mistyped model or an
120
120
  // oversized task is answered with the SAME word on either placement owner.
121
121
  | FreshCallInputRejectReason
122
+ | "acp-model-not-curated"
122
123
  | "herdr-context-missing"
123
124
  | "herdr-caller-pane-missing"
124
125
  /** herdr would not tell us where the CALLER is sitting. Three separate names because the
@@ -1102,6 +1103,10 @@ const HERDR_REJECT_HINT: Record<HerdrFreshCallRejectReason | HerdrLaunchFailureR
1102
1103
  "this surface has no record-backed identity, so a sibling would have nowhere to call home",
1103
1104
  "model-empty": "pass the model you want the sibling opened with — it is never inherited from this process",
1104
1105
  "model-invalid": "the model is not in the accepted grammar (no whitespace, no leading dash)",
1106
+ // #128: decided at the dispatch root (`fresh-call-dispatch.ts`), worded here because this rail's
1107
+ // import fence admits no ACP module. The curated list itself lives in `acp/model-ids.js`.
1108
+ "acp-model-not-curated":
1109
+ "an entwurf model must be one exact curated ACP id — Pi would otherwise pick another row by substring (a retired claude-sonnet-5 lands on cortex-claude-sonnet-5), so nothing was opened and nothing was aliased",
1105
1110
  "task-empty": "a sibling opened with nothing to do is a window nobody asked for",
1106
1111
  "task-too-long": `the task is over the ${TASK_MAX_CHARS}-character interface bound this verb shares with entwurf_v2`,
1107
1112
  "herdr-context-missing":
@@ -86,6 +86,7 @@
86
86
  * there is no "session created" field because nothing here creates one.
87
87
  */
88
88
 
89
+ import { ACP_MODEL_NOT_CURATED_HINT } from "./acp/model-ids.js";
89
90
  import { callbackEnvAssignments } from "./callback-env.ts";
90
91
  import { classifyTmuxCwd, type TmuxCwdRejectReason } from "./classify-tmux-cwd.ts";
91
92
  import {
@@ -265,6 +266,7 @@ export type FreshCallRejectReason =
265
266
  | CodexPreflightRejectReason
266
267
  | CodexCallerPreflightRejectReason
267
268
  | CodexCallerSeatRejectReason
269
+ | "acp-model-not-curated"
268
270
  | "caller-identity-unavailable"
269
271
  | "model-empty"
270
272
  | "model-invalid"
@@ -678,6 +680,7 @@ const REJECT_HINT: Record<FreshCallRejectReason, string> = {
678
680
  "no session with that exact name answers on this agent's tmux server (or that server stopped answering) — nothing was created, so open the session yourself and call again",
679
681
  "model-empty": "model is empty after trimming; fresh calls require an explicit model",
680
682
  "model-invalid": `model must be one ${MODEL_MAX_CHARS}-character argv-safe id/alias without whitespace or tmux syntax`,
683
+ "acp-model-not-curated": ACP_MODEL_NOT_CURATED_HINT,
681
684
  "task-empty": "task is empty after trimming",
682
685
  "task-too-long": `task exceeds ${TASK_MAX_CHARS} characters`,
683
686
  "runtime-unresolved": "the backend's runtime is not installed on PATH",
@@ -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;
@@ -20,12 +20,13 @@
20
20
 
21
21
  import { existsSync } from "node:fs";
22
22
  import path from "node:path";
23
+ import { AcpModelNotCuratedError, isCuratedAcpModelId, PROVIDER_ID } from "./acp/model-ids.js";
23
24
  import { getEntwurfExplicitExtensions, readSessionIdentity } from "./entwurf-core.ts";
24
25
  import { readAddressableMetaIdentity } from "./meta-session.ts";
25
26
 
26
27
  /**
27
- * The ONE expected refusal on this leaf: the target is a citizen of a backend that has no
28
- * same-id resume at all.
28
+ * One of the TWO expected refusals on this leaf: the target is a citizen of a backend that has no
29
+ * same-id resume at all. The other is `AcpModelNotCuratedError` (#128, `acp/model-ids.js`).
29
30
  *
30
31
  * Everything else this function throws is a defect in the record, the transcript or the
31
32
  * environment — a stale path, a foreign session file, an unrecorded model, an unresolvable
@@ -152,11 +153,18 @@ export function resolveResumeLaunchIdentity(gardenId: string): LaunchIdentity {
152
153
  if (!identity.cwd) {
153
154
  throw new Error(`resume-launch-identity: ${gardenId} header has no cwd (the cold-resume authority, #9).`);
154
155
  }
156
+ // #128: the pair checked is the one the launch will EMIT, not the recorded provider alone —
157
+ // `getEntwurfExplicitExtensions` also routes a bare Claude model id to `entwurf`. Under that
158
+ // provider Pi resolves `--model` by substring, so a retired `claude-sonnet-5` would reopen on
159
+ // `cortex-claude-sonnet-5`. The record and transcript stay as they are; the resume is refused.
160
+ const provider = explicitExtensions.provider ?? identity.provider;
161
+ const model = explicitExtensions.modelOverride ?? resumeModel;
162
+ if (provider === PROVIDER_ID && !isCuratedAcpModelId(model)) throw new AcpModelNotCuratedError(model);
155
163
  return {
156
164
  sessionFile,
157
165
  cwd: identity.cwd,
158
166
  explicitExtensionArgs: explicitExtensions.args,
159
- provider: explicitExtensions.provider ?? identity.provider,
160
- model: explicitExtensions.modelOverride ?? resumeModel,
167
+ provider,
168
+ model,
161
169
  };
162
170
  }