@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
@@ -53,11 +53,11 @@ import {
53
53
  import { tmpdir } from "node:os";
54
54
  import { join, resolve } from "node:path";
55
55
  import { pathToFileURL } from "node:url";
56
+ import { getModels } from "@earendil-works/pi-ai/compat";
56
57
  import { buildPiContextAugment } from "../pi-extensions/lib/acp/augment.ts";
57
58
  import type { AcpMcpServer } from "../pi-extensions/lib/acp/config.ts";
58
59
  import {
59
60
  CORTEX_MODEL_PREFIX,
60
- curatedClaudeModels,
61
61
  curatedCortexModels,
62
62
  SUPPORTED_CORTEX_MODEL_IDS,
63
63
  } from "../pi-extensions/lib/acp/models.ts";
@@ -87,12 +87,13 @@ const cortexRowIds = cortexRows.map((m) => m.id);
87
87
  assert.deepEqual(cortexRowIds, [...SUPPORTED_CORTEX_MODEL_IDS], "curated cortex rows must match the supported id set");
88
88
  // The two Claude rows ride their OWN registry bases (a fabricated row would
89
89
  // drift from the pi-ai catalog); auto/gpt ride the sonnet base as a floor.
90
- const claudeRows = curatedClaudeModels();
90
+ // Cortex retains its own native 5 ids even when Claude's curated set moves to 5.5.
91
+ const claudeRows = getModels("anthropic");
91
92
  const sonnetBase = claudeRows.find((m) => m.id === "claude-sonnet-5");
92
93
  const opusBase = claudeRows.find((m) => m.id === "claude-opus-5");
93
94
  // Plain throw (not assert.ok): an asserts-function premise makes tsc report the
94
95
  // downstream `base` const as circularly-typed (TS7022).
95
- if (!sonnetBase || !opusBase) throw new Error("test premise: sonnet-5/opus-5 present in curated claude surface");
96
+ if (!sonnetBase || !opusBase) throw new Error("test premise: sonnet-5/opus-5 present in pi-ai registry");
96
97
  for (const row of cortexRows) {
97
98
  // Explicit annotation: node:assert's asserts-typed helpers otherwise make tsc
98
99
  // flag this flow-dependent const as circularly typed (TS7022).
@@ -480,7 +481,7 @@ try {
480
481
  // throwing IS the anti-collision proof; we also assert the rows are present.
481
482
  const registered = mod.allCuratedModels().map((m) => m.id);
482
483
  for (const want of [
483
- "claude-sonnet-5",
484
+ "claude-sonnet-5-5",
484
485
  "cortex-auto",
485
486
  "cortex-claude-opus-5",
486
487
  "cortex-claude-sonnet-5",
@@ -494,12 +495,12 @@ try {
494
495
  assert.equal(routedSonnet.adapter.backend, "cortex", "cortex-claude-sonnet-5 must route to the cortex adapter");
495
496
  assert.equal(routedSonnet.nativeModelId, "claude-sonnet-5", "prefix strip must recover the native model id");
496
497
  assert.equal(
497
- mod.resolveAcpBackendAdapter("claude-sonnet-5").adapter.backend,
498
+ mod.resolveAcpBackendAdapter("claude-sonnet-5-5").adapter.backend,
498
499
  "claude",
499
500
  "the unprefixed claude id must still route to claude (prefix keeps the namespaces apart)",
500
501
  );
501
502
  assert.equal(
502
- mod.cortexAdapter.routeModel("claude-sonnet-5"),
503
+ mod.cortexAdapter.routeModel("claude-sonnet-5-5"),
503
504
  undefined,
504
505
  "cortex must NOT claim the unprefixed claude id",
505
506
  );
@@ -40,7 +40,7 @@ import type { Api, AssistantMessageEvent, Message, Model, TranscriptContext } fr
40
40
  import { normalizeContext } from "@earendil-works/pi-ai";
41
41
  import { isRetryableAssistantError } from "@earendil-works/pi-ai/compat";
42
42
 
43
- const sonnet = { id: "claude-sonnet-5" } as unknown as Model<Api>;
43
+ const sonnet = { id: "claude-sonnet-5-5" } as unknown as Model<Api>;
44
44
 
45
45
  type Stream = AsyncIterable<AssistantMessageEvent>;
46
46
 
@@ -151,7 +151,7 @@ function makeHarness(recordDir: string) {
151
151
  const makeConnection = (handlers: any) => ({
152
152
  initialize: async () => ({ agentCapabilities: {} }),
153
153
  newSession: async () => ({ sessionId: "ACP-1" }),
154
- setSessionConfigOption: async () => ({}),
154
+ setSessionConfigOption: async (p: any) => ({ configOptions: [{ id: "model", currentValue: p.value }] }),
155
155
  prompt: ({ sessionId }: any) => {
156
156
  promptCount++;
157
157
  notifier = (update) => handlers.sessionUpdate({ update, sessionId });
@@ -123,7 +123,7 @@ try {
123
123
  assert.equal(cap.cfg.apiKey, ENTWURF_ACP_NO_AUTH_SENTINEL, "entry apiKey is not the no-auth sentinel");
124
124
  assert.equal(cap.cfg.api, "entwurf", "entry api field drifted");
125
125
  const capIds = (cap.cfg.models ?? []).map((m) => m.id);
126
- for (const want of ["claude-sonnet-5", CURATED_ANCHOR_MODEL_ID]) {
126
+ for (const want of ["claude-sonnet-5-5", CURATED_ANCHOR_MODEL_ID]) {
127
127
  assert.ok(capIds.includes(want), `entry model surface missing ${want} (got: ${capIds.join(", ") || "none"})`);
128
128
  }
129
129
 
@@ -51,8 +51,8 @@ function tctx(messages: Context["messages"]): TranscriptContext {
51
51
  return normalizeContext({ tools: PI_DECLARED_TOOLS, messages });
52
52
  }
53
53
 
54
- const sonnet = { id: "claude-sonnet-5" } as unknown as Model<Api>;
55
- const opus = { id: "claude-opus-5" } as unknown as Model<Api>;
54
+ const sonnet = { id: "claude-sonnet-5-5" } as unknown as Model<Api>;
55
+ const opus = { id: "claude-opus-5-5" } as unknown as Model<Api>;
56
56
 
57
57
  type Stream = AsyncIterable<AssistantMessageEvent> & {
58
58
  result: () => Promise<{ stopReason: string; errorMessage?: string }>;
@@ -154,7 +154,7 @@ function makeHarness(recordDir: string, config: any = DEFAULT_RESOLVED_CONFIG, e
154
154
  newSessionSeq++;
155
155
  return { sessionId: `ACP-${newSessionSeq}` };
156
156
  },
157
- setSessionConfigOption: async () => ({}),
157
+ setSessionConfigOption: async (p: any) => ({ configOptions: [{ id: "model", currentValue: p.value }] }),
158
158
  prompt: async ({ sessionId, prompt }: any) => {
159
159
  promptCalls.push({ sessionId, text: prompt.map((b: { text: string }) => b.text).join("\n") });
160
160
  if (block) await block;
@@ -505,7 +505,7 @@ try {
505
505
  );
506
506
  const d1 = deltaText(t1);
507
507
  const iPrep = d1.indexOf("[acp: preparing claude session]");
508
- const iReady = d1.indexOf("[acp: session ready model=claude-sonnet-5]");
508
+ const iReady = d1.indexOf("[acp: session ready model=claude-sonnet-5-5]");
509
509
  const iSent = d1.indexOf("[acp: sending prompt]");
510
510
  assert.ok(
511
511
  iPrep >= 0 && iReady > iPrep && iSent > iReady,
@@ -533,7 +533,7 @@ try {
533
533
  const ctxWithNotice = tctx([
534
534
  { role: "user", content: "real user line", timestamp: 0 },
535
535
  mkAssistant([
536
- { type: "text", text: "\n[acp: session ready model=claude-sonnet-5]\n", textSignature: MARKER },
536
+ { type: "text", text: "\n[acp: session ready model=claude-sonnet-5-5]\n", textSignature: MARKER },
537
537
  { type: "text", text: "real assistant line" },
538
538
  ]),
539
539
  ]);
@@ -55,7 +55,7 @@ import { pathToFileURL } from "node:url";
55
55
  import type { Api, AssistantMessageEvent, Model } from "@earendil-works/pi-ai";
56
56
  import { normalizeContext } from "@earendil-works/pi-ai";
57
57
 
58
- const sonnet = { id: "claude-sonnet-5" } as unknown as Model<Api>;
58
+ const sonnet = { id: "claude-sonnet-5-5" } as unknown as Model<Api>;
59
59
 
60
60
  type Stream = AsyncIterable<AssistantMessageEvent> & {
61
61
  result: () => Promise<{ stopReason: string; errorMessage?: string }>;
@@ -124,7 +124,7 @@ function makeHarness(recordDir: string, wireReason: string | null) {
124
124
  createConnection: (_child: any, handlers: any) => ({
125
125
  initialize: async () => ({ agentCapabilities: {} }),
126
126
  newSession: async () => ({ sessionId: "ACP-1" }),
127
- setSessionConfigOption: async () => ({}),
127
+ setSessionConfigOption: async (p: any) => ({ configOptions: [{ id: "model", currentValue: p.value }] }),
128
128
  prompt: async ({ sessionId }: any) => {
129
129
  await handlers.sessionUpdate({
130
130
  update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "PARTIAL" } },
@@ -35,7 +35,7 @@ import { pathToFileURL } from "node:url";
35
35
  import type { Api, AssistantMessageEvent, Message, Model, TranscriptContext } from "@earendil-works/pi-ai";
36
36
  import { normalizeContext } from "@earendil-works/pi-ai";
37
37
 
38
- const sonnet = { id: "claude-sonnet-5" } as unknown as Model<Api>;
38
+ const sonnet = { id: "claude-sonnet-5-5" } as unknown as Model<Api>;
39
39
 
40
40
  type Stream = AsyncIterable<AssistantMessageEvent>;
41
41
 
@@ -93,7 +93,7 @@ function makeHarness(recordDir: string) {
93
93
  const makeConnection = (_handlers: any) => ({
94
94
  initialize: async () => ({ agentCapabilities: {} }),
95
95
  newSession: async () => ({ sessionId: "ACP-1" }),
96
- setSessionConfigOption: async () => ({}),
96
+ setSessionConfigOption: async (p: any) => ({ configOptions: [{ id: "model", currentValue: p.value }] }),
97
97
  prompt: async (params: any) => {
98
98
  // Deep-cloned: the capture must be the bytes that crossed the seam, not a
99
99
  // live reference a later mutation could rewrite.
@@ -61,7 +61,7 @@ import type {
61
61
  import { isContextOverflow, normalizeContext } from "@earendil-works/pi-ai";
62
62
  import { calculateContextTokens } from "@earendil-works/pi-coding-agent";
63
63
 
64
- const claude = { id: "claude-sonnet-5" } as unknown as Model<Api>;
64
+ const claude = { id: "claude-sonnet-5-5" } as unknown as Model<Api>;
65
65
  const cortex = { id: "cortex-auto" } as unknown as Model<Api>;
66
66
 
67
67
  type Stream = AsyncIterable<AssistantMessageEvent>;
@@ -160,7 +160,7 @@ function makeHarness(recordDir: string, scripts: TurnScript[]) {
160
160
  newSessionCalls++;
161
161
  return { sessionId: "ACP-1" };
162
162
  },
163
- setSessionConfigOption: async () => ({}),
163
+ setSessionConfigOption: async (p: any) => ({ configOptions: [{ id: "model", currentValue: p.value }] }),
164
164
  prompt: async ({ sessionId }: any) => {
165
165
  const script = scripts[turnIndex++] ?? {};
166
166
  const update: Record<string, unknown> = { sessionUpdate: "usage_update" };
@@ -322,25 +322,35 @@ exit "\${FAKE_CODEX_EXIT:-0}"
322
322
  );
323
323
  }
324
324
  {
325
+ // The classifier's INDETERMINATE domain, as one claim: two unidentifiable paths, both
326
+ // reaching the launcher through the one `indeterminate)` arm. A path that exists and is
327
+ // not a socket must read indeterminate, not stale, so the launcher refuses instead of
328
+ // clobbering it. A symlink that is NOT Codex's exact rendezvous alias must be refused with
329
+ // the product classifier's own reason, which is what pins the WIRING: a launcher that grew
330
+ // its own symlink rule again would refuse there too, but not in those words. Both runs are
331
+ // made before the one assertion, so a regression on that shared arm fails HERE, at this
332
+ // claim, and not at an earlier unnamed cell. The exact alias's accept path is certified on
333
+ // `checkCodexSocketFile` (check-codex-native-push), not here: this gate has no seam to put
334
+ // Codex's socket directory in a sandbox.
325
335
  const fileHome = path.join(root, "file-home");
326
336
  const fileSock = resolveCodexDefaultSocketPath({ CODEX_HOME: fileHome });
327
337
  mkdirSync(path.dirname(fileSock), { recursive: true });
328
338
  writeFileSync(fileSock, "");
329
- const r = launch([], { CODEX_HOME: fileHome });
330
- ok(
331
- "a path that exists and is not a socket is indeterminate, not stale — the launcher refuses instead of clobbering something it cannot identify",
332
- r.status !== 0 && r.out.includes("codex-app-server-socket-indeterminate") && r.args.length === 0,
333
- );
334
- }
335
- {
339
+ const regular = launch([], { CODEX_HOME: fileHome });
336
340
  const linkHome = path.join(root, "link-home");
337
341
  const linkSock = resolveCodexDefaultSocketPath({ CODEX_HOME: linkHome });
338
342
  mkdirSync(path.dirname(linkSock), { recursive: true });
339
343
  symlinkSync(path.join(root, "nowhere.sock"), linkSock);
340
- const r = launch([], { CODEX_HOME: linkHome });
344
+ const link = launch([], { CODEX_HOME: linkHome });
341
345
  ok(
342
- "[QK:CODEX-APP-SERVER-REFUSES-INDETERMINATE-SOCKET] a symlinked control socket is refused by name — the same classification the delivery rail's socket check uses",
343
- r.status !== 0 && r.out.includes("codex-app-server-socket-indeterminate") && r.args.length === 0,
346
+ "[QK:CODEX-APP-SERVER-REFUSES-INDETERMINATE-SOCKET] an unidentifiable path is refused, never launched over: a path that exists and is not a socket, and a symlink that is not Codex's rendezvous alias (refused with the classifier's own reason) — the same classification the delivery rail's socket check uses",
347
+ regular.status !== 0 &&
348
+ regular.out.includes("codex-app-server-socket-indeterminate") &&
349
+ regular.args.length === 0 &&
350
+ link.status !== 0 &&
351
+ link.out.includes("codex-app-server-socket-indeterminate") &&
352
+ link.out.includes("is not Codex's rendezvous alias") &&
353
+ link.args.length === 0,
344
354
  );
345
355
  }
346
356
  {
@@ -358,17 +368,50 @@ exit "\${FAKE_CODEX_EXIT:-0}"
358
368
  // Hard Rule 15: an unrecognised reading is the one case where proceeding is unsafe,
359
369
  // because every branch above is a decision about whether this launch would clobber a
360
370
  // running server. The stimulus is a sandbox `python3` that exits 0 while printing
361
- // something nobody wrote — the exact shape a silent fall-through needs.
371
+ // something nobody wrote — the exact shape a silent fall-through needs. The launcher runs
372
+ // that connect probe only on an endpoint the classifier verified as a socket, so the cell
373
+ // puts a (dead) socket file there; without one the probe would never be asked.
374
+ const oddHome = path.join(root, "odd-home");
375
+ const oddSock = resolveCodexDefaultSocketPath({ CODEX_HOME: oddHome });
376
+ mkdirSync(path.dirname(oddSock), { recursive: true });
377
+ spawnSync("python3", ["-c", "import socket,sys;s=socket.socket(socket.AF_UNIX);s.bind(sys.argv[1])", oddSock]);
362
378
  const oddBin = path.join(root, "odd-probe-bin");
363
379
  mkdirSync(oddBin, { recursive: true });
364
380
  const oddPython = path.join(oddBin, "python3");
365
381
  writeFileSync(oddPython, "#!/usr/bin/env bash\ncat >/dev/null\necho 'unexpected-probe-status'\nexit 0\n");
366
382
  chmodSync(oddPython, 0o755);
367
- const r = launch([], { PATH: `${oddBin}:${bin}:${process.env.PATH ?? ""}` });
383
+ const r = launch([], { CODEX_HOME: oddHome, PATH: `${oddBin}:${bin}:${process.env.PATH ?? ""}` });
384
+ // The second stimulus is the CLASSIFIER answering a word it never gives. `stale` is the
385
+ // discriminating one: it is a valid verdict of the connect probe, so a launcher that passed
386
+ // unknown classifier words through would read it as a dead socket and LAUNCH. A sandbox
387
+ // `node` answers for the classifier's basename only and hands every other invocation to
388
+ // the real Node — a test fixture on this gate's PATH, not a seam the product reads.
389
+ const oddClassifierHome = path.join(root, "odd-classifier-home");
390
+ const oddClassifierBin = path.join(root, "odd-classifier-bin");
391
+ mkdirSync(oddClassifierBin, { recursive: true });
392
+ const oddNode = path.join(oddClassifierBin, "node");
393
+ writeFileSync(
394
+ oddNode,
395
+ "#!/usr/bin/env bash\n" +
396
+ 'for a in "$@"; do case "$a" in */codex-socket-classify.js) printf \'stale\'; exit 0 ;; esac; done\n' +
397
+ `exec ${JSON.stringify(process.execPath)} "$@"\n`,
398
+ );
399
+ chmodSync(oddNode, 0o755);
400
+ const c = launch([], {
401
+ CODEX_HOME: oddClassifierHome,
402
+ PATH: `${oddClassifierBin}:${bin}:${process.env.PATH ?? ""}`,
403
+ });
368
404
  ok(
369
405
  "[QK:CODEX-APP-SERVER-REFUSES-UNRECOGNISED-PROBE] a socket classifier that exits 0 with a reading nobody wrote REFUSES instead of falling through to the exec",
370
406
  r.status !== 0 && r.out.includes("codex-app-server-socket-probe-unrecognised") && r.args.length === 0,
371
407
  );
408
+ ok(
409
+ `[QK:CODEX-APP-SERVER-REFUSES-UNRECOGNISED-CLASSIFIER] a classifier word the classifier never gives is refused before it can be read as one of the probe's own verdicts — 'stale' would otherwise launch (status ${c.status}, ${c.args.length} vendor args)`,
410
+ c.status !== 0 &&
411
+ c.out.includes("codex-app-server-socket-probe-unrecognised") &&
412
+ c.out.includes("the socket classifier answered\n 'stale'") &&
413
+ c.args.length === 0,
414
+ );
372
415
  }
373
416
 
374
417
  // ── 5. refusals that keep this from becoming something it is not ────────────
@@ -181,18 +181,219 @@ assert.equal(
181
181
  "[QK:CODEX-NATIVE-FIXED-SOCKET] CODEX_HOME owns the vendor default socket root",
182
182
  );
183
183
 
184
- const temp = fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-codex-native-"));
184
+ // ── the control endpoint: a plain socket, or Codex's EXACT rendezvous alias (#141) ──────────
185
+ // From Codex 0.162.1 the advertised path is a symlink to a 0600 socket the vendor binds under
186
+ // `realpath(/tmp)/codex-daemon-<euid>/`, named by the sha256 hex of the canonical advertised
187
+ // parent + "/" + basename (`[source]` rust-v0.162.1 `app-server-transport/src/transport/
188
+ // unix_socket.rs:290-304`, `uds/src/daemon_directory.rs:13-38`). The expected target below is
189
+ // derived HERE from that upstream rule, not read back from the product, and every cell builds
190
+ // its own sandbox socket directory: `daemonRoot`/`euid` point the product at it, so the real
191
+ // `/tmp/codex-daemon-<euid>` is never touched. Each refusal cell keeps every OTHER hop valid,
192
+ // so it fails on its own rule and not on a neighbour's. A real other-uid owner needs root and
193
+ // stays unmeasured; so does macOS, where `/tmp` canonicalizes to `/private/tmp`.
194
+ //
195
+ // The root and the socket directory names are SHORT on purpose: a bound socket path must fit
196
+ // sun_path (108 bytes on Linux), and a 64-hex name under a long sandbox root does not — listen
197
+ // fails with EINVAL. That limit is why the vendor keeps its own root at `/tmp/codex-daemon-<euid>`.
198
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-cnp-"));
199
+ const euid = process.geteuid?.() ?? -1;
200
+ const sandboxServers: net.Server[] = [];
201
+ // A tree whose canonical path is longer than PATH_MAX can only be removed through the short
202
+ // symlinked path that reaches it, so it is removed first, before the root.
203
+ const overlongTrees: string[] = [];
185
204
  try {
186
205
  const missing = checkCodexSocketFile(path.join(temp, "missing.sock"));
187
- assert.deepEqual(missing.ok ? null : missing.status, "dead", "missing socket is a dead app-server");
206
+ assert.deepEqual(
207
+ missing.ok ? null : [missing.status, missing.status === "dead" ? missing.layout : null],
208
+ ["dead", "absent"],
209
+ "missing socket is a dead app-server, and says it is the absent layout",
210
+ );
188
211
  const regular = path.join(temp, "regular");
189
212
  fs.writeFileSync(regular, "not a socket");
190
213
  const wrongType = checkCodexSocketFile(regular);
191
214
  assert.deepEqual(wrongType.ok ? null : wrongType.status, "indeterminate", "wrong path type never reads dead");
192
- const link = path.join(temp, "link");
193
- fs.symlinkSync(regular, link);
194
- assert.match(JSON.stringify(checkCodexSocketFile(link)), /symlink/, "symlinked socket is refused by name");
215
+
216
+ const listen = async (socketPath: string, mode: number): Promise<void> => {
217
+ const server = net.createServer();
218
+ sandboxServers.push(server);
219
+ await new Promise<void>((resolve, reject) => {
220
+ server.once("error", reject);
221
+ server.listen(socketPath, () => resolve());
222
+ });
223
+ fs.chmodSync(socketPath, mode);
224
+ };
225
+ const layout = (name: string): { caseRoot: string; advertised: string; daemonRoot: string; expected: string } => {
226
+ const caseRoot = path.join(temp, name);
227
+ const parent = path.join(caseRoot, "home", "app-server-control");
228
+ fs.mkdirSync(parent, { recursive: true });
229
+ const daemonRoot = path.join(caseRoot, "d");
230
+ fs.mkdirSync(daemonRoot);
231
+ fs.chmodSync(daemonRoot, 0o700);
232
+ const hash = crypto
233
+ .createHash("sha256")
234
+ .update(`${fs.realpathSync(parent)}/app-server-control.sock`)
235
+ .digest("hex");
236
+ return {
237
+ caseRoot,
238
+ advertised: path.join(parent, "app-server-control.sock"),
239
+ daemonRoot,
240
+ expected: path.join(daemonRoot, hash),
241
+ };
242
+ };
243
+ const classify = (advertised: string, daemonRoot: string, over: { euid?: number } = {}) =>
244
+ checkCodexSocketFile(advertised, { daemonRoot, euid, ...over });
245
+ const statusOf = (check: ReturnType<typeof checkCodexSocketFile>): string => (check.ok ? "ok" : check.status);
246
+
247
+ const plainDir = path.join(temp, "plain");
248
+ fs.mkdirSync(plainDir);
249
+ const plain = path.join(plainDir, "app-server-control.sock");
250
+ await listen(plain, 0o600);
251
+ assert.deepEqual(
252
+ classify(plain, path.join(temp, "no-daemon-dir")),
253
+ { ok: true, connectPath: plain },
254
+ "(A) a plain socket owned by the effective uid is the endpoint itself — the <=0.153.4 layout is unchanged",
255
+ );
256
+
257
+ const exact = layout("exact");
258
+ await listen(exact.expected, 0o600);
259
+ fs.symlinkSync(exact.expected, exact.advertised);
260
+ assert.deepEqual(
261
+ classify(exact.advertised, exact.daemonRoot),
262
+ { ok: true, connectPath: exact.expected },
263
+ "(B) Codex's exact rendezvous alias is accepted, and the endpoint is the verified physical socket, not the alias",
264
+ );
265
+
266
+ const wrongHash = layout("wrong-hash");
267
+ const elsewhere = path.join(wrongHash.daemonRoot, "0".repeat(64));
268
+ await listen(elsewhere, 0o600);
269
+ fs.symlinkSync(elsewhere, wrongHash.advertised);
270
+ const wrongHashCheck = classify(wrongHash.advertised, wrongHash.daemonRoot);
271
+ assert.equal(
272
+ statusOf(wrongHashCheck),
273
+ "indeterminate",
274
+ "[QK:CODEX-SOCKET-ALIAS-EXACT] an alias into Codex's own socket directory whose name is not the hash of THIS advertised path is refused — a valid 0600 socket in the right 0700 directory is not enough",
275
+ );
276
+ assert.match(JSON.stringify(wrongHashCheck), /not Codex's rendezvous alias/, "that refusal says why, by name");
277
+
278
+ const relative = layout("relative");
279
+ await listen(relative.expected, 0o600);
280
+ fs.symlinkSync(path.relative(path.dirname(relative.advertised), relative.expected), relative.advertised);
281
+ assert.equal(
282
+ statusOf(classify(relative.advertised, relative.daemonRoot)),
283
+ "indeterminate",
284
+ "a relative alias target is refused even when it resolves to the expected socket",
285
+ );
286
+
287
+ const chain = layout("chain");
288
+ await listen(chain.expected, 0o600);
289
+ const hop = path.join(chain.caseRoot, "hop.sock");
290
+ fs.symlinkSync(chain.expected, hop);
291
+ fs.symlinkSync(hop, chain.advertised);
292
+ assert.equal(
293
+ statusOf(classify(chain.advertised, chain.daemonRoot)),
294
+ "indeterminate",
295
+ "an alias that reaches the expected socket through a second link is refused; only the vendor's one hop is accepted",
296
+ );
297
+
298
+ const dangling = layout("dangling");
299
+ fs.symlinkSync(dangling.expected, dangling.advertised);
300
+ const danglingCheck = classify(dangling.advertised, dangling.daemonRoot);
301
+ assert.deepEqual(
302
+ danglingCheck.ok ? null : [danglingCheck.status, danglingCheck.status === "dead" ? danglingCheck.layout : null],
303
+ ["dead", "dangling-alias"],
304
+ "the exact alias with its socket gone is dead, named as the dangling alias the vendor itself removes on start",
305
+ );
306
+
307
+ const openDir = layout("dir-0755");
308
+ await listen(openDir.expected, 0o600);
309
+ fs.symlinkSync(openDir.expected, openDir.advertised);
310
+ fs.chmodSync(openDir.daemonRoot, 0o755);
311
+ assert.equal(
312
+ statusOf(classify(openDir.advertised, openDir.daemonRoot)),
313
+ "indeterminate",
314
+ "a socket directory other users can enter is refused — the vendor's own rule is mode & 0o777 == 0o700",
315
+ );
316
+
317
+ const stickyDir = layout("dir-1700");
318
+ await listen(stickyDir.expected, 0o600);
319
+ fs.symlinkSync(stickyDir.expected, stickyDir.advertised);
320
+ fs.chmodSync(stickyDir.daemonRoot, 0o1700);
321
+ assert.deepEqual(
322
+ classify(stickyDir.advertised, stickyDir.daemonRoot),
323
+ { ok: true, connectPath: stickyDir.expected },
324
+ "a sticky 01700 socket directory is accepted: the vendor masks with 0o777, so refusing it would reject a directory Codex itself uses",
325
+ );
326
+
327
+ const linkedDir = layout("dir-symlink");
328
+ await listen(linkedDir.expected, 0o600);
329
+ fs.symlinkSync(linkedDir.expected, linkedDir.advertised);
330
+ const realDir = path.join(linkedDir.caseRoot, "real-daemon-dir");
331
+ fs.renameSync(linkedDir.daemonRoot, realDir);
332
+ fs.symlinkSync(realDir, linkedDir.daemonRoot);
333
+ assert.equal(
334
+ statusOf(classify(linkedDir.advertised, linkedDir.daemonRoot)),
335
+ "indeterminate",
336
+ "a socket directory that is itself a symlink is refused, as the vendor refuses it",
337
+ );
338
+
339
+ const fileTarget = layout("physical-file");
340
+ fs.writeFileSync(fileTarget.expected, "", { mode: 0o600 });
341
+ fs.symlinkSync(fileTarget.expected, fileTarget.advertised);
342
+ assert.equal(
343
+ statusOf(classify(fileTarget.advertised, fileTarget.daemonRoot)),
344
+ "indeterminate",
345
+ "an exact alias whose target is a regular file is refused, never connected to",
346
+ );
347
+
348
+ const readable = layout("physical-0644");
349
+ await listen(readable.expected, 0o644);
350
+ fs.symlinkSync(readable.expected, readable.advertised);
351
+ assert.equal(
352
+ statusOf(classify(readable.advertised, readable.daemonRoot)),
353
+ "indeterminate",
354
+ "an exact alias whose socket group or other users can open is refused",
355
+ );
356
+
357
+ assert.deepEqual(
358
+ [
359
+ statusOf(classify(plain, path.join(temp, "no-daemon-dir"), { euid: euid + 1 })),
360
+ statusOf(classify(exact.advertised, exact.daemonRoot, { euid: euid + 1 })),
361
+ ],
362
+ ["indeterminate", "indeterminate"],
363
+ "ownership is compared with the EFFECTIVE uid (the vendor's geteuid): under another euid both layouts are refused",
364
+ );
365
+
366
+ // canonicalize(parent) failure is the alias state lstat cannot see coming. The advertised
367
+ // path is short, but it reaches its parent through a symlink, and the parent's canonical
368
+ // path is longer than PATH_MAX — the vendor's own `canonicalize` fails on it the same way.
369
+ // It must be indeterminate: never dead, and never a throw the launcher would read as an
370
+ // unrecognised status.
371
+ const canonicalCase = path.join(temp, "canonical");
372
+ let far = canonicalCase;
373
+ while (far.length < 3600) far = path.join(far, "d".repeat(200));
374
+ fs.mkdirSync(far, { recursive: true });
375
+ const via = path.join(canonicalCase, "via");
376
+ fs.symlinkSync(far, via);
377
+ overlongTrees.push(path.join(via, "e".repeat(200)));
378
+ let overlongParent = via;
379
+ for (let i = 0; i < 4; i += 1) overlongParent = path.join(overlongParent, "e".repeat(200));
380
+ fs.mkdirSync(overlongParent, { recursive: true });
381
+ const overlongAdvertised = path.join(overlongParent, "app-server-control.sock");
382
+ fs.symlinkSync(exact.expected, overlongAdvertised);
383
+ const overlongCheck = classify(overlongAdvertised, exact.daemonRoot);
384
+ assert.equal(
385
+ statusOf(overlongCheck),
386
+ "indeterminate",
387
+ "an alias whose parent cannot be canonicalized is indeterminate",
388
+ );
389
+ assert.match(
390
+ JSON.stringify(overlongCheck),
391
+ /could not verify/,
392
+ "that refusal names the verification it could not do",
393
+ );
195
394
  } finally {
395
+ for (const server of sandboxServers) server.close();
396
+ for (const tree of overlongTrees) fs.rmSync(tree, { recursive: true, force: true });
196
397
  fs.rmSync(temp, { recursive: true, force: true });
197
398
  }
198
399
 
@@ -201,15 +402,33 @@ const firstPage = new FakeProtocol([
201
402
  { data: ["other"], nextCursor: "next" },
202
403
  { data: ["target"], nextCursor: null },
203
404
  ]);
405
+ // The check is asked about the advertised default path, and everything after it — the probe's
406
+ // connect AND the route the queue send uses — goes to the endpoint the check returned. Here
407
+ // the two are different strings, so a probe that went back to the advertised path goes red.
408
+ let checkedPath = "";
409
+ let openedPath = "";
204
410
  const alive = await probeCodexThread("target", {
205
411
  env: { CODEX_HOME: "/fixed" },
206
- checkSocket: () => ({ ok: true }),
207
- openProtocol: opener(firstPage),
412
+ checkSocket: (advertised) => {
413
+ checkedPath = advertised;
414
+ return { ok: true, connectPath: "/verified/physical.sock" };
415
+ },
416
+ openProtocol: {
417
+ open: async (socketPath) => {
418
+ openedPath = socketPath;
419
+ return firstPage;
420
+ },
421
+ },
208
422
  });
209
423
  assert.deepEqual(
210
424
  alive,
211
- { status: "alive", route: { backend: "codex", socketPath: "/fixed/app-server-control/app-server-control.sock" } },
212
- "[QK:CODEX-NATIVE-LOADED-PROBE] full loaded-thread pagination proves the volatile route",
425
+ { status: "alive", route: { backend: "codex", socketPath: "/verified/physical.sock" } },
426
+ "[QK:CODEX-NATIVE-LOADED-PROBE] full loaded-thread pagination proves the volatile route, on the endpoint the socket check verified",
427
+ );
428
+ assert.deepEqual(
429
+ [checkedPath, openedPath],
430
+ ["/fixed/app-server-control/app-server-control.sock", "/verified/physical.sock"],
431
+ "the socket check reads the advertised default path; the probe connects to the endpoint it verified, never the alias again",
213
432
  );
214
433
  assert.equal(firstPage.closed, true, "successful probe closes its connection");
215
434
  assert.deepEqual(
@@ -221,7 +440,7 @@ assert.deepEqual(firstPage.calls[3]?.params, { cursor: "next" }, "pagination car
221
440
  const absentProtocol = new FakeProtocol([{}, { data: [], nextCursor: null }]);
222
441
  const absent = await probeCodexThread("target", {
223
442
  env: { CODEX_HOME: "/fixed" },
224
- checkSocket: () => ({ ok: true }),
443
+ checkSocket: (advertised) => ({ ok: true, connectPath: advertised }),
225
444
  openProtocol: opener(absentProtocol),
226
445
  });
227
446
  assert.equal(absent.status, "dead", "a clean loaded inventory without the target is not delivery-capable");
@@ -230,7 +449,7 @@ assert.equal(absentProtocol.closed, true, "negative probe closes its connection"
230
449
  const malformedProtocol = new FakeProtocol([{}, { data: [{ id: "wrong-shape" }], nextCursor: null }]);
231
450
  const malformed = await probeCodexThread("target", {
232
451
  env: { CODEX_HOME: "/fixed" },
233
- checkSocket: () => ({ ok: true }),
452
+ checkSocket: (advertised) => ({ ok: true, connectPath: advertised }),
234
453
  openProtocol: opener(malformedProtocol),
235
454
  });
236
455
  assert.equal(malformed.status, "indeterminate", "a malformed inventory is unknown, never dead");
@@ -374,7 +593,7 @@ assert.deepEqual(
374
593
 
375
594
  const adapter = createCodexNativePushAdapter({
376
595
  env: { CODEX_HOME: "/fixed" },
377
- checkSocket: () => ({ ok: true }),
596
+ checkSocket: (advertised) => ({ ok: true, connectPath: advertised }),
378
597
  openProtocol: opener(new FakeProtocol([{}, { data: ["target"], nextCursor: null }])),
379
598
  runner: queueRunner,
380
599
  });
@@ -26,6 +26,8 @@
26
26
  * FCD-RENDER-PER-RAIL each rail renders its own receipt; no universal tmux prose
27
27
  * FCD-HERDR-VIEW-NOT-ADDRESS the herdr receipt says its coordinates are a view
28
28
  * FCD-RECOVERY-VISIBLE a post-split failure shows closed vs orphan-unreclaimed:reason
29
+ * FCD-ACP-MODEL-EXACT a pi `entwurf/<id>` that is not an exact curated ACP id refuses on
30
+ * either rail before anything runs (#128)
29
31
  */
30
32
 
31
33
  import assert from "node:assert/strict";
@@ -112,10 +114,18 @@ async function main(): Promise<void> {
112
114
  // Up to the FIRST statement of the tmux path, which is the target capability question
113
115
  // (#95 lane B added a caller-side one beside it, so `const missing =` is no longer the
114
116
  // boundary — it is the line that FOLDS the two).
115
- const herdrBranch = dispatchCode.slice(
116
- dispatchCode.indexOf('if (selectFreshCallRail(env) === "herdr")'),
117
- dispatchCode.indexOf("\tconst targetMissing ="),
118
- );
117
+ // Fail closed on the anchors: a renamed or reordered line would otherwise slice an EMPTY branch,
118
+ // and every negative `!herdrBranch.includes(…)` below would pass on nothing (#128 review D2).
119
+ // The slice ends at the herdr branch's OWN return, not at a tmux-path statement, so a mutant
120
+ // that edits the tmux path cannot move this boundary and fail this cell for its reason.
121
+ const herdrStart = dispatchCode.indexOf('if (selectFreshCallRail(env) === "herdr")');
122
+ const herdrReturn = 'return { rail: "herdr", result: await herdrFreshCall(';
123
+ const herdrReturnAt = dispatchCode.indexOf(herdrReturn);
124
+ const herdrBranch =
125
+ herdrStart >= 0 && herdrReturnAt > herdrStart
126
+ ? dispatchCode.slice(herdrStart, herdrReturnAt + herdrReturn.length)
127
+ : "";
128
+ const herdrBranchLocated = herdrBranch.includes("herdrFreshCall(");
119
129
 
120
130
  // ── no fallback, either direction ────────────────────────────────────────────────────
121
131
  const incomplete = await dispatchFreshCall(REQUEST, { HERDR_ENV: "1" }, recordingSpawn().spawn);
@@ -124,6 +134,37 @@ async function main(): Promise<void> {
124
134
  incomplete.rail === "herdr" && !incomplete.result.ok && incomplete.result.reason === "herdr-context-missing",
125
135
  );
126
136
 
137
+ // ── #128: an entwurf/<id> pi launch names an EXACT curated ACP id ───────────────────────
138
+ {
139
+ const herdrSpawn = recordingSpawn();
140
+ const pi = (model: string) => ({ ...REQUEST, model });
141
+ const herdrRetired = await dispatchFreshCall(pi("entwurf/claude-sonnet-5"), HERDR_ENV, herdrSpawn.spawn);
142
+ const tmuxRetired = await dispatchFreshCall(pi(" entwurf/claude-opus-5 "), {}, recordingSpawn().spawn);
143
+ const current = recordingSpawn();
144
+ const herdrCurrent = await dispatchFreshCall(pi("entwurf/claude-opus-5-5"), HERDR_ENV, current.spawn);
145
+ const cortex = await dispatchFreshCall(pi("entwurf/cortex-claude-sonnet-5"), {}, recordingSpawn().spawn);
146
+ const otherBackend = await dispatchFreshCall(
147
+ { ...REQUEST, backend: "claude-code", model: "entwurf/claude-sonnet-5" },
148
+ {},
149
+ recordingSpawn().spawn,
150
+ );
151
+ const reasonOf = (d: Awaited<ReturnType<typeof dispatchFreshCall>>) => (d.result.ok ? null : d.result.reason);
152
+ ok(
153
+ "[QK:FCD-ACP-MODEL-EXACT] a pi sibling under the entwurf provider must name an EXACT curated ACP id: a retired Claude 5 id refuses as acp-model-not-curated on BOTH rails (trimmed like the rails read it) before herdr is asked anything, while the 5.5 id and a Cortex id pass through and another backend is not judged — Pi would otherwise open the retired id on a Cortex row by substring",
154
+ herdrRetired.rail === "herdr" &&
155
+ reasonOf(herdrRetired) === "acp-model-not-curated" &&
156
+ herdrSpawn.calls.length === 0 &&
157
+ tmuxRetired.rail === "tmux" &&
158
+ reasonOf(tmuxRetired) === "acp-model-not-curated" &&
159
+ reasonOf(herdrCurrent) !== "acp-model-not-curated" &&
160
+ current.calls.length > 0 &&
161
+ reasonOf(cortex) !== "acp-model-not-curated" &&
162
+ reasonOf(otherBackend) !== "acp-model-not-curated" &&
163
+ renderDispatchedFreshCall(herdrRetired).text.includes("exact curated ACP id") &&
164
+ renderDispatchedFreshCall(tmuxRetired).text.includes("exact curated ACP id"),
165
+ );
166
+ }
167
+
127
168
  const nonPilot = recordingSpawn();
128
169
  const refused = await dispatchFreshCall({ ...REQUEST, backend: "codex" }, HERDR_ENV, nonPilot.spawn);
129
170
  ok(
@@ -132,6 +173,7 @@ async function main(): Promise<void> {
132
173
  !refused.result.ok &&
133
174
  refused.result.reason === "herdr-backend-unsupported" &&
134
175
  nonPilot.calls.length === 0 &&
176
+ herdrBranchLocated &&
135
177
  // And structurally: the herdr branch names NEITHER capability preflight — not the
136
178
  // target one, and not the caller-seat one #95 lane B put beside it.
137
179
  !herdrBranch.includes("codexFreshPreflight") &&
@@ -153,6 +195,7 @@ async function main(): Promise<void> {
153
195
  !durableRefused.result.ok &&
154
196
  durableRefused.result.reason === "herdr-backend-unsupported" &&
155
197
  durable.calls.length === 0 &&
198
+ herdrBranchLocated &&
156
199
  !herdrBranch.includes("piDurable"),
157
200
  );
158
201