From a51361f4e28dd88bd0c9b830260c3479f6490617 Mon Sep 17 00:00:00 2001 From: Zhaofeng Li Date: Fri, 18 Sep 2026 10:55:52 +0800 Subject: [PATCH 01/13] fix: recover follow-up and final-answer agent messages --- .../docs/guides/subagent-v1-default.md | 7 +- .../docs/reference/configuration/agents.md | 8 +- .../docs/reference/configuration/providers.md | 14 +-- src/server/responses/agent-task-recovery.ts | 54 +++++++--- src/server/responses/encrypted-payload.ts | 29 ++--- structure/subagents.md | 7 +- tests/helpers/agent-task-recovery.ts | 13 +++ tests/server/agent-task-recovery.test.ts | 100 +++++++++++++++++- .../server-agent-task-recovery-replay.test.ts | 33 +++++- .../server/v2-agent-message-failfast.test.ts | 37 ++++++- 10 files changed, 255 insertions(+), 47 deletions(-) diff --git a/docs-site/src/content/docs/guides/subagent-v1-default.md b/docs-site/src/content/docs/guides/subagent-v1-default.md index 6c6c69d55e0..5f4fd79b58f 100644 --- a/docs-site/src/content/docs/guides/subagent-v1-default.md +++ b/docs-site/src/content/docs/guides/subagent-v1-default.md @@ -105,9 +105,10 @@ Four routes, in the order most people should try them: 3. **Trust a direct key-auth Responses relay.** A provider you explicitly mark with `allowEncryptedV2AgentTasks: true` receives the opaque payload instead of the 400. Only do this for a destination you know can consume it. -4. **Enable `agentTaskRecovery`.** Experimental and off by default. It recovers most fresh spawns - through the ChatGPT backend, at the cost of quota, latency and a dependency on undocumented - behavior, and it still loses message-type follow-ups and multipart envelopes. +4. **Enable `agentTaskRecovery`.** Experimental and off by default. It recovers unreadable encrypted + `NEW_TASK`, `MESSAGE`, `FOLLOWUP_TASK`, and `FINAL_ANSWER` items through the ChatGPT backend, at + the cost of quota, latency and a dependency on undocumented behavior; combo recovery remains + limited to spawned-child turns, and split-token fragments stay unsupported. See [Sub-agent Surface](/guides/sub-agent-surface/) for the full mechanics of each, and [Agent configuration](/reference/configuration/agents/) for the settings themselves. diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index 0fd021ad65c..d546eb07a28 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -225,7 +225,13 @@ explicitly enabled and the final routed task contains an otherwise unreadable Fe opencodex uses a raw Responses passthrough request to the fixed `https://chatgpt.com/backend-api/codex/responses` endpoint with forward-mode authentication. ChatGPT returns the plaintext assignment through a forced function call; opencodex then converts -only that task item to a standard user message before routed-provider dispatch. +only that task item to a standard user message before routed-provider dispatch. Direct routed +recovery, cached history replay, and the unreadable-task detector recognise all four codex-rs +agent-message types: `NEW_TASK`, `MESSAGE`, `FOLLOWUP_TASK`, and `FINAL_ANSWER`. Combo recovery +remains limited to spawned-child turns. A `FINAL_ANSWER` envelope may omit its `Task name` line. +Recovery then has no header address to compare with the item's recipient, so that single cross-check +does not run; the sender comparison and the cache scope, which still binds the structured recipient, +are unchanged. This is not local decryption and does not fix the Codex wire protocol. It depends on undocumented ChatGPT backend behavior and may stop working after a backend change. The recovered assignment is diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index a62e8ed8178..5ee01aa2782 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -1093,12 +1093,14 @@ whitespace-only strings remain unchanged, as do incomplete and mixed encrypted/u Encrypted and unknown content is not normalized; native encrypted tasks still require the separate opt-in [task recovery](/reference/configuration/agents/#encrypted-v2-task-recovery). -With task recovery enabled, replayed `NEW_TASK` and `MESSAGE` items reuse a cached assignment only -after validating the caller and matching the parent-thread scope. Replay restoration -does not make a new recovery request or extend cache expiry. Expired or unseen -ciphertext is not replaced. Fresh encrypted `NEW_TASK` and `MESSAGE` items use the same -opt-in recovery path, including native-parent `send_message` delivery. Message type, -sender, recipient, parent scope and caller credentials remain part of validation or cache identity. +With task recovery enabled, replayed `NEW_TASK`, `MESSAGE`, `FOLLOWUP_TASK`, and `FINAL_ANSWER` +items reuse a cached assignment only after validating the caller and matching the parent-thread +scope. Replay restoration does not make a new recovery request or extend cache expiry. Expired or +unseen ciphertext is not replaced. Fresh encrypted `NEW_TASK`, `MESSAGE`, `FOLLOWUP_TASK`, and +`FINAL_ANSWER` items use the same opt-in recovery path, including native-parent `send_message` +delivery. Message type, sender, recipient, parent scope and caller credentials remain part of +validation or cache identity. A `FINAL_ANSWER` without a `Task name` line has no header address to +cross-check, but its recipient still scopes the cache. When a request contains several agent messages, cached replay restoration checks each message independently. The cache separates message type, sender, recipient and ciphertext diff --git a/src/server/responses/agent-task-recovery.ts b/src/server/responses/agent-task-recovery.ts index 8f44661d22d..8d3aaac1479 100644 --- a/src/server/responses/agent-task-recovery.ts +++ b/src/server/responses/agent-task-recovery.ts @@ -75,15 +75,17 @@ interface AgentEnvelope { encryptedStartIndex: number; inputSnapshot: string; headerText: string; - messageType: "NEW_TASK" | "MESSAGE"; - taskName: string; + messageType: "NEW_TASK" | "MESSAGE" | "FOLLOWUP_TASK" | "FINAL_ANSWER"; + taskName: string | null; sender: string; ciphertexts: readonly string[]; author: string; recipient: string; } -const ROUTING_HEADER = /(?:^|\n)Message Type\s*:\s*(NEW_TASK|MESSAGE)\s*\nTask name\s*:\s*(\S+)\s*\nSender\s*:\s*(\S+)\s*\nPayload\s*:\s*(?:\n|$)/; +const ROUTING_HEADER = /(?:^|\n)Message Type\s*:\s*(NEW_TASK|MESSAGE|FOLLOWUP_TASK)\s*\nTask name\s*:\s*(\S+)\s*\nSender\s*:\s*(\S+)\s*\nPayload\s*:\s*(?:\n|$)/; +// FINAL_ANSWER omits the Task name line when the sender declares no recipient. +const FINAL_ANSWER_HEADER = /(?:^|\n)Message Type\s*:\s*FINAL_ANSWER\s*\n(?:Task name\s*:\s*(\S+)\s*\n)?Sender\s*:\s*(\S+)\s*\nPayload\s*:\s*(?:\n|$)/; function findEnvelope(input: unknown): AgentEnvelope | null { if (!Array.isArray(input)) return null; @@ -104,7 +106,7 @@ function findEnvelope(input: unknown): AgentEnvelope | null { if (!Array.isArray(content)) return null; let headerText: string | null = null; - let messageType: "NEW_TASK" | "MESSAGE" | null = null; + let messageType: "NEW_TASK" | "MESSAGE" | "FOLLOWUP_TASK" | "FINAL_ANSWER" | null = null; let taskName: string | null = null; let sender: string | null = null; let encryptedStartIndex = -1; @@ -119,16 +121,24 @@ function findEnvelope(input: unknown): AgentEnvelope | null { && typeof part.text === "string" ) { const match = ROUTING_HEADER.exec(part.text); - if (match) { + const finalMatch = match ? null : FINAL_ANSWER_HEADER.exec(part.text); + if (match || finalMatch) { if (headerText !== null) return null; + const m = match ?? finalMatch!; if ( - part.text.slice(0, match.index).trim().length > 0 - || part.text.slice(match.index + match[0].length).trim().length > 0 + part.text.slice(0, m.index).trim().length > 0 + || part.text.slice(m.index + m[0].length).trim().length > 0 ) return null; - headerText = match[0].startsWith("\n") ? match[0].slice(1) : match[0]; - messageType = match[1] as "NEW_TASK" | "MESSAGE"; - taskName = match[2]!; - sender = match[3]!; + headerText = m[0].startsWith("\n") ? m[0].slice(1) : m[0]; + if (match) { + messageType = match[1] as "NEW_TASK" | "MESSAGE" | "FOLLOWUP_TASK"; + taskName = match[2]!; + sender = match[3]!; + } else { + messageType = "FINAL_ANSWER"; + taskName = finalMatch![1] ?? null; + sender = finalMatch![2]!; + } } } if (part.type !== "encrypted_content") continue; @@ -145,7 +155,6 @@ function findEnvelope(input: unknown): AgentEnvelope | null { if ( !headerText || !messageType - || !taskName || !sender || encryptedStartIndex < 0 || ciphertexts.length === 0 @@ -153,7 +162,13 @@ function findEnvelope(input: unknown): AgentEnvelope | null { const itemRecord = item as { author?: unknown; recipient?: unknown }; if (typeof itemRecord.author !== "string" || typeof itemRecord.recipient !== "string") return null; - if (itemRecord.author !== sender || itemRecord.recipient !== taskName) return null; + // A FINAL_ANSWER without a Task name line declares no recipient, so the structured + // recipient is only cross-checked when a task name is present; admission is the + // trust boundary either way. + if ( + itemRecord.author !== sender + || (taskName !== null && itemRecord.recipient !== taskName) + ) return null; return { itemIndex, @@ -170,10 +185,13 @@ function findEnvelope(input: unknown): AgentEnvelope | null { } function stripMatchingEnvelope(assignment: string, envelope: AgentEnvelope): string | null { - const match = ROUTING_HEADER.exec(assignment); + const header = envelope.messageType === "FINAL_ANSWER" ? FINAL_ANSWER_HEADER : ROUTING_HEADER; + const match = header.exec(assignment); if (!match) return assignment; if (match.index !== 0) return null; - if ( + if (envelope.messageType === "FINAL_ANSWER") { + if ((match[1] ?? null) !== envelope.taskName || match[2] !== envelope.sender) return null; + } else if ( match[1] !== envelope.messageType || match[2] !== envelope.taskName || match[3] !== envelope.sender @@ -302,7 +320,11 @@ function admittedRecovery( .update("\0") .update(envelope.messageType) .update("\0") - .update(envelope.taskName) + .update(envelope.taskName ?? "") + .update("\0") + // A FINAL_ANSWER that omits its Task name line carries no addressing in the header, + // so the structured recipient is the only field separating two such envelopes. + .update(envelope.recipient) .update("\0") .update(envelope.sender) .update("\0") diff --git a/src/server/responses/encrypted-payload.ts b/src/server/responses/encrypted-payload.ts index 0e9efddf999..2df430aecc9 100644 --- a/src/server/responses/encrypted-payload.ts +++ b/src/server/responses/encrypted-payload.ts @@ -177,21 +177,21 @@ function textWithoutFernetRuns(payload: string, runs: readonly FernetTokenRun[]) /** * The routing header codex-rs writes above a delegated agent payload. * - * `MESSAGE` is matched as well as `NEW_TASK`, and only for the unreadability CHECK -- - * recovery stays NEW_TASK-only. #3021 reported a subagent `MESSAGE` arriving in the - * parent conversation as raw `gAAAA...` ciphertext after an `adapter_eof`. The detector - * decides "unreadable" by stripping the envelope and asking whether any plaintext - * survives, so an envelope shape it does not recognise counts as surviving text: a - * `MESSAGE` whose entire body is one Fernet token measured as READABLE and was forwarded - * verbatim. + * All four codex-rs message types are recognised: NEW_TASK, MESSAGE, FOLLOWUP_TASK, + * and FINAL_ANSWER, whose Task name line is optional. #3021 reported a subagent + * `MESSAGE` arriving in the parent conversation as raw `gAAAA...` ciphertext after an + * `adapter_eof`. The detector decides "unreadable" by stripping the envelope and asking + * whether any plaintext survives, so an envelope shape it does not recognise counts as + * surviving text: an unrecognised type whose entire body is one Fernet token measured as + * READABLE and would be forwarded verbatim. * - * Widening the strip is not the same as widening recovery. Recovery decrypts, and - * decrypting a `MESSAGE` on the parent's behalf would build a plaintext oracle out of a - * payload the parent's session may have no right to read. This only lets the proxy - * NOTICE that what it is about to forward is unreadable ciphertext, which is what the - * report asks for: fail closed with a structured error rather than paste the token. + * This strip must therefore stay in step with the message types the opt-in recovery + * recognises (see agent-task-recovery.ts). The strip itself is still only detection: it + * lets the proxy notice that what it is about to forward is unreadable ciphertext and + * fail closed with a structured error rather than paste the token. Recovery admission, + * not the strip, is the trust boundary for decryption. */ -export const AGENT_MESSAGE_ROUTING_ENVELOPE = /(?:^|\n)Message Type\s*:\s*(?:NEW_TASK|MESSAGE)[^\n]*\nTask name\s*:[^\n]*\nSender\s*:[^\n]*\nPayload\s*:\s*(?:\n|$)/gi; +export const AGENT_MESSAGE_ROUTING_ENVELOPE = /(?:^|\n)Message Type\s*:\s*(?:NEW_TASK|MESSAGE|FOLLOWUP_TASK)[^\n]*\nTask name\s*:[^\n]*\nSender\s*:[^\n]*\nPayload\s*:\s*(?:\n|$)|(?:^|\n)Message Type\s*:\s*FINAL_ANSWER[^\n]*\n(?:Task name\s*:[^\n]*\n)?Sender\s*:[^\n]*\nPayload\s*:\s*(?:\n|$)/gi; // CXC is the compatibility-hook control namespace. Strip only the tagged paragraph: // later untagged paragraphs may be genuine task text. Repeated CXC paragraphs are @@ -241,7 +241,8 @@ function splitFernetParts(content: unknown[]): Set { export function hasUnreadableEncryptedAgentTask(input: unknown): boolean { if (!Array.isArray(input)) return false; - // codex-rs appends one NEW_TASK agent_message at the current input tail. Historical + // codex-rs appends one agent_message (any of the four codex-rs message types) at the + // current input tail. Historical // agent messages may be adjacent in full-history bodies; they must not poison the // later task. compaction_trigger/additional_tools are trailing metadata rather than // a newer user turn. diff --git a/structure/subagents.md b/structure/subagents.md index 91ea815fbe6..551af8b2acf 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -160,6 +160,10 @@ that run. The existing credential admission precedes cache access; the cache key unambiguous ordered sequence. One fixed-endpoint request forwards separate parts, and assignment replacement compares the complete original item snapshot before splicing the run. Recovery output is model-transcribed plaintext, not cryptographic fidelity proof, and no internal outage retry is added. +Recovery recognises all four codex-rs message types (NEW_TASK, MESSAGE, FOLLOWUP_TASK, +FINAL_ANSWER); a FINAL_ANSWER envelope may omit the Task name line, in which case the +structured recipient is not cross-checked because the envelope names no recipient, and +admission remains the trust boundary. `src/server/responses/encrypted-payload.ts` uses bounded concatenation only to recognize otherwise unreadable split-token shapes. The sanitizer preserves just those fragment objects and continues @@ -219,7 +223,8 @@ target its own `structuredClone` and its own concrete route, so a sibling's repa them and a target resolving to a routed Responses wire would otherwise send what the parent's own dispatch no longer does. -Nothing here decrypts, and the tail NEW_TASK envelope keeps `unreadable_encrypted_agent_task` and +Nothing here decrypts, and the tail agent_message envelope (any of the four codex-rs +message types) keeps `unreadable_encrypted_agent_task` and its opt-in recovery unchanged: an unreadable current task still fails closed rather than reaching a child with a marker where its assignment should be. An `agent_message` carrying unknown parts but no ciphertext still reaches the wire unchanged and still draws the destination's own 422, which is diff --git a/tests/helpers/agent-task-recovery.ts b/tests/helpers/agent-task-recovery.ts index 4a6a95c5aeb..1d5178bc88d 100644 --- a/tests/helpers/agent-task-recovery.ts +++ b/tests/helpers/agent-task-recovery.ts @@ -47,6 +47,19 @@ function routingEnvelope( export const ROUTING_ENVELOPE = routingEnvelope(); +function finalAnswerEnvelope(withTaskName: boolean, taskName = "/root/worker", sender = "/root"): string { + return [ + "Message Type: FINAL_ANSWER", + ...(withTaskName ? [`Task name: ${taskName}`] : []), + `Sender: ${sender}`, + "Payload:", + "", + ].join("\n"); +} + +export const FINAL_ANSWER_ENVELOPE = finalAnswerEnvelope(false); +export const FINAL_ANSWER_TASK_ENVELOPE = finalAnswerEnvelope(true); + export function agentMessage(content: Array>): unknown[] { return [{ type: "agent_message", diff --git a/tests/server/agent-task-recovery.test.ts b/tests/server/agent-task-recovery.test.ts index faf117f1a76..eba2f1d77d5 100644 --- a/tests/server/agent-task-recovery.test.ts +++ b/tests/server/agent-task-recovery.test.ts @@ -16,6 +16,8 @@ import { codexHeaders, encryptedInput, FERNET_TASK, + FINAL_ANSWER_ENVELOPE, + FINAL_ANSWER_TASK_ENVELOPE, originalFetch, post, providerResponse, @@ -37,7 +39,7 @@ describe("agent task recovery (opt-in, default off)", () => { resetAgentTaskRecoveryState(); }); - for (const messageType of ["NEW_TASK", "MESSAGE"] as const) { + for (const messageType of ["NEW_TASK", "MESSAGE", "FOLLOWUP_TASK"] as const) { test(`typed ${messageType} recovery preserves boolean, replay and discard contracts`, async () => { const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); const config = routedConfig(); @@ -991,7 +993,7 @@ describe("bounded multipart encrypted task recovery", () => { ...tokens.map(encrypted_content => ({ type: "encrypted_content", encrypted_content })), ]); - test.each(["NEW_TASK", "MESSAGE"] as const)("recovers ordered %s parts in one request and isolates sequence caches", async messageType => { + test.each(["NEW_TASK", "MESSAGE", "FOLLOWUP_TASK"] as const)("recovers ordered %s parts in one request and isolates sequence caches", async messageType => { let sends = 0; const sent: Array<{ input: Array<{ content: Array<{ encrypted_content?: string }> }> }> = []; globalThis.fetch = (async (_url, init) => { @@ -1115,3 +1117,97 @@ describe("bounded multipart encrypted task recovery", () => { expect(sends).toBe(1); }); }); + +describe("FINAL_ANSWER encrypted task recovery", () => { + beforeEach(() => resetAgentTaskRecoveryState()); + afterEach(() => { globalThis.fetch = originalFetch; resetAgentTaskRecoveryState(); }); + + test("recovers a FINAL_ANSWER without a Task name line and replays it from cache", async () => { + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + let fetches = 0; + globalThis.fetch = (async () => { + fetches += 1; + return new Response(recoverySse("Recovered final answer.")); + }) as typeof fetch; + const input = () => agentMessage([ + { type: "input_text", text: FINAL_ANSWER_ENVELOPE }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ]); + const typedInput = input(); + expect(await recoverEncryptedAgentTaskWithResult(req, typedInput, {}, routedConfig())).toEqual({ recovered: true }); + expect(typedInput).toEqual([{ + type: "message", role: "user", content: [ + { type: "input_text", text: FINAL_ANSWER_ENVELOPE }, + { type: "input_text", text: "Recovered final answer." }, + ], + }]); + expect(restoreCachedEncryptedAgentTasks(req, input(), routedConfig())).toBe(1); + expect(fetches).toBe(1); + discardEncryptedAgentTaskRecovery(req, input(), routedConfig()); + expect(restoreCachedEncryptedAgentTasks(req, input(), routedConfig())).toBe(0); + }); + + test("recovers a FINAL_ANSWER whose Task name matches the structured recipient", async () => { + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + let fetches = 0; + globalThis.fetch = (async () => { + fetches += 1; + return new Response(recoverySse("Recovered final answer.")); + }) as typeof fetch; + const input = agentMessage([ + { type: "input_text", text: FINAL_ANSWER_TASK_ENVELOPE }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ]); + expect(await recoverEncryptedAgentTaskWithResult(req, input, {}, routedConfig())).toEqual({ recovered: true }); + expect(fetches).toBe(1); + }); + + test("accepts a FINAL_ANSWER assignment that echoes its own header", async () => { + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + let fetches = 0; + globalThis.fetch = (async () => { + fetches += 1; + return new Response(recoverySse(`${FINAL_ANSWER_ENVELOPE}Recovered final answer.`)); + }) as typeof fetch; + const input = agentMessage([ + { type: "input_text", text: FINAL_ANSWER_ENVELOPE }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ]); + expect(await recoverEncryptedAgentTaskWithResult(req, input, {}, routedConfig())).toEqual({ recovered: true }); + expect(input).toEqual([{ + type: "message", role: "user", content: [ + { type: "input_text", text: FINAL_ANSWER_ENVELOPE }, + { type: "input_text", text: "Recovered final answer." }, + ], + }]); + expect(fetches).toBe(1); + }); + + test.each([ + ["sender mismatch", () => [{ + type: "agent_message", + author: "/other", + recipient: "/root/worker", + content: [ + { type: "input_text", text: FINAL_ANSWER_ENVELOPE }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ], + }]], + ["recipient mismatch against the Task name line", () => agentMessage([ + { type: "input_text", text: FINAL_ANSWER_TASK_ENVELOPE.replace("/root/worker", "/root/other-worker") }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ])], + ] as const)("refuses %s FINAL_ANSWER without a recovery dispatch", async (_label, makeInput) => { + let fetches = 0; + globalThis.fetch = (async () => { + fetches += 1; + return new Response(recoverySse("must not run")); + }) as typeof fetch; + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const input = makeInput(); + const before = structuredClone(input); + expect(await recoverEncryptedAgentTaskWithResult(req, input, {}, routedConfig())).toEqual({ recovered: false, reason: "unsupported_envelope" }); + expect(input).toEqual(before); + expect(fetches).toBe(0); + }); +}); diff --git a/tests/server/server-agent-task-recovery-replay.test.ts b/tests/server/server-agent-task-recovery-replay.test.ts index caaad660aad..014c3a57cdf 100644 --- a/tests/server/server-agent-task-recovery-replay.test.ts +++ b/tests/server/server-agent-task-recovery-replay.test.ts @@ -6,7 +6,7 @@ import { bindTurnTerminationScope, rememberDeliveredFinalAnswer } from "../../sr import { conversationIdFromResponsesRequest } from "../../src/server/request-log-conversation"; import type { OcxParsedRequest } from "../../src/types"; import { recoverEncryptedAgentTask, resetAgentTaskRecoveryState, restoreCachedEncryptedAgentTasks } from "../../src/server/responses/agent-task-recovery"; -import { codexHeaders, encryptedInput, fakeChatGptJwt, FERNET_TASK, SECOND_FERNET_TASK, originalFetch, recoverySse, routedConfig } from "../helpers/agent-task-recovery"; +import { codexHeaders, encryptedInput, fakeChatGptJwt, FINAL_ANSWER_ENVELOPE, FERNET_TASK, SECOND_FERNET_TASK, originalFetch, recoverySse, routedConfig } from "../helpers/agent-task-recovery"; afterEach(() => { globalThis.fetch = originalFetch; resetAgentTaskRecoveryState(); }); test("replay reuses admitted recovery after a tool result without another network call", async () => { @@ -264,6 +264,37 @@ test("MESSAGE cache remains isolated by message type, account, parent and sender expect(calls).toBe(1); }); +test("FINAL_ANSWER cache stays isolated by structured recipient when the envelope names no task", async () => { + let calls = 0; + globalThis.fetch = (async () => { + calls++; + return new Response(recoverySse(calls === 1 ? "Worker assignment." : "Other worker assignment.")); + }) as typeof fetch; + const config = routedConfig({ enabled: true }); + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const scope = { parentThreadId: "parent" }; + // Same ciphertext, sender, credentials, and Task-name-less header for both; only the + // structured recipient differs, so the header alone cannot separate these envelopes. + const finalAnswer = (recipient: string): unknown[] => [{ + type: "agent_message", + author: "/root", + recipient, + content: [ + { type: "input_text", text: FINAL_ANSWER_ENVELOPE }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ], + }]; + expect(await recoverEncryptedAgentTask(req, finalAnswer("/root/worker"), {}, config, scope)).toBe(true); + expect(calls).toBe(1); + const other = finalAnswer("/root/other-worker"); + expect(restoreCachedEncryptedAgentTasks(req, other, config, scope)).toBe(0); + expect(JSON.stringify(other)).toContain(FERNET_TASK); + expect(JSON.stringify(other)).not.toContain("Worker assignment."); + expect(await recoverEncryptedAgentTask(req, other, {}, config, scope)).toBe(true); + expect(calls).toBe(2); + expect(JSON.stringify(other)).toContain("Other worker assignment."); +}); + test("mixed history restores cached NEW_TASK and MESSAGE separately before recovering only the new tail", async () => { let calls = 0; diff --git a/tests/server/v2-agent-message-failfast.test.ts b/tests/server/v2-agent-message-failfast.test.ts index 0e0dc66a24b..d684ffb3d15 100644 --- a/tests/server/v2-agent-message-failfast.test.ts +++ b/tests/server/v2-agent-message-failfast.test.ts @@ -38,6 +38,15 @@ const ROUTING_ENVELOPE = [ // The same envelope a delegated agent uses to REPLY, as opposed to being spawned. // #3021 saw one of these reach the parent conversation as raw `gAAAA...` text. const MESSAGE_ROUTING_ENVELOPE = ROUTING_ENVELOPE.replace("NEW_TASK", "MESSAGE"); +// FOLLOWUP_TASK shares the four-line header; a FINAL_ANSWER completion may omit the +// Task name line entirely, in which case the envelope names no recipient. +const FOLLOWUP_ROUTING_ENVELOPE = ROUTING_ENVELOPE.replace("NEW_TASK", "FOLLOWUP_TASK"); +const FINAL_ANSWER_ENVELOPE = [ + "Message Type: FINAL_ANSWER", + "Sender: /root", + "Payload:", + "", +].join("\n"); afterEach(() => { globalThis.fetch = originalFetch; @@ -151,9 +160,8 @@ describe("V2 routed agent-message ciphertext guard", () => { * as surviving text. The envelope pattern matched only NEW_TASK, so a MESSAGE whose * entire body was one Fernet token measured as READABLE and was forwarded verbatim. * - * This is the detection half only. Recovery stays NEW_TASK-only on purpose: - * decrypting a MESSAGE on the parent's behalf would build a plaintext oracle out of - * a payload the parent's session may not be entitled to read. + * This is the detection half only. The opt-in recovery recognises the same envelope + * types; its admission gate, not the detector, is the trust boundary for decryption. */ test("blocks a MESSAGE reply envelope followed only by a Fernet payload", () => { expect(hasUnreadableEncryptedAgentTask(agentMessage([ @@ -182,6 +190,29 @@ describe("V2 routed agent-message ciphertext guard", () => { ]))).toBe(false); }); + test("blocks a FOLLOWUP_TASK envelope followed only by a Fernet payload", () => { + expect(hasUnreadableEncryptedAgentTask(agentMessage([ + { type: "input_text", text: FOLLOWUP_ROUTING_ENVELOPE }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ]))).toBe(true); + }); + + test("blocks a FINAL_ANSWER envelope without a Task name followed only by a Fernet payload", () => { + expect(hasUnreadableEncryptedAgentTask(agentMessage([ + { type: "input_text", text: FINAL_ANSWER_ENVELOPE }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ]))).toBe(true); + }); + + test("a FINAL_ANSWER reply that carries real text stays readable", () => { + // The control. The widened strip must not turn a FINAL_ANSWER carrying real text + // into a blocked one. + expect(hasUnreadableEncryptedAgentTask(agentMessage([ + { type: "input_text", text: `${FINAL_ANSWER_ENVELOPE}the worker finished the migration` }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ]))).toBe(false); + }); + test("blocks a control preamble mixed into the Fernet slot before sanitization", async () => { const input = agentMessage([ { type: "input_text", text: ROUTING_ENVELOPE }, From 718e33b493cb451a2046590c6d4c25c81427d43b Mon Sep 17 00:00:00 2001 From: Zhaofeng Li Date: Fri, 18 Sep 2026 11:24:14 +0800 Subject: [PATCH 02/13] fix: reject mismatched recovery envelope echoes --- src/server/responses/agent-task-recovery.ts | 7 ++++- tests/server/agent-task-recovery.test.ts | 29 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/server/responses/agent-task-recovery.ts b/src/server/responses/agent-task-recovery.ts index 8d3aaac1479..6d587ac2b5a 100644 --- a/src/server/responses/agent-task-recovery.ts +++ b/src/server/responses/agent-task-recovery.ts @@ -187,7 +187,12 @@ function findEnvelope(input: unknown): AgentEnvelope | null { function stripMatchingEnvelope(assignment: string, envelope: AgentEnvelope): string | null { const header = envelope.messageType === "FINAL_ANSWER" ? FINAL_ANSWER_HEADER : ROUTING_HEADER; const match = header.exec(assignment); - if (!match) return assignment; + if (!match) { + // A routing header of the other family is still an echoed envelope: a bare payload does not + // carry one, and a same-family header past the start already fails closed, so reject it too. + const foreign = (header === FINAL_ANSWER_HEADER ? ROUTING_HEADER : FINAL_ANSWER_HEADER).exec(assignment); + return foreign ? null : assignment; + } if (match.index !== 0) return null; if (envelope.messageType === "FINAL_ANSWER") { if ((match[1] ?? null) !== envelope.taskName || match[2] !== envelope.sender) return null; diff --git a/tests/server/agent-task-recovery.test.ts b/tests/server/agent-task-recovery.test.ts index eba2f1d77d5..8c30a27f00c 100644 --- a/tests/server/agent-task-recovery.test.ts +++ b/tests/server/agent-task-recovery.test.ts @@ -1211,3 +1211,32 @@ describe("FINAL_ANSWER encrypted task recovery", () => { expect(fetches).toBe(0); }); }); + +describe("recovery refuses a wrong-family echoed routing header", () => { + beforeEach(() => resetAgentTaskRecoveryState()); + afterEach(() => { globalThis.fetch = originalFetch; resetAgentTaskRecoveryState(); }); + + const echoCases: Array<[string, string, string]> = [ + ["FINAL_ANSWER envelope echoing a NEW_TASK header", FINAL_ANSWER_ENVELOPE, `${ROUTING_ENVELOPE}Recovered final answer.`], + ["FINAL_ANSWER envelope echoing a NEW_TASK header mid-assignment", FINAL_ANSWER_ENVELOPE, `Recovered final answer.\n\n${ROUTING_ENVELOPE}`], + ["NEW_TASK envelope echoing a FINAL_ANSWER header", ROUTING_ENVELOPE, `${FINAL_ANSWER_ENVELOPE}Recovered final answer.`], + ["MESSAGE envelope echoing a FINAL_ANSWER header", ROUTING_ENVELOPE.replace("NEW_TASK", "MESSAGE"), `${FINAL_ANSWER_ENVELOPE}Recovered final answer.`], + ]; + + test.each(echoCases)("refuses %s", async (_label, envelopeText, assignment) => { + let fetches = 0; + globalThis.fetch = (async () => { + fetches += 1; + return new Response(recoverySse(assignment)); + }) as typeof fetch; + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const input = agentMessage([ + { type: "input_text", text: envelopeText }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ]); + const before = structuredClone(input); + expect(await recoverEncryptedAgentTaskWithResult(req, input, {}, routedConfig())).toEqual({ recovered: false, reason: "recovery_invalid_output" }); + expect(input).toEqual(before); + expect(fetches).toBe(1); + }); +}); From c9ec685bd4ad713917fa84cdb0f904be0d963cfd Mon Sep 17 00:00:00 2001 From: Zhaofeng Li Date: Fri, 18 Sep 2026 12:51:10 +0800 Subject: [PATCH 03/13] fix(codex): catch deferred WAL preflight open failures --- src/codex/history-state-open.ts | 18 +++++++++++++++++- structure/codex-home.md | 2 +- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/codex/history-state-open.ts b/src/codex/history-state-open.ts index 58a91c37289..913fcfc1504 100644 --- a/src/codex/history-state-open.ts +++ b/src/codex/history-state-open.ts @@ -61,6 +61,12 @@ export function setStateDbPreflightOpenFailureForTests(hook: typeof openFailureF * `history_injection_preflight_unavailable`, and `ocx sync` refuses on every attempt with no * way forward (#4943). * + * bun:sqlite can defer that failure past construction: when the store needs shared memory it does + * not have, CANTOPEN surfaces on the first statement that touches the schema rather than at + * construction. The fallback decision below therefore materializes one read-only statement + * itself; otherwise the failure escapes the `try` that chooses between the two opens and lands + * in the preflight's catch-all. + * * So the fallback is admitted only in the state where the absent sidecars are what make an * immutable read exact rather than stale: no `-wal` and no `-shm` on disk means no writer is * attached and no committed content sits outside the main database, so the main file IS the @@ -73,7 +79,17 @@ export function openCodexStateForPreflight(resolvedPath: string): Database { try { const forced = openFailureForTests?.(resolvedPath); if (forced) throw forced; - return new Database(resolvedPath, { readonly: true }); + const db = new Database(resolvedPath, { readonly: true }); + try { + // Touches the schema so a deferred CANTOPEN on a cleanly-closed WAL store is raised here, + // where the fallback can decide on it. A live WAL store answers here exactly as it would + // for the preflight's own queries. + db.query("PRAGMA schema_version").get(); + return db; + } catch (error) { + db.close(); + throw error; + } } catch (error) { if (!isStateDbCantOpenError(error)) throw error; if (existsSync(`${resolvedPath}-wal`) || existsSync(`${resolvedPath}-shm`)) throw error; diff --git a/structure/codex-home.md b/structure/codex-home.md index 929c7c25c2f..9838126fe2c 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -307,7 +307,7 @@ management read degrades instead of returning an error page. Injection preflights affected history using the normalized config candidate before writing config/profile/journal, then checks again after the complete artifact write. Native restore also rechecks after successful journal restoration or fallback removal, while exact config/profile/journal preimages and any coordinated remove transaction remain available for compensation. -The preflight opens the state store read-write-free and in that order deliberately. `{ readonly: true }` is the primary open and the only one that joins a live writer's WAL shared memory, so a thread another process just migrated to paginated history is visible and refuses here. A WAL store whose last writer closed cleanly has no `-shm` to join and a read-only connection may not create one, so that open fails `SQLITE_CANTOPEN` on a perfectly healthy store and the catch-all turned it into `history_injection_preflight_unavailable` on every attempt (#4943). The immutable fallback (`immutable=1` over a `file:` URI, the same idiom as the storage scanner and the log-guard inspector) is admitted only when neither `-wal` nor `-shm` is on disk, because that is the state in which the main database is the whole store and an immutable read is exact rather than stale. Either sidecar present, or any other open failure, keeps the original error and the refusal that follows: an immutable read is a snapshot, and a refusal this preflight fails to observe is a config transition over history Codex owns. +The preflight opens the state store read-write-free and in that order deliberately. `{ readonly: true }` is the primary open and the only one that joins a live writer's WAL shared memory, so a thread another process just migrated to paginated history is visible and refuses here. A WAL store whose last writer closed cleanly has no `-shm` to join and a read-only connection may not create one, so that open fails `SQLITE_CANTOPEN` on a perfectly healthy store and the catch-all turned it into `history_injection_preflight_unavailable` on every attempt (#4943). Because `bun:sqlite` can defer that `SQLITE_CANTOPEN` past construction to the first statement that touches the schema, the preflight materializes one schema-touching read-only statement inside the fallback decision, so the choice between the two opens is made on the delivered error rather than on a lazy handle. The immutable fallback (`immutable=1` over a `file:` URI, the same idiom as the storage scanner and the log-guard inspector) is admitted only when neither `-wal` nor `-shm` is on disk, because that is the state in which the main database is the whole store and an immutable read is exact rather than stale. Either sidecar present, or any other open failure, keeps the original error and the refusal that follows: an immutable read is a snapshot, and a refusal this preflight fails to observe is a config transition over history Codex owns. What a detected migration does depends on which refusal it is, and on direction. The reason that stands down is one exported constant, `HISTORY_RELABEL_STANDS_DOWN` in `src/codex/history-provider.ts`, because apply and restore have to agree on it exactly and once did not. From 19e52c2a9ac7a397416d90cf2e9d55f58167f1d9 Mon Sep 17 00:00:00 2001 From: Zhaofeng Li Date: Fri, 18 Sep 2026 12:54:01 +0800 Subject: [PATCH 04/13] test(remote-workspace): stop using the host runtime as the sandbox stand-in trustedBubblewrap() rejects a component that is multiply linked or group/world writable, so bubblewrapPath: process.execPath only held while the pinned runtime sat somewhere private. It does not on this checkout: node_modules/bun hard-links bin/bunx.exe to bin/bun.exe on every platform (install.js optimizeBun()), and a checkout under a world-writable /tmp fails the ancestor rule. Both call sites only build argv and never execute that path, so use the system shell on POSIX and a minimal never-executed fixture on Windows. --- .../remote-workspace-command-runner.test.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/tests/clients/remote-workspace-command-runner.test.ts b/tests/clients/remote-workspace-command-runner.test.ts index 376ed9e0c3d..cf3ffd5d766 100644 --- a/tests/clients/remote-workspace-command-runner.test.ts +++ b/tests/clients/remote-workspace-command-runner.test.ts @@ -17,6 +17,19 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; const roots: string[] = []; +// trustedBubblewrap() only stats this path, so POSIX uses the system shell and Windows a minimal +// fixture that is never executed. The host runtime cannot serve: node_modules/bun hard-links +// bin/bunx.exe to bin/bun.exe, and a /tmp checkout fails the ancestor rule Windows skips. +function trustedSandboxBinary(): string { + if (process.platform !== "win32") return realpathSync("/bin/sh"); + const root = mkdtempSync(join(tmpdir(), "ocx-trusted-sandbox-")); + roots.push(root); + const fixture = join(root, "bwrap.exe"); + writeFileSync(fixture, "", { mode: 0o755 }); + chmodSync(fixture, 0o755); + return realpathSync(fixture); +} + afterEach(() => { for (const root of roots.splice(0)) removeTreeWithRetry(root); }); @@ -83,14 +96,15 @@ describe("remote workspace Linux command sandbox", () => { test("builds a minimal bubblewrap argv with one writable workspace", () => { const state = fixture(); + const sandboxBinary = trustedSandboxBinary(); const argv = linuxRemoteWorkspaceCommandArgv({ command: ["/bin/sh", "-lc", "pwd"], root: state.workspace, cwd: join(state.workspace, "project"), timeoutMs: 1_000, maxOutputBytes: 4_096, - }, { bubblewrapPath: process.execPath }); - expect(argv[0]).toBe(process.execPath); + }, { bubblewrapPath: sandboxBinary }); + expect(argv[0]).toBe(sandboxBinary); expect(argv).toContain("--unshare-net"); expect(argv).toContain("--clearenv"); expect(argv).toContain("--bind"); @@ -280,6 +294,7 @@ describe("remote workspace Linux command sandbox", () => { test("revalidates approved toolchain roots and rejects a later symlink substitution", () => { const state = fixture(); + const sandboxBinary = trustedSandboxBinary(); const realToolchain = join(state.root, "real-toolchain"); const substituted = join(state.root, "toolchain"); mkdirSync(realToolchain); @@ -291,7 +306,7 @@ describe("remote workspace Linux command sandbox", () => { timeoutMs: 1_000, maxOutputBytes: 4_096, }, { - bubblewrapPath: process.execPath, + bubblewrapPath: sandboxBinary, toolchainRoots: [substituted], })).toThrow("remain a real directory"); }); From 3402a7311c0ce23253d3487437fcb9e6fb61ab25 Mon Sep 17 00:00:00 2001 From: Zhaofeng Li Date: Fri, 18 Sep 2026 12:54:01 +0800 Subject: [PATCH 05/13] test(responses-state): fire the ACL belt on a short timer in the never-settling fixture The product belt waits out SUBPROCESS_KILL_GRACE_MS plus its margin before releasing a caller whose killed child has not reaped, so an in-process runner that never settles spent 2 x 2350 ms reaching the queue retry and tombstone, past the fixture watchdog. There is no child to reap here, so schedule the same belt on a 50 ms timer and leave the real duration to tests/lib/stall-subprocess-exit.test.ts. --- .../responses-state-never-settling-acl-child.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/helpers/responses-state-never-settling-acl-child.ts b/tests/helpers/responses-state-never-settling-acl-child.ts index 0f83f78ff19..b46c64e00d2 100644 --- a/tests/helpers/responses-state-never-settling-acl-child.ts +++ b/tests/helpers/responses-state-never-settling-acl-child.ts @@ -11,6 +11,7 @@ import { setResponseStateByteCapForTests, } from "../../src/responses/state"; import { + setAsyncIcaclsBeltSchedulerForTests, setAsyncIcaclsRunnerForTests, setPlatformForTests, } from "../../src/lib/windows-secret-acl"; @@ -46,6 +47,16 @@ if (mode === "principal") { setAsyncIcaclsRunnerForTests(async () => ({ success: true, exitCode: 0, timedOut: false, stdout: "" })); } else { setAsyncIcaclsRunnerForTests(() => new Promise(() => {})); + // The product belt waits out SUBPROCESS_KILL_GRACE_MS plus its margin before releasing a caller + // whose killed child has not reaped, so an in-process runner that never settles would spend + // 2 x 2350 ms reaching the queue's unavoidable retry and tombstone -- longer than this fixture's + // watchdog. There is no child here to reap, so fire the same belt on a short real timer: the + // bounded attempt/retry/tombstone path stays under test, and the real belt duration stays + // covered by tests/lib/stall-subprocess-exit.test.ts. + setAsyncIcaclsBeltSchedulerForTests(callback => { + const timer = setTimeout(callback, 50); + return () => clearTimeout(timer); + }); } rememberLarge(`resp_never_settling_${mode}_first`); From 96ad9ecf9c9fb01857d7895a79fdf8af44407998 Mon Sep 17 00:00:00 2001 From: Zhaofeng Li Date: Fri, 18 Sep 2026 13:00:47 +0800 Subject: [PATCH 06/13] test(layout): hold ratchet-overflow cases in sibling files management-provider-validation.test.ts and codex-v2-gate.test.ts both sat at their file-size caps, so the cases added after the cap was set failed the ratchet. Move the #5013 pins-less POST candidate case and the three #4941 pristine-baseline pin cases into sibling files, register both in the layout maps, and leave the baselines unchanged. Cases are unchanged. --- scripts/test-layout/layout.json | 2 + .../codex-v2-gate-pristine-pins.test.ts | 122 ++++++++++++++++++ tests/codex-integration/codex-v2-gate.test.ts | 96 -------------- tests/fixtures/test-layout-expected.json | 2 + ...management-provider-post-candidate.test.ts | 87 +++++++++++++ .../management-provider-validation.test.ts | 33 ----- 6 files changed, 213 insertions(+), 129 deletions(-) create mode 100644 tests/codex-integration/codex-v2-gate-pristine-pins.test.ts create mode 100644 tests/server/management-provider-post-candidate.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index bdafd2d1abf..1384e3aa5b5 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -567,6 +567,7 @@ "codex-transition-state-race.test.ts": "codex-integration", "codex-transition-state.test.ts": "codex-integration", "codex-user-identity.test.ts": "codex-integration", + "codex-v2-gate-pristine-pins.test.ts": "codex-integration", "codex-v2-gate.test.ts": "codex-integration", "codex-warmup.test.ts": "codex-integration", "codex-websocket-registry.test.ts": "codex-integration", @@ -920,6 +921,7 @@ "management-integration-journal-delete.test.ts": "server", "management-integration-routes.test.ts": "server", "management-origin-tls.test.ts": "server", + "management-provider-post-candidate.test.ts": "server", "management-provider-proto-override.test.ts": "server", "management-provider-validation.test.ts": "server", "management-route-registry.test.ts": "server", diff --git a/tests/codex-integration/codex-v2-gate-pristine-pins.test.ts b/tests/codex-integration/codex-v2-gate-pristine-pins.test.ts new file mode 100644 index 00000000000..c6e264fb995 --- /dev/null +++ b/tests/codex-integration/codex-v2-gate-pristine-pins.test.ts @@ -0,0 +1,122 @@ +/** + * Default-mode pin preservation from the pristine installed-catalog baseline, split out of + * codex-v2-gate.test.ts because that file sits at its file-size ratchet cap; the cases are unchanged. + */ +import { describe, expect, test } from "bun:test"; +import { CODEX_ACCOUNT_BOUND_CATALOG_KIND, mergeCatalogEntriesForSync } from "../../src/codex/catalog"; +import { nativeMultiAgentDefaults } from "../../src/codex/catalog/parsing"; + +function template(): Record { + return { + slug: "gpt-5.5", + display_name: "gpt-5.5", + description: "Native GPT model", + priority: 1, + visibility: "list", + base_instructions: "You are Codex, a coding agent based on GPT-5.\nUse tools carefully.", + model_messages: { instructions_template: "You are Codex, a coding agent based on GPT-5." }, + tool_mode: "code", + supported_reasoning_levels: [ + { effort: "low", description: "l" }, { effort: "medium", description: "m" }, + { effort: "high", description: "h" }, { effort: "xhigh", description: "x" }, + ], + default_reasoning_level: "medium", + }; +} + +describe("3-state multi-agent mode", () => { + test("mode default prefers pristine-baseline pins over the bundled snapshot", () => { + // The installed pristine backup is authoritative for the rows it contains: a + // baseline pin wins even when the bundled snapshot pins a different value, and + // a baseline row with no pin still gets stale forced-stamp cleanup. + const diskSol = { ...template(), slug: "gpt-5.6-sol", display_name: "GPT-5.6 Sol", multi_agent_version: "v2" }; + const diskLuna = { ...template(), slug: "gpt-5.6-luna", display_name: "GPT-5.6 Luna", multi_agent_version: "v2" }; + const diskNative = { ...template(), slug: "gpt-5.5", display_name: "gpt-5.5", multi_agent_version: "v2" }; + const merged = mergeCatalogEntriesForSync( + [diskSol as never, diskLuna as never, diskNative as never], + [], new Map(), [], false, new Set(), null, new Set(), new Set(), "default", + new Set(), false, true, [], new Set(), new Set(), undefined, false, + new Map([ + ["gpt-5.6-sol", "v1"], + ["gpt-5.6-luna", "v1"], + ["gpt-5.5", null], + ]), + ); + // Baseline says v1 — applied instead of the bundled snapshot's v2 pin. + expect(merged.find(e => e.slug === "gpt-5.6-sol")?.multi_agent_version).toBe("v1"); + expect(merged.find(e => e.slug === "gpt-5.6-luna")?.multi_agent_version).toBe("v1"); + // Baseline contains the row with no pin — stale forced stamp is cleared. + expect(merged.find(e => e.slug === "gpt-5.5")?.multi_agent_version).toBeUndefined(); + }); + + test("mode default preserves pins on live native rows outside the pristine baseline", () => { + // A preserved on-disk row the pristine backup never contained may carry a + // user- or provider-preserved pin newer than our bundled snapshot. It was not + // stamped by us, so default mode must not delete it. + const liveNative = { ...template(), slug: "custom-native", display_name: "Custom Native", multi_agent_version: "v2" }; + // A routed row is never in the bare-native baseline, so its absence proves + // nothing; default mode still clears its stale pin. + const staleRouted = { ...template(), slug: "provider/model", display_name: "Routed", multi_agent_version: "v1" }; + const merged = mergeCatalogEntriesForSync( + [liveNative as never, staleRouted as never], + [], new Map(), [], false, new Set(), null, new Set(), new Set(), "default", + new Set(), false, true, [], new Set(), new Set(), undefined, false, + new Map([["gpt-5.6-sol", "v2"]]), + ); + expect(merged.find(e => e.slug === "custom-native")?.multi_agent_version).toBe("v2"); + expect(merged.find(e => e.slug === "provider/model")?.multi_agent_version).toBeUndefined(); + }); + + test("mode default keys baseline pins by trusted account-bound slugs only", () => { + // hasNativeDefault resolves the lookup slug through + // trustedAccountBoundNativeCatalogSlug, so an account-bound clone tracks its + // bound native's pristine pin: the backup's "v1" beats both the bundled + // snapshot's "v2" and a stale stamp on the clone, and a baseline row with no + // pin still clears the clone's stale stamp. + const boundSol = { + ...template(), + slug: "team/gpt-5.6-sol", + display_name: "team / GPT-5.6 Sol", + opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, + multi_agent_version: "v2", + }; + const boundNative = { + ...template(), + slug: "team/gpt-5.5", + display_name: "team / gpt-5.5", + opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, + multi_agent_version: "v2", + }; + // An untrusted slashed row must not key the baseline by its post-slash part: + // "external/gpt-5.6-sol" is not the native "gpt-5.6-sol" row, so its preserved + // pin survives instead of being rewritten to the baseline's "v1". + const foreignRouted = { + ...template(), + slug: "external/gpt-5.6-sol", + display_name: "External Sol", + multi_agent_version: "v2", + }; + const merged = mergeCatalogEntriesForSync( + [foreignRouted as never], [], new Map(), [], false, + new Set(), null, new Set(), new Set(), "default", + new Set(), false, true, [boundSol as never, boundNative as never], + new Set(), new Set(), undefined, false, + new Map([["gpt-5.6-sol", "v1"], ["gpt-5.5", null]]), + ); + expect(merged.find(e => e.slug === "team/gpt-5.6-sol")?.multi_agent_version).toBe("v1"); + expect(merged.find(e => e.slug === "team/gpt-5.5")?.multi_agent_version).toBeUndefined(); + expect(merged.find(e => e.slug === "external/gpt-5.6-sol")?.multi_agent_version).toBe("v2"); + + // The baseline extractor itself never indexes slashed rows, so account-bound + // or routed rows inside a backup cannot alias a bare native slug. + const defaults = nativeMultiAgentDefaults([ + { slug: "gpt-5.6-sol", multi_agent_version: "v1" }, + { slug: "team/gpt-5.6-sol", multi_agent_version: "v2" }, + { slug: "gpt-5.5" }, + ]); + expect(defaults.get("gpt-5.6-sol")).toBe("v1"); + expect(defaults.has("team/gpt-5.6-sol")).toBe(false); + expect(defaults.has("gpt-5.5")).toBe(true); + expect(defaults.get("gpt-5.5")).toBeNull(); + }); +}); diff --git a/tests/codex-integration/codex-v2-gate.test.ts b/tests/codex-integration/codex-v2-gate.test.ts index a7fb5cd8187..7b9ce035aee 100644 --- a/tests/codex-integration/codex-v2-gate.test.ts +++ b/tests/codex-integration/codex-v2-gate.test.ts @@ -20,7 +20,6 @@ import { buildCatalogEntriesFromObservedState, mergeCatalogEntriesFromObservedState, } from "../../src/codex/catalog/sync"; -import { nativeMultiAgentDefaults } from "../../src/codex/catalog/parsing"; import { getAgentsEnabled, getAgentsMaxDepth, @@ -2066,100 +2065,5 @@ describe("3-state multi-agent mode", () => { // gpt-5.5 has no upstream pin — cleared (codex flag decides) expect(native.multi_agent_version).toBeUndefined(); }); - - test("mode default prefers pristine-baseline pins over the bundled snapshot", () => { - // The installed pristine backup is authoritative for the rows it contains: a - // baseline pin wins even when the bundled snapshot pins a different value, and - // a baseline row with no pin still gets stale forced-stamp cleanup. - const diskSol = { ...template(), slug: "gpt-5.6-sol", display_name: "GPT-5.6 Sol", multi_agent_version: "v2" }; - const diskLuna = { ...template(), slug: "gpt-5.6-luna", display_name: "GPT-5.6 Luna", multi_agent_version: "v2" }; - const diskNative = { ...template(), slug: "gpt-5.5", display_name: "gpt-5.5", multi_agent_version: "v2" }; - const merged = mergeCatalogEntriesForSync( - [diskSol as never, diskLuna as never, diskNative as never], - [], new Map(), [], false, new Set(), null, new Set(), new Set(), "default", - new Set(), false, true, [], new Set(), new Set(), undefined, false, - new Map([ - ["gpt-5.6-sol", "v1"], - ["gpt-5.6-luna", "v1"], - ["gpt-5.5", null], - ]), - ); - // Baseline says v1 — applied instead of the bundled snapshot's v2 pin. - expect(merged.find(e => e.slug === "gpt-5.6-sol")?.multi_agent_version).toBe("v1"); - expect(merged.find(e => e.slug === "gpt-5.6-luna")?.multi_agent_version).toBe("v1"); - // Baseline contains the row with no pin — stale forced stamp is cleared. - expect(merged.find(e => e.slug === "gpt-5.5")?.multi_agent_version).toBeUndefined(); - }); - - test("mode default preserves pins on live native rows outside the pristine baseline", () => { - // A preserved on-disk row the pristine backup never contained may carry a - // user- or provider-preserved pin newer than our bundled snapshot. It was not - // stamped by us, so default mode must not delete it. - const liveNative = { ...template(), slug: "custom-native", display_name: "Custom Native", multi_agent_version: "v2" }; - // A routed row is never in the bare-native baseline, so its absence proves - // nothing; default mode still clears its stale pin. - const staleRouted = { ...template(), slug: "provider/model", display_name: "Routed", multi_agent_version: "v1" }; - const merged = mergeCatalogEntriesForSync( - [liveNative as never, staleRouted as never], - [], new Map(), [], false, new Set(), null, new Set(), new Set(), "default", - new Set(), false, true, [], new Set(), new Set(), undefined, false, - new Map([["gpt-5.6-sol", "v2"]]), - ); - expect(merged.find(e => e.slug === "custom-native")?.multi_agent_version).toBe("v2"); - expect(merged.find(e => e.slug === "provider/model")?.multi_agent_version).toBeUndefined(); - }); - - test("mode default keys baseline pins by trusted account-bound slugs only", () => { - // hasNativeDefault resolves the lookup slug through - // trustedAccountBoundNativeCatalogSlug, so an account-bound clone tracks its - // bound native's pristine pin: the backup's "v1" beats both the bundled - // snapshot's "v2" and a stale stamp on the clone, and a baseline row with no - // pin still clears the clone's stale stamp. - const boundSol = { - ...template(), - slug: "team/gpt-5.6-sol", - display_name: "team / GPT-5.6 Sol", - opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, - multi_agent_version: "v2", - }; - const boundNative = { - ...template(), - slug: "team/gpt-5.5", - display_name: "team / gpt-5.5", - opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, - multi_agent_version: "v2", - }; - // An untrusted slashed row must not key the baseline by its post-slash part: - // "external/gpt-5.6-sol" is not the native "gpt-5.6-sol" row, so its preserved - // pin survives instead of being rewritten to the baseline's "v1". - const foreignRouted = { - ...template(), - slug: "external/gpt-5.6-sol", - display_name: "External Sol", - multi_agent_version: "v2", - }; - const merged = mergeCatalogEntriesForSync( - [foreignRouted as never], [], new Map(), [], false, - new Set(), null, new Set(), new Set(), "default", - new Set(), false, true, [boundSol as never, boundNative as never], - new Set(), new Set(), undefined, false, - new Map([["gpt-5.6-sol", "v1"], ["gpt-5.5", null]]), - ); - expect(merged.find(e => e.slug === "team/gpt-5.6-sol")?.multi_agent_version).toBe("v1"); - expect(merged.find(e => e.slug === "team/gpt-5.5")?.multi_agent_version).toBeUndefined(); - expect(merged.find(e => e.slug === "external/gpt-5.6-sol")?.multi_agent_version).toBe("v2"); - - // The baseline extractor itself never indexes slashed rows, so account-bound - // or routed rows inside a backup cannot alias a bare native slug. - const defaults = nativeMultiAgentDefaults([ - { slug: "gpt-5.6-sol", multi_agent_version: "v1" }, - { slug: "team/gpt-5.6-sol", multi_agent_version: "v2" }, - { slug: "gpt-5.5" }, - ]); - expect(defaults.get("gpt-5.6-sol")).toBe("v1"); - expect(defaults.has("team/gpt-5.6-sol")).toBe(false); - expect(defaults.has("gpt-5.5")).toBe(true); - expect(defaults.get("gpt-5.5")).toBeNull(); - }); }); import { ManagementRequest as Request } from "../helpers/management-auth"; diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 5b2b98ccc07..031b0651048 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -399,6 +399,7 @@ "codex-transition-state-race.test.ts": "codex-integration", "codex-transition-state.test.ts": "codex-integration", "codex-user-identity.test.ts": "codex-integration", + "codex-v2-gate-pristine-pins.test.ts": "codex-integration", "codex-v2-gate.test.ts": "codex-integration", "codex-warmup.test.ts": "codex-integration", "codex-websocket-registry.test.ts": "codex-integration", @@ -746,6 +747,7 @@ "management-integration-journal-delete.test.ts": "server", "management-integration-routes.test.ts": "server", "management-origin-tls.test.ts": "server", + "management-provider-post-candidate.test.ts": "server", "management-provider-proto-override.test.ts": "server", "management-provider-validation.test.ts": "server", "management-route-registry.test.ts": "server", diff --git a/tests/server/management-provider-post-candidate.test.ts b/tests/server/management-provider-post-candidate.test.ts new file mode 100644 index 00000000000..12920397c45 --- /dev/null +++ b/tests/server/management-provider-post-candidate.test.ts @@ -0,0 +1,87 @@ +/** + * Split out of management-provider-validation.test.ts because that file sits at its file-size + * ratchet cap; the case itself is unchanged. + */ +import { afterEach, beforeEach, describe, expect, setDefaultTimeout, spyOn, test } from "bun:test"; +import { config } from "../helpers/management-relative-send-paths"; +import { managementFetch as fetch } from "../helpers/management-auth"; +import { existsSync, mkdirSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig, saveConfig } from "../../src/config"; +import { startServer } from "../../src/server"; +import * as destinationPolicy from "../../src/lib/destination-policy"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import type { OcxConfig } from "../../src/types"; + +setDefaultTimeout(60_000); + +const previousApiToken = process.env.OPENCODEX_API_AUTH_TOKEN; +const previousOpencodexHome = process.env.OPENCODEX_HOME; +const originalGlobalFetch = globalThis.fetch; +const TEST_DIR = mkdtempSync(join(tmpdir(), "ocx-management-provider-post-candidate-")); +let isolatedCodexHome: IsolatedCodexHome | null = null; + +const canonicalDirect = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", +} as const; + +function poolProviders(): OcxConfig["providers"] { + return { + openai: { ...canonicalDirect, codexAccountMode: "pool" }, + }; +} + +beforeEach(() => { + isolatedCodexHome = installIsolatedCodexHome("ocx-server-auth-codex-"); +}); + +afterEach(() => { + globalThis.fetch = originalGlobalFetch; + if (previousApiToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; + else process.env.OPENCODEX_API_AUTH_TOKEN = previousApiToken; + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); +}); + +describe("provider management validation", () => { + // A pins-less POST used to skip validateConfigCandidate entirely, so a provider + // field the management boundary does not check (apiKeyPoolStrategy is an + // editor-owned enum) could persist a schema-invalid candidate. The candidate + // draft is now validated for every completed POST before live adoption. + test("provider POST validates a pins-less candidate before live adoption", async () => { + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig({ ...config("127.0.0.1"), providers: poolProviders() }); + + const server = startServer(0); + const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); + try { + const response = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "relay", + provider: { + adapter: "openai-chat", + baseUrl: "https://relay.example/v1", + apiKeyPoolStrategy: "bogus", + }, + }), + }); + expect(response.status).toBe(400); + expect(loadConfig().providers.relay).toBeUndefined(); + } finally { + resolvedError.mockRestore(); + await server.stop(true); + } + }); +}); diff --git a/tests/server/management-provider-validation.test.ts b/tests/server/management-provider-validation.test.ts index 4deaaf2000e..ca4ee03a3af 100644 --- a/tests/server/management-provider-validation.test.ts +++ b/tests/server/management-provider-validation.test.ts @@ -612,39 +612,6 @@ describe("provider management validation", () => { } }); - // A pins-less POST used to skip validateConfigCandidate entirely, so a provider - // field the management boundary does not check (apiKeyPoolStrategy is an - // editor-owned enum) could persist a schema-invalid candidate. The candidate - // draft is now validated for every completed POST before live adoption. - test("provider POST validates a pins-less candidate before live adoption", async () => { - if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); - mkdirSync(TEST_DIR, { recursive: true }); - process.env.OPENCODEX_HOME = TEST_DIR; - saveConfig({ ...config("127.0.0.1"), providers: poolProviders() }); - - const server = startServer(0); - const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); - try { - const response = await fetch(new URL("/api/providers", server.url), { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - name: "relay", - provider: { - adapter: "openai-chat", - baseUrl: "https://relay.example/v1", - apiKeyPoolStrategy: "bogus", - }, - }), - }); - expect(response.status).toBe(400); - expect(loadConfig().providers.relay).toBeUndefined(); - } finally { - resolvedError.mockRestore(); - await server.stop(true); - } - }); - test("provider PATCH sets, clears, and rejects annotateEmptyToolOutputs", async () => { if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); mkdirSync(TEST_DIR, { recursive: true }); From e02ab7f81f39f9dddd55b2de7f9a6615d0191671 Mon Sep 17 00:00:00 2001 From: Zhaofeng Li Date: Fri, 18 Sep 2026 13:02:06 +0800 Subject: [PATCH 07/13] test(catalog): expect routed rows to clear pins in default mode #4941 required a row to be native (a bare slug or a trusted account-bound slug) before the absence of a pristine-baseline entry could preserve its pin, and default mode deletes multi_agent_version on routed rows. The moved case asserted the opposite for an untrusted slashed row; expect the documented clear, which still proves the row never adopted the native slug's baseline pin. --- .../codex-integration/codex-v2-gate-pristine-pins.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/codex-integration/codex-v2-gate-pristine-pins.test.ts b/tests/codex-integration/codex-v2-gate-pristine-pins.test.ts index c6e264fb995..901049668eb 100644 --- a/tests/codex-integration/codex-v2-gate-pristine-pins.test.ts +++ b/tests/codex-integration/codex-v2-gate-pristine-pins.test.ts @@ -87,9 +87,9 @@ describe("3-state multi-agent mode", () => { opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, multi_agent_version: "v2", }; - // An untrusted slashed row must not key the baseline by its post-slash part: - // "external/gpt-5.6-sol" is not the native "gpt-5.6-sol" row, so its preserved - // pin survives instead of being rewritten to the baseline's "v1". + // An untrusted slashed row is routed, not native, so default mode clears its stale pin + // rather than keying the baseline by its post-slash part. The clear also proves the row + // never adopted the native "gpt-5.6-sol" row's baseline "v1". const foreignRouted = { ...template(), slug: "external/gpt-5.6-sol", @@ -105,7 +105,7 @@ describe("3-state multi-agent mode", () => { ); expect(merged.find(e => e.slug === "team/gpt-5.6-sol")?.multi_agent_version).toBe("v1"); expect(merged.find(e => e.slug === "team/gpt-5.5")?.multi_agent_version).toBeUndefined(); - expect(merged.find(e => e.slug === "external/gpt-5.6-sol")?.multi_agent_version).toBe("v2"); + expect(merged.find(e => e.slug === "external/gpt-5.6-sol")?.multi_agent_version).toBeUndefined(); // The baseline extractor itself never indexes slashed rows, so account-bound // or routed rows inside a backup cannot alias a bare native slug. From 30c90bf3cf75fc1f1353b0db27cc29db94bae45a Mon Sep 17 00:00:00 2001 From: Zhaofeng Li Date: Fri, 18 Sep 2026 13:53:48 +0800 Subject: [PATCH 08/13] test(server): give the local-read capability case a real server budget Eight sequential live requests against a bound listener outrun Bun's 5s default under load, exactly like the sibling live-server cases in this file that already carry SERVER_BUDGET_MS. Assertions are unchanged. --- tests/server/server-management-auth.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/server/server-management-auth.test.ts b/tests/server/server-management-auth.test.ts index d5dae240414..c8b661e3224 100644 --- a/tests/server/server-management-auth.test.ts +++ b/tests/server/server-management-auth.test.ts @@ -462,7 +462,7 @@ describe("management and data-plane credential separation", () => { } finally { await server.stop(true); } - }); + }, SERVER_BUDGET_MS); // eight sequential live requests against a real listener; Bun's 5s default is not a server budget. test("a provider-reload capability is one-shot and exact to its operation", () => { const secret = "A".repeat(43); From 8f93c7dd89a849a29b3ac082aa423f553b26b7cc Mon Sep 17 00:00:00 2001 From: Zhaofeng Li Date: Fri, 18 Sep 2026 14:08:19 +0800 Subject: [PATCH 09/13] fix: key the recovery cache on a JSON tuple, not a joined string The key joined admission scope, parent thread, message type, task name, recipient, sender and ciphertexts with NUL. A field carrying that byte shifts every boundary after it, so a FINAL_ANSWER with recipient "r" and sender "s\0t" hashed the same as one with recipient "r\0s" and sender "t" whenever the other fields matched, and the first envelope's recovery replayed for the second instead of recovering it. The key is now one JSON-encoded fixed-order tuple of those fields, which no field content can re-split. The regression moves the NUL between the two fields and asserts the second envelope is not served from the first's cache entry, while the envelope the cache was filled from still replays. structure/subagents.md states the tuple contract. --- src/server/responses/agent-task-recovery.ts | 29 +++++++++---------- structure/subagents.md | 6 ++-- tests/server/agent-task-recovery.test.ts | 32 +++++++++++++++++++++ 3 files changed, 50 insertions(+), 17 deletions(-) diff --git a/src/server/responses/agent-task-recovery.ts b/src/server/responses/agent-task-recovery.ts index 6d587ac2b5a..a77ac55c537 100644 --- a/src/server/responses/agent-task-recovery.ts +++ b/src/server/responses/agent-task-recovery.ts @@ -318,22 +318,21 @@ function admittedRecovery( if (!envelope) return { admitted: false, reason: "unsupported_envelope" }; const admission = recoveryAdmission(req, config); if (!admission) return { admitted: false, reason: "admission_denied" }; + // A JSON-encoded fixed-order tuple, not a delimiter-joined string: a field that carries the + // delimiter shifts every boundary after it, so two envelopes could hash to one entry and one + // recovery would replay for the other. A FINAL_ANSWER that omits its Task name line carries no + // addressing in the header, which leaves the structured recipient as the only field separating + // two such envelopes and makes the boundary the whole difference. const cacheKey = createHash("sha256") - .update(admission.cacheScope) - .update("\0") - .update(parentThreadId ?? "") - .update("\0") - .update(envelope.messageType) - .update("\0") - .update(envelope.taskName ?? "") - .update("\0") - // A FINAL_ANSWER that omits its Task name line carries no addressing in the header, - // so the structured recipient is the only field separating two such envelopes. - .update(envelope.recipient) - .update("\0") - .update(envelope.sender) - .update("\0") - .update(JSON.stringify(envelope.ciphertexts)) + .update(JSON.stringify([ + admission.cacheScope, + parentThreadId ?? "", + envelope.messageType, + envelope.taskName ?? "", + envelope.recipient, + envelope.sender, + envelope.ciphertexts, + ])) .digest("hex"); return { admitted: true, recovery: { envelope, admission, cacheKey } }; } diff --git a/structure/subagents.md b/structure/subagents.md index 2a48532cb98..c9836ad3b1f 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -160,8 +160,10 @@ Full derivation with per-line citations: `devlog/_plan/260816_codexrs_multiagent `src/server/responses/agent-task-recovery.ts` admits at most 32 consecutive, individually complete Fernet-shaped parts with a combined 2 MiB ciphertext limit. Every encrypted slot must belong to -that run. The existing credential admission precedes cache access; the cache key includes an -unambiguous ordered sequence. One fixed-endpoint request forwards separate parts, and assignment +that run. The existing credential admission precedes cache access; the cache key is a JSON-encoded +fixed-order tuple of every addressing field (scope, parent thread, message type, task name, +recipient, sender, ciphertexts) rather than a delimiter-joined string, so no field content can shift +a boundary. One fixed-endpoint request forwards separate parts, and assignment replacement compares the complete original item snapshot before splicing the run. Recovery output is model-transcribed plaintext, not cryptographic fidelity proof, and no internal outage retry is added. Recovery recognises all four codex-rs message types (NEW_TASK, MESSAGE, FOLLOWUP_TASK, diff --git a/tests/server/agent-task-recovery.test.ts b/tests/server/agent-task-recovery.test.ts index 8c30a27f00c..31d01e67ee8 100644 --- a/tests/server/agent-task-recovery.test.ts +++ b/tests/server/agent-task-recovery.test.ts @@ -1147,6 +1147,38 @@ describe("FINAL_ANSWER encrypted task recovery", () => { expect(restoreCachedEncryptedAgentTasks(req, input(), routedConfig())).toBe(0); }); + test("does not share a cache entry when a NUL byte moves between recipient and sender", async () => { + // Both envelopes below carry the same admission scope, parent thread, message type, absent + // Task name and ciphertext, and their recipient/sender fields concatenate to the same bytes + // once a separator is placed between them. Moving where the NUL sits must not move the key. + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + let fetches = 0; + globalThis.fetch = (async () => { + fetches += 1; + return new Response(recoverySse("Recovered final answer.")); + }) as typeof fetch; + const input = (recipient: string, sender: string) => [{ + type: "agent_message", + author: sender, + recipient, + content: [ + { + type: "input_text", + text: ["Message Type: FINAL_ANSWER", `Sender: ${sender}`, "Payload:", ""].join("\n"), + }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ], + }]; + expect(await recoverEncryptedAgentTaskWithResult(req, input("r", "s\0t"), {}, routedConfig())) + .toEqual({ recovered: true }); + expect(fetches).toBe(1); + // A different split of the same concatenation is a different envelope, not a cache hit. + expect(restoreCachedEncryptedAgentTasks(req, input("r\0s", "t"), routedConfig())).toBe(0); + // The envelope the cache was actually filled from still replays, so the line above is not + // passing because nothing was cached at all. + expect(restoreCachedEncryptedAgentTasks(req, input("r", "s\0t"), routedConfig())).toBe(1); + }); + test("recovers a FINAL_ANSWER whose Task name matches the structured recipient", async () => { const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); let fetches = 0; From a881fc78a5c56883b18d11dbcfc3074aac6d84a4 Mon Sep 17 00:00:00 2001 From: Zhaofeng Li Date: Fri, 18 Sep 2026 14:57:33 +0800 Subject: [PATCH 10/13] test(claude,responses,server): budget the live-server suites that failed the broad run Three files failed the broad suite with one shape: a real listener plus live HTTP where the elapsed wait was a stall window, not a code defect. Budget-only change; no production timeout, assertion, or TTL/security check is touched. - claude-native-passthrough: cfg() deliberately shortens the product connect budget to 250ms. It now scales through the existing isolationBudgetMs helper, so a loopback round-trip that misses 250ms under the wrapper's load gate no longer turns a passthrough turn into the product's configured 504. - responses-context-overflow and server-kiro-completion-e2e: every case in both files binds a real server and drives it over HTTP, so both take the existing SERVER_BUDGET_MS default instead of Bun's 5s default. Negative controls, run in an isolated copy and restored: a deterministic 400ms mock delay reproduced the 504 at 250ms and passed at the scaled budget; with the new budget in place, disabling the production conversationId, the 413 classification, and the private-tool split still failed their assertions. So the budget does not hide a vacuous test. The broad suite is not claimed green by this commit. --- tests/claude-integration/claude-native-passthrough.test.ts | 5 ++++- tests/responses/responses-context-overflow.test.ts | 7 ++++++- tests/server/server-kiro-completion-e2e.test.ts | 7 ++++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/tests/claude-integration/claude-native-passthrough.test.ts b/tests/claude-integration/claude-native-passthrough.test.ts index 44cf9333f46..8966ee7ed9f 100644 --- a/tests/claude-integration/claude-native-passthrough.test.ts +++ b/tests/claude-integration/claude-native-passthrough.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../../src/config"; import { buildDesktop3pRegistry } from "../../src/claude/desktop-3p"; +import { isolationBudgetMs } from "../helpers/ci-watchdog"; import { SERVER_BUDGET_MS } from "../helpers/test-budget"; import { startServer } from "../../src/server"; import type { OcxConfig } from "../../src/types"; @@ -62,7 +63,9 @@ function cfg(anthropicBaseUrl: string, extraClaude?: Record): O providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:1/v1", apiKey: "k", allowPrivateNetwork: true, liveModels: false, models: ["test-model"] }, }, - connectTimeoutMs: 250, + // Shortened on purpose so a wedged upstream fails fast; the wrapper's full-suite + // lane needs headroom for a loopback round-trip on a busy machine. + connectTimeoutMs: isolationBudgetMs(250), claudeCode: { anthropicBaseUrl, ...extraClaude }, } as OcxConfig; } diff --git a/tests/responses/responses-context-overflow.test.ts b/tests/responses/responses-context-overflow.test.ts index 24be2316ee3..f6023d53ac8 100644 --- a/tests/responses/responses-context-overflow.test.ts +++ b/tests/responses/responses-context-overflow.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test"; import { mkdtempSync} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -7,6 +7,11 @@ import { startServer } from "../../src/server"; import { PROVIDER_INPUT_TOO_LARGE_MESSAGE } from "../../src/server/responses/context-overflow"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { SERVER_BUDGET_MS } from "../helpers/test-budget"; + +// Every case here binds a real listener and drives it over HTTP, so the file-wide budget is +// the server budget; the assertions themselves are unchanged. +setDefaultTimeout(SERVER_BUDGET_MS); let testDir = ""; let previousOcxHome: string | undefined; diff --git a/tests/server/server-kiro-completion-e2e.test.ts b/tests/server/server-kiro-completion-e2e.test.ts index 01327b482c8..a81796425c7 100644 --- a/tests/server/server-kiro-completion-e2e.test.ts +++ b/tests/server/server-kiro-completion-e2e.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -10,6 +10,11 @@ import { clearRequestLogsForTests, getRequestLogEntries } from "../../src/server import type { OcxConfig } from "../../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { SERVER_BUDGET_MS } from "../helpers/test-budget"; + +// Every case here binds a real listener and drives it over HTTP, so the file-wide budget is +// the server budget; the assertions themselves are unchanged. +setDefaultTimeout(SERVER_BUDGET_MS); const enc = new TextEncoder(); const originalFetch = globalThis.fetch; From f7c9f5e3db7cac9db70c1f5b28be3b2bfa061d9c Mon Sep 17 00:00:00 2001 From: Zhaofeng Li Date: Fri, 18 Sep 2026 15:12:02 +0800 Subject: [PATCH 11/13] test(cli): check the Aside bulk failure channel before its stdout The bulk-207 case asserted stdout first, so a run whose request never reached the route reported an empty rendering instead of the transport error that caused it. The same two assertions now run in the order stderr -> requests -> stdout; the assertions themselves are unchanged, and no production code moves. --- tests/cli/cli-headless-parity.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index 9b4bd04ce4f..ce52216d16e 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -1249,6 +1249,13 @@ describe("Aside CLI recovery metadata", () => { expect(await handleClientIntegrationCommand([ "enable", "--client", "aside", ...(wantsJson ? ["--json"] : []), ], runtime.deps)).toBe(1); + // Assert the failure channel before stdout. A request that never reached the route leaves + // stdout empty for a reason stderr alone names, and checking stdout first reports the empty + // rendering instead of the transport error that caused it. + expect(error.mock.calls.map(call => String(call[0])).join("\n")).toContain(result.message); + expect(runtime.requests).toEqual([{ + path: "/api/client-integrations/aside/profiles", method: "PUT", body: { enabled: true }, + }]); const stdout = log.mock.calls.map(call => String(call[0])).join("\n"); if (wantsJson) { expect(JSON.parse(stdout)).toEqual(result); @@ -1261,10 +1268,6 @@ describe("Aside CLI recovery metadata", () => { "aside:9 Profile 9 recovery failed Recovery did not finish.", ]); } - expect(error.mock.calls.map(call => String(call[0])).join("\n")).toContain(result.message); - expect(runtime.requests).toEqual([{ - path: "/api/client-integrations/aside/profiles", method: "PUT", body: { enabled: true }, - }]); } finally { log.mockRestore(); error.mockRestore(); From 3c509951e10f6e0f302d90231d0eb17839a5910d Mon Sep 17 00:00:00 2001 From: Zhaofeng Li Date: Wed, 23 Sep 2026 11:53:53 +0800 Subject: [PATCH 12/13] fix(responses): align encrypted envelope guard with recovery --- src/server/responses/agent-task-recovery.ts | 9 +++------ src/server/responses/encrypted-payload.ts | 2 +- tests/server/agent-task-recovery.test.ts | 2 ++ tests/server/v2-agent-message-failfast.test.ts | 11 +++++++++++ 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/server/responses/agent-task-recovery.ts b/src/server/responses/agent-task-recovery.ts index a77ac55c537..362dcfdf8c1 100644 --- a/src/server/responses/agent-task-recovery.ts +++ b/src/server/responses/agent-task-recovery.ts @@ -186,13 +186,10 @@ function findEnvelope(input: unknown): AgentEnvelope | null { function stripMatchingEnvelope(assignment: string, envelope: AgentEnvelope): string | null { const header = envelope.messageType === "FINAL_ANSWER" ? FINAL_ANSWER_HEADER : ROUTING_HEADER; + const foreign = header === FINAL_ANSWER_HEADER ? ROUTING_HEADER : FINAL_ANSWER_HEADER; + if (foreign.test(assignment)) return null; const match = header.exec(assignment); - if (!match) { - // A routing header of the other family is still an echoed envelope: a bare payload does not - // carry one, and a same-family header past the start already fails closed, so reject it too. - const foreign = (header === FINAL_ANSWER_HEADER ? ROUTING_HEADER : FINAL_ANSWER_HEADER).exec(assignment); - return foreign ? null : assignment; - } + if (!match) return assignment; if (match.index !== 0) return null; if (envelope.messageType === "FINAL_ANSWER") { if ((match[1] ?? null) !== envelope.taskName || match[2] !== envelope.sender) return null; diff --git a/src/server/responses/encrypted-payload.ts b/src/server/responses/encrypted-payload.ts index 2df430aecc9..d8ae7878630 100644 --- a/src/server/responses/encrypted-payload.ts +++ b/src/server/responses/encrypted-payload.ts @@ -191,7 +191,7 @@ function textWithoutFernetRuns(payload: string, runs: readonly FernetTokenRun[]) * fail closed with a structured error rather than paste the token. Recovery admission, * not the strip, is the trust boundary for decryption. */ -export const AGENT_MESSAGE_ROUTING_ENVELOPE = /(?:^|\n)Message Type\s*:\s*(?:NEW_TASK|MESSAGE|FOLLOWUP_TASK)[^\n]*\nTask name\s*:[^\n]*\nSender\s*:[^\n]*\nPayload\s*:\s*(?:\n|$)|(?:^|\n)Message Type\s*:\s*FINAL_ANSWER[^\n]*\n(?:Task name\s*:[^\n]*\n)?Sender\s*:[^\n]*\nPayload\s*:\s*(?:\n|$)/gi; +export const AGENT_MESSAGE_ROUTING_ENVELOPE = /(?:^|\n)Message Type\s*:\s*(?:NEW_TASK|MESSAGE|FOLLOWUP_TASK)[^\n]*\n\s*Task name\s*:[^\n]*\n\s*Sender\s*:[^\n]*\n\s*Payload\s*:\s*(?:\n|$)|(?:^|\n)Message Type\s*:\s*FINAL_ANSWER[^\n]*\n\s*(?:Task name\s*:[^\n]*\n\s*)?Sender\s*:[^\n]*\n\s*Payload\s*:\s*(?:\n|$)/gi; // CXC is the compatibility-hook control namespace. Strip only the tagged paragraph: // later untagged paragraphs may be genuine task text. Repeated CXC paragraphs are diff --git a/tests/server/agent-task-recovery.test.ts b/tests/server/agent-task-recovery.test.ts index 31d01e67ee8..02efa649f24 100644 --- a/tests/server/agent-task-recovery.test.ts +++ b/tests/server/agent-task-recovery.test.ts @@ -1251,7 +1251,9 @@ describe("recovery refuses a wrong-family echoed routing header", () => { const echoCases: Array<[string, string, string]> = [ ["FINAL_ANSWER envelope echoing a NEW_TASK header", FINAL_ANSWER_ENVELOPE, `${ROUTING_ENVELOPE}Recovered final answer.`], ["FINAL_ANSWER envelope echoing a NEW_TASK header mid-assignment", FINAL_ANSWER_ENVELOPE, `Recovered final answer.\n\n${ROUTING_ENVELOPE}`], + ["FINAL_ANSWER echo followed by a NEW_TASK header", FINAL_ANSWER_ENVELOPE, `${FINAL_ANSWER_ENVELOPE}${ROUTING_ENVELOPE}Recovered final answer.`], ["NEW_TASK envelope echoing a FINAL_ANSWER header", ROUTING_ENVELOPE, `${FINAL_ANSWER_ENVELOPE}Recovered final answer.`], + ["NEW_TASK echo followed by a FINAL_ANSWER header", ROUTING_ENVELOPE, `${ROUTING_ENVELOPE}${FINAL_ANSWER_ENVELOPE}Recovered task.`], ["MESSAGE envelope echoing a FINAL_ANSWER header", ROUTING_ENVELOPE.replace("NEW_TASK", "MESSAGE"), `${FINAL_ANSWER_ENVELOPE}Recovered final answer.`], ]; diff --git a/tests/server/v2-agent-message-failfast.test.ts b/tests/server/v2-agent-message-failfast.test.ts index d684ffb3d15..b2ecb26a0fd 100644 --- a/tests/server/v2-agent-message-failfast.test.ts +++ b/tests/server/v2-agent-message-failfast.test.ts @@ -197,6 +197,17 @@ describe("V2 routed agent-message ciphertext guard", () => { ]))).toBe(true); }); + test.each([ + FOLLOWUP_ROUTING_ENVELOPE, + FINAL_ANSWER_ENVELOPE, + ])("blocks an encrypted envelope with a blank line after its type", envelope => { + const input = agentMessage([ + { type: "input_text", text: envelope.replace("\n", "\n\n") }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ]); + expect(hasUnreadableEncryptedAgentTask(input)).toBe(true); + }); + test("blocks a FINAL_ANSWER envelope without a Task name followed only by a Fernet payload", () => { expect(hasUnreadableEncryptedAgentTask(agentMessage([ { type: "input_text", text: FINAL_ANSWER_ENVELOPE }, From 0f020d0dba11d08ad84e03ba20aa4b8620ee13e6 Mon Sep 17 00:00:00 2001 From: Zhaofeng Li Date: Wed, 23 Sep 2026 12:31:38 +0800 Subject: [PATCH 13/13] test: remove unrelated fixture and timing changes from agent recovery PR --- .../claude-native-passthrough.test.ts | 5 +---- tests/cli/cli-headless-parity.test.ts | 11 ++++------- .../remote-workspace-command-runner.test.ts | 19 ++++++++++--------- ...esponses-state-never-settling-acl-child.ts | 11 ----------- .../responses-context-overflow.test.ts | 7 +------ .../server/server-kiro-completion-e2e.test.ts | 7 +------ tests/server/server-management-auth.test.ts | 2 +- 7 files changed, 18 insertions(+), 44 deletions(-) diff --git a/tests/claude-integration/claude-native-passthrough.test.ts b/tests/claude-integration/claude-native-passthrough.test.ts index 8966ee7ed9f..44cf9333f46 100644 --- a/tests/claude-integration/claude-native-passthrough.test.ts +++ b/tests/claude-integration/claude-native-passthrough.test.ts @@ -6,7 +6,6 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../../src/config"; import { buildDesktop3pRegistry } from "../../src/claude/desktop-3p"; -import { isolationBudgetMs } from "../helpers/ci-watchdog"; import { SERVER_BUDGET_MS } from "../helpers/test-budget"; import { startServer } from "../../src/server"; import type { OcxConfig } from "../../src/types"; @@ -63,9 +62,7 @@ function cfg(anthropicBaseUrl: string, extraClaude?: Record): O providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:1/v1", apiKey: "k", allowPrivateNetwork: true, liveModels: false, models: ["test-model"] }, }, - // Shortened on purpose so a wedged upstream fails fast; the wrapper's full-suite - // lane needs headroom for a loopback round-trip on a busy machine. - connectTimeoutMs: isolationBudgetMs(250), + connectTimeoutMs: 250, claudeCode: { anthropicBaseUrl, ...extraClaude }, } as OcxConfig; } diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index 4a14bf8825b..1b0096372d3 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -1257,13 +1257,6 @@ describe("Aside CLI recovery metadata", () => { expect(await handleClientIntegrationCommand([ "enable", "--client", "aside", ...(wantsJson ? ["--json"] : []), ], runtime.deps)).toBe(1); - // Assert the failure channel before stdout. A request that never reached the route leaves - // stdout empty for a reason stderr alone names, and checking stdout first reports the empty - // rendering instead of the transport error that caused it. - expect(error.mock.calls.map(call => String(call[0])).join("\n")).toContain(result.message); - expect(runtime.requests).toEqual([{ - path: "/api/client-integrations/aside/profiles", method: "PUT", body: { enabled: true }, - }]); const stdout = log.mock.calls.map(call => String(call[0])).join("\n"); if (wantsJson) { expect(JSON.parse(stdout)).toEqual(result); @@ -1276,6 +1269,10 @@ describe("Aside CLI recovery metadata", () => { "aside:9 Profile 9 recovery failed Recovery did not finish.", ]); } + expect(error.mock.calls.map(call => String(call[0])).join("\n")).toContain(result.message); + expect(runtime.requests).toEqual([{ + path: "/api/client-integrations/aside/profiles", method: "PUT", body: { enabled: true }, + }]); } finally { log.mockRestore(); error.mockRestore(); diff --git a/tests/clients/remote-workspace-command-runner.test.ts b/tests/clients/remote-workspace-command-runner.test.ts index 570b590e32e..fc8170c41ac 100644 --- a/tests/clients/remote-workspace-command-runner.test.ts +++ b/tests/clients/remote-workspace-command-runner.test.ts @@ -31,14 +31,15 @@ function fixture() { return { root, workspace, outside }; } -function trustedSandboxBinary(): string { - if (process.platform !== "win32") return realpathSync("/bin/sh"); - const root = mkdtempSync(join(tmpdir(), "ocx-trusted-sandbox-")); +function privateBubblewrapFixture(): string { + // The production guard checks every ancestor, so tmpdir's shared /tmp parent + // is deliberately ineligible. Own a disposable sibling under the trusted + // interpreter directory without chmod'ing the interpreter or shared parents. + const root = mkdtempSync(join(dirname(realpathSync(process.execPath)), "ocx-bwrap-fixture-")); roots.push(root); - const path = join(root, "bwrap.exe"); - writeFileSync(path, "", { mode: 0o755 }); - chmodSync(path, 0o755); - return realpathSync(path); + const path = join(root, "bwrap"); + writeFileSync(path, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + return path; } function fakeNativeHelper(root: string, response: Record, requestPath?: string) { @@ -93,7 +94,7 @@ describe("remote workspace Linux command sandbox", () => { test("builds a minimal bubblewrap argv with one writable workspace", () => { const state = fixture(); - const bubblewrapPath = trustedSandboxBinary(); + const bubblewrapPath = privateBubblewrapFixture(); const argv = linuxRemoteWorkspaceCommandArgv({ command: ["/bin/sh", "-lc", "pwd"], root: state.workspace, @@ -302,7 +303,7 @@ describe("remote workspace Linux command sandbox", () => { timeoutMs: 1_000, maxOutputBytes: 4_096, }, { - bubblewrapPath: trustedSandboxBinary(), + bubblewrapPath: privateBubblewrapFixture(), toolchainRoots: [substituted], })).toThrow("remain a real directory"); }); diff --git a/tests/helpers/responses-state-never-settling-acl-child.ts b/tests/helpers/responses-state-never-settling-acl-child.ts index b46c64e00d2..0f83f78ff19 100644 --- a/tests/helpers/responses-state-never-settling-acl-child.ts +++ b/tests/helpers/responses-state-never-settling-acl-child.ts @@ -11,7 +11,6 @@ import { setResponseStateByteCapForTests, } from "../../src/responses/state"; import { - setAsyncIcaclsBeltSchedulerForTests, setAsyncIcaclsRunnerForTests, setPlatformForTests, } from "../../src/lib/windows-secret-acl"; @@ -47,16 +46,6 @@ if (mode === "principal") { setAsyncIcaclsRunnerForTests(async () => ({ success: true, exitCode: 0, timedOut: false, stdout: "" })); } else { setAsyncIcaclsRunnerForTests(() => new Promise(() => {})); - // The product belt waits out SUBPROCESS_KILL_GRACE_MS plus its margin before releasing a caller - // whose killed child has not reaped, so an in-process runner that never settles would spend - // 2 x 2350 ms reaching the queue's unavoidable retry and tombstone -- longer than this fixture's - // watchdog. There is no child here to reap, so fire the same belt on a short real timer: the - // bounded attempt/retry/tombstone path stays under test, and the real belt duration stays - // covered by tests/lib/stall-subprocess-exit.test.ts. - setAsyncIcaclsBeltSchedulerForTests(callback => { - const timer = setTimeout(callback, 50); - return () => clearTimeout(timer); - }); } rememberLarge(`resp_never_settling_${mode}_first`); diff --git a/tests/responses/responses-context-overflow.test.ts b/tests/responses/responses-context-overflow.test.ts index f6023d53ac8..24be2316ee3 100644 --- a/tests/responses/responses-context-overflow.test.ts +++ b/tests/responses/responses-context-overflow.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtempSync} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -7,11 +7,6 @@ import { startServer } from "../../src/server"; import { PROVIDER_INPUT_TOO_LARGE_MESSAGE } from "../../src/server/responses/context-overflow"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; -import { SERVER_BUDGET_MS } from "../helpers/test-budget"; - -// Every case here binds a real listener and drives it over HTTP, so the file-wide budget is -// the server budget; the assertions themselves are unchanged. -setDefaultTimeout(SERVER_BUDGET_MS); let testDir = ""; let previousOcxHome: string | undefined; diff --git a/tests/server/server-kiro-completion-e2e.test.ts b/tests/server/server-kiro-completion-e2e.test.ts index a81796425c7..01327b482c8 100644 --- a/tests/server/server-kiro-completion-e2e.test.ts +++ b/tests/server/server-kiro-completion-e2e.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -10,11 +10,6 @@ import { clearRequestLogsForTests, getRequestLogEntries } from "../../src/server import type { OcxConfig } from "../../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; -import { SERVER_BUDGET_MS } from "../helpers/test-budget"; - -// Every case here binds a real listener and drives it over HTTP, so the file-wide budget is -// the server budget; the assertions themselves are unchanged. -setDefaultTimeout(SERVER_BUDGET_MS); const enc = new TextEncoder(); const originalFetch = globalThis.fetch; diff --git a/tests/server/server-management-auth.test.ts b/tests/server/server-management-auth.test.ts index 1c733a892fa..8459b651250 100644 --- a/tests/server/server-management-auth.test.ts +++ b/tests/server/server-management-auth.test.ts @@ -443,7 +443,7 @@ describe("management and data-plane credential separation", () => { } finally { await server.stop(true); } - }, SERVER_BUDGET_MS); // eight sequential live requests against a real listener; Bun's 5s default is not a server budget. + }); test("a provider-reload capability is one-shot and exact to its operation", () => { const secret = "A".repeat(43);