@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.
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +338 -0
- package/CONTRIBUTING.md +1 -1
- package/DELIVERY.md +15 -0
- package/FAQ.md +3 -3
- package/README.md +30 -16
- package/VERIFY.md +14 -11
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +29 -3
- package/docs/setup-clean-host.md +31 -23
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +46 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/model-ids.js +67 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +9 -20
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +4 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +11 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +27 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +2 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/codex-ws-client.js +99 -18
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +3 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +13 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +178 -25
- package/mcp/entwurf-bridge/dist/scripts/codex-socket-classify.js +44 -0
- package/mcp/entwurf-bridge/tsconfig.build.json +5 -0
- package/package.json +20 -15
- package/pi/pi-durable/bootstrap.mjs +12 -8
- package/pi/pi-durable/carrier/runtime.js +52 -2
- package/pi/pi-durable/carrier/subagent.js +6 -1
- package/pi/pi-durable/carrier/tui.js +31 -4
- package/pi/pi-durable/carrier-relocation.mjs +2 -2
- package/pi/pi-durable/overlay/upstream-pin.json +79 -36
- package/pi-extensions/lib/acp/backend-adapter.ts +52 -2
- package/pi-extensions/lib/acp/model-ids.js +77 -0
- package/pi-extensions/lib/acp/models.ts +15 -22
- package/pi-extensions/lib/codex-fresh-preflight.ts +4 -3
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +12 -0
- package/pi-extensions/lib/fresh-call-dispatch.ts +25 -1
- package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
- package/pi-extensions/lib/mux-fresh-call.ts +3 -0
- package/pi-extensions/lib/native-push/codex-ws-client.ts +129 -18
- package/pi-extensions/lib/pi-durable-runtime.ts +4 -4
- package/pi-extensions/lib/resume-launch-identity.ts +12 -4
- package/pi-extensions/meta-bridge-pi-durable.ts +230 -34
- package/run.sh +193 -58
- package/scripts/check-acp-backend-preflight.ts +10 -2
- package/scripts/check-acp-cortex.ts +7 -6
- package/scripts/check-acp-prompt-lifecycle.ts +2 -2
- package/scripts/check-acp-provider-surface.ts +1 -1
- package/scripts/check-acp-session-reuse.ts +5 -5
- package/scripts/check-acp-stop-reason.ts +2 -2
- package/scripts/check-acp-stream-hooks.ts +2 -2
- package/scripts/check-acp-usage-accounting.ts +2 -2
- package/scripts/check-codex-app-server-launch.ts +55 -12
- package/scripts/check-codex-native-push.ts +231 -12
- package/scripts/check-fresh-call-dispatch.ts +47 -4
- package/scripts/check-gate-qualification.ts +8 -6
- package/scripts/check-probe-ordering.ts +13 -6
- package/scripts/check-resume-launch-identity.ts +38 -0
- package/scripts/codex-app-server-launch.sh +54 -29
- package/scripts/codex-socket-classify.ts +46 -0
- package/scripts/emit-pi-durable-carrier.ts +159 -168
- package/scripts/mutants/acp-cortex.json +17 -8
- package/scripts/mutants/acp-set-model-readback.json +28 -0
- package/scripts/mutants/codex-app-server-launch.json +39 -12
- package/scripts/mutants/codex-native.json +15 -0
- package/scripts/mutants/control-send-receipt.json +1 -1
- package/scripts/mutants/fresh-call-dispatch.json +11 -0
- package/scripts/mutants/herdr-dev-checkout.json +70 -0
- package/scripts/mutants/pack-install.json +3 -3
- package/scripts/mutants/pi-durable.json +282 -16
- package/scripts/mutants/pi-floor.json +5 -5
- package/scripts/mutants/resume-launch-identity.json +15 -0
- package/scripts/mutants/v2-visible-resume.json +11 -0
- package/scripts/pi-durable-contact-host.mjs +1 -1
- package/scripts/resolve-acp-bridge.ts +1 -1
- package/scripts/smoke-acp-bundled-mcp-live.ts +1 -1
- package/scripts/smoke-acp-carrier-augment-live.ts +1 -1
- package/scripts/smoke-acp-long-turn-live.ts +1 -1
- package/scripts/smoke-acp-mcp-live.ts +1 -1
- package/scripts/smoke-acp-memory-containment-live.ts +1 -1
- package/scripts/smoke-acp-ordering-probe-live.ts +1 -1
- package/scripts/smoke-acp-overlay-live.ts +1 -1
- package/scripts/smoke-acp-provider-live.ts +1 -1
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-acp-session-reuse-live.ts +1 -1
- package/scripts/smoke-acp-skill-live.ts +1 -1
- package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
- package/scripts/smoke-acp-v2-send-live.ts +1 -1
- package/scripts/smoke-entwurf-chain-live.ts +1 -1
- package/scripts/smoke-mux-lifecycle-live.ts +1 -1
- 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
|
-
|
|
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
|
|
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
|
|
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
|
|
344
|
+
const link = launch([], { CODEX_HOME: linkHome });
|
|
341
345
|
ok(
|
|
342
|
-
"[QK:CODEX-APP-SERVER-REFUSES-INDETERMINATE-SOCKET] a
|
|
343
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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: () =>
|
|
207
|
-
|
|
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: "/
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|