Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
33 changes: 31 additions & 2 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand All @@ -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();
Expand Down
75 changes: 75 additions & 0 deletions tests/agent-task-recovery-combo.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof routedConfig> {
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");
});
});
16 changes: 10 additions & 6 deletions tests/agent-task-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -551,18 +551,19 @@ 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: {
strategy: "failover",
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(
Expand All @@ -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" },
});
Expand Down
Loading