From 6c19488e7818784efa6e605bee7d55e7d5d8cbd2 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 2 Sep 2026 10:57:59 +0900 Subject: [PATCH] fix(subagents): let encrypted-task recovery run before the synthesized native chain #3239 synthesized a DEFAULT_SUBAGENT_MODELS chain for an unreadable encrypted spawn when the operator configured none. That chain fires in the first fallback pass, before recoverEncryptedAgentTask, so with agentTaskRecovery enabled the spawn was rerouted to native gpt-5.5 and recovery was skipped along with its caller-auth, proxy-secret and token-validity gates. tests/agent-task-recovery-security.test.ts went 13/13 -> 2/13 on dev. Synthesize the chain only when recovery is not enabled. An operator who enabled recovery chose to decrypt and stay routed; a configured chain keeps its precedence either way. Regression: recovery enabled + no chain + nativeFallbackOnly -> no fallback (red without the guard); the 13 recovery security cases are green again. --- src/codex/subagent-model-fallback.ts | 8 ++++++++ tests/subagent-model-fallback.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/codex/subagent-model-fallback.ts b/src/codex/subagent-model-fallback.ts index ddd5b67e60..7110d4cf50 100644 --- a/src/codex/subagent-model-fallback.ts +++ b/src/codex/subagent-model-fallback.ts @@ -614,7 +614,15 @@ export function applySubagentModelFallback( : resolvedFallbackChain; // Native-only encrypted V2 tasks need a readable ChatGPT backend even when the // operator configured no fallback chain. Keep ordinary routed spawns unchanged. + // + // Not when encrypted-task recovery is enabled: that operator chose to decrypt the + // assignment and stay on the routed model. The synthesized chain would reroute the + // spawn to native in this first pass, before recovery runs, and recovery's own + // caller-auth / proxy-secret / token-validity gates would never execute + // (tests/agent-task-recovery-security.test.ts went 13/13 -> 2/13 when #3239 landed + // without this guard). A configured chain keeps its existing precedence. const fallbackChain = configuredFallbackChain === null && nativeFallbackOnly + && config.agentTaskRecovery?.enabled !== true ? normalizedChain(parsed.modelId, config, [], DEFAULT_SUBAGENT_MODELS) : configuredFallbackChain; if (!fallbackChain) return null; diff --git a/tests/subagent-model-fallback.test.ts b/tests/subagent-model-fallback.test.ts index d600072798..510c294d80 100644 --- a/tests/subagent-model-fallback.test.ts +++ b/tests/subagent-model-fallback.test.ts @@ -1079,6 +1079,33 @@ describe("subagent model fallback chain", () => { expect(parsed.modelId).toBe("gpt-5.5"); }); + test("the synthesized native chain yields to enabled encrypted-task recovery", () => { + // With recovery on, the first fallback pass must leave the routed spawn alone so + // recoverEncryptedAgentTask runs (and its security gates fire); rerouting here + // would bypass them. + const config = cfg({ + subagentModelFallback: undefined, + defaultProvider: "xai", + agentTaskRecovery: { enabled: true }, + }); + const parsed = { + modelId: "xai/grok-4.5", + options: {}, + context: { messages: [] }, + _rawBody: { model: "xai/grok-4.5" }, + }; + const result = applySubagentModelFallback( + parsed as never, + new Headers({ "x-openai-subagent": "collab_spawn" }), + config, + "pool-a", + Date.now(), + true, + ); + expect(result).toBeNull(); + expect(parsed.modelId).toBe("xai/grok-4.5"); + }); + test("applySubagentModelFallback is a no-op for main turns", () => { updateAccountQuota("pool-a", 95); const parsed = {