Skip to content

[Regression] Mixed encrypted agent_message history bypasses routed Responses fail-closed #4454

Description

@321sssrt-bit

Client or integration

Codex App / Codex CLI

Provider or upstream service

xAI OAuth / Grok 4.6

OpenCodex version

@bitkyc08/opencodex 2.52.0

Endpoint or capability

POST /v1/responses; routed Responses adapter and multi-agent history replay

Current behaviour

OpenCodex 2.52.0 can forward a historical agent_message item to a non-forward Responses destination when the item contains mixed readable and backend-encrypted content. xAI/Grok rejects the request with HTTP 422:

Failed to deserialize the JSON body into the target type:
input[5]: unknown item type "agent_message";
expected one of: message, reasoning, function_call, function_call_output,
shell_call, shell_call_output, web_search_call, file_search_call,
code_interpreter_call, mcp_call, custom_tool_call, image_generation_call, compaction

The child work and initial routed requests can succeed. After a sub-agent result is recorded in the parent history, later requests repeatedly fail at the upstream schema boundary. The failing input index changes as the history grows (input[5], input[8], and so on).

The local usage log contains 60 recent xai/grok-4.6 HTTP 422 records and 55 HTTP 200 records. All 422 records select provider=xai, model=grok-4.6, requestedModel=xai/grok-4.6, inboundProtocol=responses, routeKind=explicit-provider. No request body or ciphertext is included here.

A failing historical item has this redacted shape:

{
  "type": "agent_message",
  "author": "/root/child",
  "recipient": "/root",
  "content": [
    { "type": "input_text", "text": "visible child result or routing text" },
    { "type": "encrypted_content", "encrypted_content": "<backend ciphertext redacted>" }
  ]
}

The item is not necessarily the last element of input; it can be followed by later user messages and tool items. Metadata-only inspection of response-state spill files around the failures confirmed content part types input_text + encrypted_content.

Expected behaviour

For a non-forward Responses destination, no private agent_message item with unsupported content should be sent upstream.

If a historical encrypted result cannot be safely recovered, OpenCodex should fail closed before provider dispatch with a structured error and must not echo or log the ciphertext. Plaintext agent-message conversion, native ChatGPT forward passthrough, and the current encrypted NEW_TASK trust/recovery boundaries should remain unchanged.

Minimal redacted request or reproduction

  1. Start a fresh Codex App task using xai/grok-4.6 with the V2 collaboration surface.
  2. Let a sub-agent run and return a result.
  3. Continue the parent task so the result is replayed in the next /v1/responses request.
  4. Observe HTTP 422 with unknown item type agent_message.
  5. Inspect only item metadata and confirm a non-tail agent_message with content part types input_text + encrypted_content.

The local proxy endpoint shown to the client is http://127.0.0.1:10100/v1/responses. The upstream xAI destination and all credentials are omitted.

Actual response or error

HTTP 422 Unprocessable Entity
unexpected status 422 Unprocessable Entity:
Failed to deserialize the JSON body into the target type:
input[5]: unknown item type "agent_message"

Upstream documentation

xAI documents standard Responses input and tool-result items, but does not document Codex's private agent_message item type:

The expected behaviour is also a concrete Codex client requirement: a routed parent must be able to consume a completed child result without sending a provider-private item.

Suggested mapping or implementation notes

The current path is:

  1. src/adapters/openai-responses.ts calls normalizeRoutedAgentMessages() for every non-forward provider.
  2. src/adapters/routed-agent-messages.ts rewrites an agent_message only when every content part is input_text, input_image, or input_file. If encrypted_content or an unknown part is present, it returns the original private item unchanged by design.
  3. src/server/responses/encrypted-payload.ts hasUnreadableEncryptedAgentTask() inspects only the trailing agent_message. This protects the current NEW_TASK envelope but intentionally ignores historical agent messages.
  4. src/server/responses/core.ts computes the unreadable-task flag before expandPreviousResponseInput() and before the later sanitization step. A historical item inserted/replayed later, or any non-tail item, can therefore reach the adapter with unreadableEncryptedAgentTask=false.
  5. The adapter serializes the unchanged agent_message and strict xAI Responses validation returns HTTP 422 instead of a bounded OpenCodex error.

This is a coverage gap after #3907/#3911, which fixed complete readable agent_message conversion. #92 and #3661 cover encrypted NEW_TASK delivery/recovery, not historical child-result replay.

A narrow fix would add a shared routed-replay classification after expandPreviousResponseInput() and before adapter dispatch:

  • Fully representable plaintext agent_message: use the existing public message conversion.
  • Current unreadable encrypted NEW_TASK: keep the existing recovery/fail-closed path.
  • Historical or mixed encrypted agent_message: detect explicitly. The safe default is a bounded structured failure (for example, a distinct historical_encrypted_agent_message reason) rather than a provider-specific 422. If maintainers choose an opt-in lossy compatibility mode, retain visible plaintext parts, replace encrypted parts with a fixed placeholder, and then lower the item to a public message; never attempt transparent decryption of historical child results.

Add a regression test with a mixed agent_message in a non-tail position after expandPreviousResponseInput(). Assert that no private agent_message reaches a routed Responses adapter, and that either a public message is emitted or the structured fail-closed error is returned. Keep authMode=forward and complete plaintext messages unchanged. Ensure ciphertext is not logged, cached as plaintext, or returned to the client.

An adjacent edge is also visible in the current helper: an empty agent_message (content: []) is returned unchanged and can still produce the same upstream 422.

Additional context and attachments

Related issues:

This report is narrower than #3907/#3911: it covers encrypted mixed historical replay that still bypasses the fail-closed guard after those fixes.

The Codex runtime selected by ocx doctor is 0.151.0; 0.154.0-alpha.6.2 is also present on the host. This version mismatch is recorded for context, but the 422 is directly correlated with the mixed content shape and the adapter branch above.

Checks

  • I searched existing provider and compatibility issues.
  • The request and response were redacted.
  • The expected behaviour is based on an upstream specification or a concrete client requirement.

Activity

  1. github-actions commented on Sep 13, 2026

    @github-actions
    Contributor

    Issue reopened

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

  2. lidge-jun commented on Sep 13, 2026

    @lidge-jun
    Owner

    리뷰 · 우선순위 73 / 80

    설명

    이 이슈는 암호화가 섞인 과거 agent_message가 routed Responses fail-closed를 우회해, xAI 같은 외부 Responses가 422 unknown item type "agent_message"로 죽는 회귀입니다. #3907/#3911은 완전 평문 child-result만 public message로 바꿨습니다. 지금은 평문 파트와 encrypted_content가 한 아이템에 같이 있거나, NEW_TASK가 아닌 히스토리 중간에 남아 있으면 변환도 안 되고 가드도 못 봅니다. 보고자 로그의 Grok/xAI OAuth Responses에서 반복 422가 나와 설득력 있습니다.

    지금 dev(HEAD 261bab915) 코드와 읽기가 일치합니다. src/adapters/routed-agent-messages.ts의 normalizeRoutedAgentMessages는 content의 모든 파트가 input_text/input_image/input_file일 때만 바꿉니다. encrypted_content나 빈 배열이면 원본 private item을 그대로 둡니다. src/server/responses/encrypted-payload.ts의 hasUnreadableEncryptedAgentTask는 꼬리 agent_message(compaction_trigger/additional_tools 건너뛴 뒤)만 봅니다. 히스토리 중간·비꼬리 mixed 아이템은 unreadableEncryptedAgentTask=false로 남아 어댑터까지 갑니다. authMode=forward는 이 함수에 안 들어오므로 그대로 두면 됩니다. #92/#3661은 NEW_TASK 전달/복구 경계이고, 이번 보고는 과거 mixed replay coverage gap이라 별도입니다. #4351 plaintext V2 opt-in과도 겹치지만, 기본 fail-closed를 뚫는 구멍이 우선입니다.

    고침 제안도 타당합니다. expandPreviousResponseInput() 뒤·어댑터 앞의 공유 분류: (1) 완전 표현 가능한 평문 → 기존 public 변환, (2) 현재 꼬리 암호화 NEW_TASK → 기존 복구/fail-closed, (3) 역사적/mixed 암호화 → 명시적 structured failure(예: historical_encrypted_agent_message). 손실 호환 모드를 쓰더라도 암호 복호화 없이 평문만 남기고 placeholder로 내리는 opt-in이어야 합니다. 빈 content: []도 같은 422 후보로 같이 막으세요.

    경로 src/adapters/routed-agent-messages.ts normalizeRoutedAgentMessages - mixed/unknown part에서 return item이 곧 upstream 422입니다. “변환 불가”를 호출자에게 구분 신호로 올리거나, 상위에서 가로채야 합니다. 지금처럼 조용히 통과시키면 안 됩니다.

    경로 src/server/responses/encrypted-payload.ts hasUnreadableEncryptedAgentTask - 꼬리만 보는 설계는 NEW_TASK에 맞습니다. 역사 아이템용 별도 분류 함수를 두고, 꼬리 가드와 책임을 섞지 마세요.

    경로 src/server/responses/core.ts - unreadable 플래그를 expand 전에만 계산하면 나중에 삽입/재생된 아이템이 빠집니다. expand 이후·dispatch 이전에 한 번 더 분류하는 위치가 이슈 본문과 맞습니다.

    회귀 테스트 - 비꼬리 mixed agent_message fixture로 “private item이 routed adapter 입력에 안 남는지”와 structured error(또는 공개 메시지)를 고정하세요. ciphertext를 로그/캐시/클라에 평문으로 남기지 마세요.

    메인테이너의 판단이 필요한 지점

    • 기본을 항상 structured fail-closed로 할지, 손실 placeholder 변환을 opt-in으로 둘지
    • 빈 agent_message를 같은 reason으로 묶을지 별도 reason으로 둘지
    • #3661 복구 스택과 이슈를 한 플랜에 묶을지, 좁은 replay 가드만 먼저 할지
    • xAI만이 아니라 모든 non-forward Responses에 동일 적용할지(코드상 이미 그 범위)

    너의 추천
    provider-compatibility로 두고, 첫 PR은 expand 이후 mixed/historical 분류 + fail-closed(또는 명시 opt-in) + 비꼬리 fixture만으로 좁히세요. #3907/#3911을 다시 열지 말고, 이 이슈를 그 coverage gap의 SSOT로 두세요. types/config 분할 무관입니다.

    이 댓글은 grok-bot이 작성했습니다

  3. added 2 commits that reference this issue on Sep 13, 2026
  4. lidge-jun commented on Sep 13, 2026

    @lidge-jun
    Owner

    Fixed on dev via #4498 (merge commit 8e6c996, verified as an ancestor of origin/dev).

    Your report was precise about the shape, and the shape was the whole defect. Two checks bounded the private agent_message item and neither covered the gap between them: hasUnreadableEncryptedAgentTask inspects only the tail item and reports readable the moment any plaintext survives, while normalizeRoutedAgentMessages forwards the item verbatim when a content part cannot be lowered onto a public message. An item mixing input_text with encrypted_content answers readable to the first question and not-lowerable to the second, so it reached the wire as backend ciphertext plus an item type only the Codex backend declares.

    Position turned out to be incidental. A replayed sub-agent result sits mid-history where a tail-only scan cannot see it, which is how you hit it, but the tail is exposed the same way once it is mixed.

    The fix applies a repair this repository already had. prepareOpaqueBlobRecovery replaces an undecryptable part with a marker, and it ran after an upstream rejection — a destination that can never accept the private item was never going to answer that request, so the round trip only served to send the ciphertext. It now runs before dispatch, against the final route.

    Three rounds of security review changed the fix before it landed, which is worth recording here because two of the rounds found the same defect wearing different payloads: combo children were bypassing the repair on their own cloned body, and the matcher initially required a well-formed Fernet token, so a truncated or standard-base64 blob would have taken your exact path one payload later. The landed version strips an encrypted_content slot whatever it holds, and matches free text strictly so a digest a sub-agent deliberately printed is not destroyed to protect bytes that were never secret. The canonical Codex backend still receives the item untouched.

    Closing manually because this merges into dev rather than the default branch.

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

    providerProvider adapters, OpenAI-compat presets, upstream API quirksprovider-compatibilityProvider compatibility reports

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions