Repository navigation
[Bug][Responses] Fernet-shaped agent plaintext is preserved as ciphertext and poisons native replay #5236
Description
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
- Run OpenCodex 2.59.0 with Codex App, a native ChatGPT parent (
gpt-5.6-sol), and a routedopenai-chatchild. - Have the routed child call
collaboration.send_messagewith an ordinary long alphanumeric message beginning withgAAAA.... - The Codex client persists/replays that message as
agent_message.content[].encrypted_content. - Send the next turn through the native ChatGPT Responses route.
- Observe HTTP 502 / stream termination with
Encrypted function output content could not be decrypted or decoded. - Observe no opaque-blob recovery (
sendCount=1,recoveryKinds=[]). - 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 matchingencrypted_contentpart.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 watcheserror,response.failed, andresponse.incomplete; the observed WebSocket failure did not enter this recovery path.
Related but not duplicate:
- #3021 fixed genuine encrypted sub-agent
MESSAGEcontent being surfaced as visiblegAAAA...text afteradapter_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_messagehistory 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_messagehistory. - 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:
- A 195-character Base64-like
agent_messagepayload remains plaintext or is omitted; it is never trusted as ciphertext from shape alone. - Genuine backend-minted ciphertext from trusted provenance remains byte-identical.
- A WebSocket
error,server_error,response.failed, or terminal close carrying the exact decrypt rejection triggers at most one sanitize-and-retry. - A sanitized retry cannot loop indefinitely.
- 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
github-actions commentedon Sep 20, 2026
Issue reopened
The report now contains the information required by the automated check. Thanks for updating it.
Ingwannu commentedon Sep 20, 2026
Confirmed on current
dev(2a2c01a7). The deterministic false positive is real:sanitizeEncryptedContentInPlace()protects any slot accepted by the very looselooksLikeBackendCiphertext(), while the same module's canonical Fernet validator rejects the supplied 195-character payload.The fix needs two layers:
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.
Ingwannu commentedon Sep 20, 2026
Fix PR #5239 is now open against
devat exact head237178376925e52907910d2eeb1b5d846b142b5c.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
detailrejection 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.lidge-jun commentedon Sep 20, 2026
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.