Skip to content

[Bug]: Mid-thread model switch from a native ChatGPT model to a routed provider dies with unreadable_encrypted_agent_task; agentTaskRecovery never runs because it is gated on threadSpawn #4089

Description

@practical-tools-lab

Area

Proxy and routing

What happened

Switching a live Codex Desktop thread from a native ChatGPT model (gpt-6-astra) to a routed provider model (anthropic/claude-fable-5-1) breaks the thread permanently with:

Routed V2 worker task is encrypted for the native ChatGPT backend and cannot be read by
the selected provider. Use plaintext V2 agent-message delivery or select a native ChatGPT model.

A thread that is started on the routed model never hits this. Only threads whose history already contains a backend-minted encrypted_content agent message do, and once present, every later turn replays it, so the thread can never be continued on the routed model.

The important part: agentTaskRecovery is enabled and still never runs on this path. Recovery is gated on isThreadSpawnRequest(req.headers), and a mid-thread model switch is not a thread spawn, so the request fails closed without any recovery attempt.

Reproduction (no auth, loopback only)

Two identical requests to a running proxy, differing only by the spawn header. The body is one agent_message carrying a routing header plus a structurally valid Fernet-shaped encrypted_content slot, targeting a routed provider.

# A) no spawn header  -> mid-thread model switch shape
curl -sS -X POST http://127.0.0.1:10100/v1/responses \
  -H 'Content-Type: application/json' -H 'originator: codex_cli_rs' \
  --data @probe.json

# B) same body + spawn header
curl -sS -X POST http://127.0.0.1:10100/v1/responses \
  -H 'Content-Type: application/json' -H 'originator: codex_cli_rs' \
  -H 'x-openai-subagent: collab_spawn' \
  --data @probe.json

Observed:

// A) no recovery_reason field at all -> recovery was never attempted
{"error":{"message":"Routed V2 worker task is encrypted ...","type":"invalid_request_error",
  "code":"unreadable_encrypted_agent_task"}}

// B) recovery_reason present -> recovery WAS attempted
{"error":{"message":"Routed V2 worker task is encrypted ...","type":"invalid_request_error",
  "code":"unreadable_encrypted_agent_task","recovery_reason":"unsupported_envelope"}}

The presence or absence of recovery_reason is the discriminator. unsupported_envelope in (B) only reflects the synthetic envelope used for the probe; the point is that (B) entered the recovery path and (A) did not.

Code pointers

  • src/server/responses/core.ts — the direct (non-combo) recovery block is gated on threadSpawn:
    inboundWire === "responses" && threadSpawn && agentTaskRecovery && !isCanonicalOpenAiForwardProvider(route.provider) && ...
  • src/server/effort-policy.tsisThreadSpawnRequest() is true only for x-openai-subagent: collab_spawn or turn metadata subagent_kind === "thread_spawn".
  • src/server/responses/core.tscanPassThroughEncryptedV2AgentTask() requires authMode === "key" plus allowEncryptedV2AgentTasks and an openai-responses wire, so an OAuth-mode routed provider has no passthrough option either.

Expected

A thread that can be continued should be continuable. Either the recovery path should be reachable for non-spawn requests that carry an unreadable encrypted agent task, or the client should be told the switch is impossible before the thread is bricked.

Suggested direction

  1. Let agentTaskRecovery run for any request whose input contains an unreadable encrypted agent envelope, not only thread-spawn requests. The admission checks in recoveryAdmission() (native ChatGPT bearer, matching chatgpt-account-id, Codex originator, no inbound API key) already bound the trust surface; threadSpawn looks like an additional narrowing rather than the security boundary.
  2. Alternatively, expose the failure earlier: when a thread's history contains backend-minted ciphertext, reject or warn on the model switch itself instead of failing every subsequent turn.
  3. restoreCachedEncryptedAgentTasks() is similarly gated today, so a proxy restart also loses any chance of reusing a previously recovered plaintext for that thread.

Environment

  • @bitkyc08/opencodex 2.48.0
  • macOS, Codex Desktop, routed provider is an OAuth-mode Anthropic-adapter provider on loopback
  • agentTaskRecovery = {"enabled": true, "model": "gpt-5.6-sol", "timeoutMs": 45000, "cacheEntries": 200}, verified live via the startup warning

Related

This report is about the non-spawn, same-thread model-switch path, which none of the above cover.

Workaround

Do not switch a native-model thread to a routed provider. Start a new thread on the routed model instead.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingproxyHTTP proxy, routing, reverse-proxy / management auth

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions