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 = {