Skip to content

[Bug][Responses] Fernet-shaped agent plaintext is preserved as ciphertext and poisons native replay #5236

Closed

Description

@wuliaolaozi

Client or integration

Codex App

Area

Proxy and routing

Summary

A routed sub-agent returned an ordinary 195-character string through collaboration.send_message.message. It began with gAAAA... and used only Base64-compatible characters, but it was model-generated text rather than backend-minted ciphertext. The Codex client later replayed that value inside agent_message.content[].encrypted_content.

On the next native ChatGPT Responses request, OpenCodex preserved the value because looksLikeBackendCiphertext() currently accepts any string whose length is at least 64 and whose characters match /^[A-Za-z0-9+/=_-]+$/. The ChatGPT backend rejected the unauthentic value with Encrypted function output content could not be decrypted or decoded. The existing opaque-blob recovery did not sanitize and replay this WebSocket failure (sendCount=1, recoveryKinds=[]), so a later turn in the same thread failed again.

Expected: agent-originated plaintext must not be preserved as backend ciphertext solely from its appearance. If an unverifiable agent-message ciphertext still reaches the backend, the exact decrypt rejection should trigger one bounded sanitize-and-retry attempt instead of leaving the thread poisoned.

Observed on installed OpenCodex 2.59.0. The relevant sanitizer and recovery logic is still present in development version 2.60.0 at commit e64d699.

Reproduction

Deterministic unit-level reproduction

Construct an agent_message with a deliberately invalid 195-character Base64-like payload:

const fake = `gAAAAA${"A".repeat(189)}`;

const input = [{
  type: "agent_message",
  author: "/root/test",
  content: [{
    type: "encrypted_content",
    encrypted_content: fake,
  }],
}];

expect(fake.length).toBe(195);
expect(looksLikeBackendCiphertext(fake)).toBe(true);

// Current behavior: the part is preserved as ciphertext.
expect(sanitizeEncryptedContentInPlace(input)).toBe(0);
expect(input[0].content[0].type).toBe("encrypted_content");

The existing key-independent Fernet validator rejects this value because it is not a canonical Fernet wire token. Authenticity still cannot be established from structure alone, so provenance should be the primary trust boundary.

Observed integration path

  1. Run OpenCodex 2.59.0 with Codex App, a native ChatGPT parent (gpt-5.6-sol), and a routed openai-chat child.
  2. Have the routed child call collaboration.send_message with an ordinary long alphanumeric message beginning with gAAAA....
  3. The Codex client persists/replays that message as agent_message.content[].encrypted_content.
  4. Send the next turn through the native ChatGPT Responses route.
  5. Observe HTTP 502 / stream termination with Encrypted function output content could not be decrypted or decoded.
  6. Observe no opaque-blob recovery (sendCount=1, recoveryKinds=[]).
  7. Send another user turn in the same thread and observe the same failure because the invalid part remains in replay history.

No real ciphertext, account identifier, token, API key, private prompt, or request credential is required for this reproduction.

Version

Installed: 2.59.0; also inspected dev 2.60.0 at e64d699

Operating system

macOS 15.8 (24H23)

Provider and model

Native openai / gpt-5.6-sol parent; routed openai-chat / Grok 4.6 child

Logs or error output

HTTP 502
stream disconnected before completion: Encrypted function output content could not be decrypted or decoded.
sendCount=1
recoveryKinds=[]

Screenshots and supporting files

Relevant current code:

  • src/server/responses/encrypted-payload.ts:100-102: looksLikeBackendCiphertext() accepts any sufficiently long Base64-like string.
  • src/server/responses/encrypted-payload.ts:483-545: sanitizeEncryptedContentInPlace() preserves a matching encrypted_content part.
  • src/server/responses/request-prepare.ts:272-285: the sanitizer runs before native passthrough and intentionally preserves values classified as backend ciphertext.
  • src/server/responses/core-opaque-recovery.ts:180-198: the exact 502 is eligible only when the outbound body is detected as carrying encrypted function/agent output.
  • src/server/responses/passthrough-dispatch.ts:1448-1500: streamed recovery watches error, response.failed, and response.incomplete; the observed WebSocket failure did not enter this recovery path.

Related but not duplicate:

  • #3021 fixed genuine encrypted sub-agent MESSAGE content being surfaced as visible gAAAA... text after adapter_eof. This report is the inverse direction: agent-generated plaintext is mistaken for ciphertext and sent to the native backend.
  • #4454 covered mixed encrypted agent_message history reaching xAI and being rejected as an unknown item type. This report fails on the native ChatGPT path with a decrypt/decode 502.
  • #3661 concerns genuine native ciphertext that a routed V2 worker cannot recover. This report concerns unauthentic agent text sent to the native backend.
  • #4191 tracks generic mid-turn WebSocket fallback. It does not cover ciphertext classification or poisoned agent_message history.
  • PR #3535 targeted the same exact rejection message with sanitize-and-rebuild recovery, but it was closed without merge and did not address this false-positive classification trigger.

Suggested regression coverage:

  1. A 195-character Base64-like agent_message payload remains plaintext or is omitted; it is never trusted as ciphertext from shape alone.
  2. Genuine backend-minted ciphertext from trusted provenance remains byte-identical.
  3. A WebSocket error, server_error, response.failed, or terminal close carrying the exact decrypt rejection triggers at most one sanitize-and-retry.
  4. A sanitized retry cannot loop indefinitely.
  5. Subsequent turns do not resend the rejected part as ciphertext.

Redacted configuration

{
  "defaultProvider": "openai",
  "providers": {
    "routed-child": {
      "adapter": "openai-chat",
      "baseUrl": "<redacted>",
      "apiKey": "<redacted>"
    }
  }
}

Checks

  • I searched existing issues and documentation.
  • I removed secrets, tokens, account details, request credentials, and personal data.

Activity

  1. added
    proxyHTTP proxy, routing, reverse-proxy / management auth
    streamingSSE, WebSocket, terminal stream frames
    on Sep 20, 2026
  2. github-actions commented on Sep 20, 2026

    @github-actions
    Contributor

    Issue reopened

    The report now contains the information required by the automated check. Thanks for updating it.

  3. Ingwannu commented on Sep 20, 2026

    @Ingwannu
    Owner

    Confirmed on current dev (2a2c01a7). The deterministic false positive is real: sanitizeEncryptedContentInPlace() protects any slot accepted by the very loose looksLikeBackendCiphertext(), while the same module's canonical Fernet validator rejects the supplied 195-character payload.

    The fix needs two layers:

    1. Classification: provenance should be authoritative when OCX has it. A backend-minted encrypted part retained in response/replay state remains byte-identical; agent/model-originated text must never gain ciphertext authority from alphabet and length alone. For unknown legacy history, require at least canonical Fernet structure rather than the loose predicate, while recognizing that structure is not authenticity.
    2. Recovery: the exact decrypt/decode rejection must reach the same single-shot, request-budgeted sanitize-and-rebuild path for HTTP, SSE and WebSocket terminal/error envelopes. It must never replay after visible output or other billable effects, and the guard must stay armed across the rebuilt send.

    Required regressions should include the reported 195-byte false positive, a structurally valid but unauthenticated token, trusted backend provenance, restart/unknown legacy history, SSE and WS rejection shapes, and a second rejection proving no loop. The sanitized request and all later retries must contain no rejected encrypted part. Keeping this open as a verified replay-poisoning bug.

  4. Ingwannu commented on Sep 20, 2026

    @Ingwannu
    Owner

    Fix PR #5239 is now open against dev at exact head 237178376925e52907910d2eeb1b5d846b142b5c.

    It keeps ambiguous encoded slots opaque until the final route is known, applies canonical Fernet structure only on the native ChatGPT route, preserves third-party omission behavior, and adds the single-shot WebSocket/SSE detail rejection recovery. Focused regression suites pass 283/283; typecheck, structure checks, and the file-size ratchet are green locally in isolated homes. Hosted CI and @lidge-jun review are pending.

  5. lidge-jun commented on Sep 20, 2026

    @lidge-jun
    Owner

    Closing the reported replay-poisoning defect as completed by #5239, with the canonical survivor fixture corrected in #5246. Both are included in main at 7c625fc (2.60.0).

    Verified against that main source: encrypted-payload.ts now requires canonical key-independent Fernet structure for unknown native replay; request-prepare.ts retains the pre-route opaque hold and applies strict classification only after the canonical destination is known. The exact pre-commit SSE/WebSocket detail rejection reaches the existing single-shot, request-budgeted sanitize-and-rebuild path. Added regressions cover the reported 195-character false positive and the detail error envelope.

    Structure is not cryptographic authenticity, and this closeout does not claim a general post-output replay guarantee. Genuine routed ciphertext recovery (#3661), generic mid-turn WebSocket recovery (#4191), and separately wrapped gateway errors (#5245) keep their own tracking. This closeout used source/merged-change inspection; no local or live-model tests were run.

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 authstreamingSSE, WebSocket, terminal stream frames

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions