From db48ebc301d510e5a049539922ce973ead94cd53 Mon Sep 17 00:00:00 2001 From: yxr1995-maker Date: Thu, 27 Aug 2026 17:02:59 +0800 Subject: [PATCH 1/2] Recover encrypted agent tasks on the combo path before failing closed The direct routed path recovers a ciphertext-only V2 spawn through agentTaskRecovery when its final route is not the native backend, but the combo path failed closed unconditionally: a ciphertext-only spawn routed at an all-third-party combo always returned 400 even when the caller carried valid recovery credentials. Run the same single recovery pass before the combo payload gate and mark every target eligible after success. Failed recovery keeps the existing unreadable_encrypted_agent_task 400, and child attempts never re-run recovery (comboAttempt guard preserved). --- src/server/responses/core.ts | 33 ++++++++++- tests/agent-task-recovery-combo.test.ts | 75 +++++++++++++++++++++++++ tests/agent-task-recovery.test.ts | 16 ++++-- 3 files changed, 116 insertions(+), 8 deletions(-) create mode 100644 tests/agent-task-recovery-combo.test.ts diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index d88f9b22a7..b491222659 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1905,6 +1905,7 @@ export async function handleComboResponses( }); }; + const parentThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() ?? null; const unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask( (body as { input?: unknown } | undefined)?.input, ); @@ -1918,11 +1919,39 @@ export async function handleComboResponses( return false; } }; + let comboPayloadReadable = false; const payloadEligible = (target: (typeof combo.targets)[number]): boolean => - !unreadableEncryptedAgentTask || canDecryptUnreadableAgentTask(target); + comboPayloadReadable || !unreadableEncryptedAgentTask || canDecryptUnreadableAgentTask(target); if (unreadableEncryptedAgentTask && !combo.targets.some(canDecryptUnreadableAgentTask)) { - return unreadableEncryptedAgentTaskResponse(); + // A ciphertext-only spawn can still be recovered the same way the direct path + // recovers it when its final route is non-native. Try recovery once before + // failing the combo; every target becomes eligible after a successful pass. + const agentTaskRecovery = agentTaskRecoveryConfig(config); + if ( + isThreadSpawnRequest(req.headers) + && agentTaskRecovery + && !options.comboAttempt + ) { + let recovered = false; + try { + recovered = await recoverEncryptedAgentTask( + req, + (body as { input?: unknown } | undefined)?.input, + agentTaskRecovery, + config, + { parentThreadId, abortSignal: options.abortSignal }, + ); + } catch { + recovered = false; + } + if (!recovered || hasUnreadableEncryptedAgentTask((body as { input?: unknown } | undefined)?.input)) { + return unreadableEncryptedAgentTaskResponse(); + } + comboPayloadReadable = true; + } else { + return unreadableEncryptedAgentTaskResponse(); + } } const initialNow = Date.now(); diff --git a/tests/agent-task-recovery-combo.test.ts b/tests/agent-task-recovery-combo.test.ts new file mode 100644 index 0000000000..f76fff6642 --- /dev/null +++ b/tests/agent-task-recovery-combo.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { resetAgentTaskRecoveryState } from "../src/server/responses/agent-task-recovery"; +import { + agentMessage, + codexHeaders, + encryptedInput, + FERNET_TASK, + originalFetch, + post, + providerResponse, + recoverySse, + routedConfig, +} from "./helpers/agent-task-recovery"; + +function comboConfig(): ReturnType { + const config = routedConfig(); + config.combos = { + fast: { + targets: [ + { provider: "xai", model: "grok-4.5" }, + ], + }, + }; + return config; +} + +describe("combo path encrypted agent task recovery", () => { + beforeEach(() => { + resetAgentTaskRecoveryState(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + resetAgentTaskRecoveryState(); + }); + + test("recovers a ciphertext-only spawn before the combo payload gate", async () => { + const config = comboConfig(); + const fetchedUrls: string[] = []; + globalThis.fetch = (async (input) => { + const url = String(input); + fetchedUrls.push(url); + if (url.includes("chatgpt.com")) { + return new Response(recoverySse("plaintext assignment"), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + } + return providerResponse(); + }) as typeof fetch; + + const response = await post(config, "combo/fast", encryptedInput(), codexHeaders()); + + expect(response.status).toBe(200); + expect(fetchedUrls[0]).toContain("chatgpt.com/backend-api/codex"); + // One recovery call + one provider dispatch: recovery never repeats per child attempt. + expect(fetchedUrls).toHaveLength(2); + }); + + test("still fails closed when recovery does not produce a readable task", async () => { + const config = comboConfig(); + globalThis.fetch = (async () => new Response("{}", { status: 500 })) as typeof fetch; + + const response = await post(config, "combo/fast", [ + ...agentMessage([ + { type: "input_text", text: "Message Type: NEW_TASK\nTask name: /root/worker\nSender: /root\nPayload:" }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ]), + ], codexHeaders()); + + expect(response.status).toBe(400); + const payload = await response.json() as { error?: { code?: string } }; + expect(payload.error?.code).toBe("unreadable_encrypted_agent_task"); + }); +}); diff --git a/tests/agent-task-recovery.test.ts b/tests/agent-task-recovery.test.ts index 78647e1075..474c395925 100644 --- a/tests/agent-task-recovery.test.ts +++ b/tests/agent-task-recovery.test.ts @@ -551,7 +551,7 @@ describe("agent task recovery (opt-in, default off)", () => { expect(forwardedBody).not.toContain("capture_assignment"); }); - test("does not enable recovery inside combo attempts", async () => { + test("fails closed after a single failed combo recovery pass", async () => { const config = routedConfig(); config.combos = { routed: { @@ -559,10 +559,11 @@ describe("agent task recovery (opt-in, default off)", () => { targets: [{ provider: "xai", model: "grok-4.5" }], }, }; - let fetchCalls = 0; - globalThis.fetch = (async () => { - fetchCalls += 1; - throw new Error("combo must fail before dispatch"); + const fetchedUrls: string[] = []; + globalThis.fetch = (async (input) => { + const url = String(input); + fetchedUrls.push(url); + throw new Error("every upstream call must fail"); }) as typeof fetch; const response = await post( @@ -573,7 +574,10 @@ describe("agent task recovery (opt-in, default off)", () => { ); expect(response.status).toBe(400); - expect(fetchCalls).toBe(0); + // Exactly one recovery pass runs up front; failed recovery never re-runs per + // combo child attempt and no provider dispatch happens. + expect(fetchedUrls).toHaveLength(1); + expect(fetchedUrls[0]).toContain("chatgpt.com/backend-api/codex/responses"); expect(await response.json()).toMatchObject({ error: { code: "unreadable_encrypted_agent_task" }, }); From 1d8e35462a1743cb33845266949ae31e493cbb81 Mon Sep 17 00:00:00 2001 From: Eran Date: Sat, 29 Aug 2026 02:06:42 +0800 Subject: [PATCH 2/2] fix(release): bump package.json ahead of the newest release tag --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fd0601a9fc..d623252890 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.34.0", + "version": "2.36.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs",