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", 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" }, });