Skip to content

encrypted_content blocks (Codex multi-agent agent_message) pass through verbatim → invalid Chat Completions, upstream 400 #60

Closed

Description

@qcnhy

Summary

agent_message input items (Codex desktop multi-agent) can carry encrypted_content content blocks. codex-relay handles the agent_message item type but does not map the encrypted_content content block type — it falls through to the catch-all part.clone() in map_content_part, so the block is emitted verbatim as {type:"encrypted_content", encrypted_content:"..."} in the Chat Completions payload. Strict upstreams reject this unknown content type with HTTP 400, failing the whole request.

Environment

  • codex-relay 0.5.5
  • Codex desktop with multi-agent (sub-agent) sessions — a parent agent sends a NEW_TASK to a sub-agent via an agent_message item whose content includes an encrypted_content block carrying the task payload.

Root cause

src/translate.rs, map_content_part:

fn map_content_part(part: &Value) -> Value {
    let kind = part.get("type").and_then(|t| t.as_str()).unwrap_or("");
    match kind {
        "input_text" | "text" | "output_text" => json!({"type": "text", "text": text}),
        "input_image" => { /* → image_url */ }
        "image_url" => { /* normalize */ }
        _ => part.clone(),   // ← encrypted_content lands here, passed through verbatim
    }
}

The relay already recognizes agent_message as an item type (Some("message" | "agent_message")) and there's a test test_agent_message_plaintext_input_reaches_chat_upstream — but that test only covers input_text content, not encrypted_content. The block-level translator has no arm for encrypted_content, so it's passed through unchanged.

The doc comment on value_to_chat_content states the pass-through is intentional ("Unknown part types pass through; the upstream may reject them and the relay propagates that error as-is"), but encrypted_content isn't a random unknown type — it's the primary content block of the agent_message item type the relay already supports, so the inconsistency produces invalid Chat Completions output for a supported item type.

Example input item:

{
  "type": "agent_message",
  "id": "amsg_...",
  "author": "/root",
  "recipient": "/root/sol_test",
  "content": [
    {"type": "input_text", "text": "Message Type: NEW_TASK\nTask name: /root/sol_test\nSender: /root\nPayload:"},
    {"type": "encrypted_content", "encrypted_content": "List files in current directory."}
  ]
}

Observed behavior

Upstream (Zhipu GLM coding endpoint, Chat Completions) returns HTTP 400:

{"error":{"code":"1214","message":"messages[2].content[1].type类型错误"}}

(messages[2].content[1] is the {type:"encrypted_content", ...} block that survived translation.) Every multi-agent request that includes an encrypted_content block fails this way; the sub-agent never gets a response.

Expected behavior

encrypted_content should map to a valid Chat Completions content part. In Codex multi-agent the encrypted_content field carries the task text (observed as plaintext, e.g. "List files in current directory."), so mapping it to text is what makes the sub-agent functional:

"encrypted_content" => {
    let text = part.get("encrypted_content").and_then(|t| t.as_str()).unwrap_or("");
    json!({"type": "text", "text": text})
}

If the field can ever be a truly opaque/non-text payload, dropping the block is also acceptable — but the current pass-through that 400s the entire request is the worst of the options.

Workaround

Flatten agent_message → message and encrypted_content → input_text in a proxy layer before handing the request to codex-relay. Verified: after flattening, the same multi-agent traffic that consistently 400'd now streams successfully.

Activity

  1. CCanxue commented on Aug 14, 2026

    @CCanxue

    Confirmed on codex-cli 0.146.0 with DeepSeek: for a non-OpenAI parent the encrypted_content block actually contains the plaintext task, so mapping it to a text part is sufficient for that topology. For an OpenAI parent the value is real Fernet ciphertext (gAAAAA...) and no block-level mapping can recover the task; that case needs the upstream delivery fix (#36376 / #37197). For the non-OpenAI-parent case we deliver V2 tasks as plain user messages in the core instead of at the relay: https://github.com/CCanxue/codex-deepseek-subagent-fix

  2. mountain commented on Aug 14, 2026

    @mountain
    Collaborator

    Maintainer status update

    Thank you for the detailed report and for the additional confirmation in the comment above. The observed behavior is valid, but there are two different transport cases hidden behind the same encrypted_content shape.

    What is confirmed

    • With codex-relay 0.5.5, an encrypted_content part could pass through to Chat Completions unchanged and cause the upstream 400 described here.
    • With a non-OpenAI parent on older Codex builds (including the reported Codex 0.146 case), the value inside encrypted_content may in fact be the plaintext task. Rewriting that particular value to text can make the reported GLM/DeepSeek topology work.
    • With an OpenAI Responses parent, however, the same field can contain genuine opaque ciphertext (gAAAAA...). A relay or downstream Chat Completions provider cannot decrypt it. Rewriting it as text would expose opaque provider state to the model and would not recover the task. This is the unresolved producer/delivery problem tracked in MultiAgentV2 cross-provider subagent cannot consume encrypted task assignment openai/codex#34833, #36376, and #37197.

    These observations are not contradictory: the container has the same name, but its payload depends on the parent provider, Codex version, and delivery path.

    Why the proposed unconditional mapping conflicts with the current safety model

    The original version of #43 implemented essentially the mapping proposed here. It was changed during security review because treating every encrypted_content value as plaintext is not protocol-safe.

    The merged solution in #43 instead uses Codex's plaintext negotiation path from openai/codex#35845: codex-relay adds encrypted_function_args: [] only to request-scoped collaboration.spawn_agent, send_message, and followup_task calls. A compatible Codex then delivers the child task as plaintext input_text.

    The follow-up in #52 established the complementary fail-closed invariant: an agent_message is forwarded only when its content is provably plaintext text. Opaque or encrypted agent messages are rejected locally rather than copied to a third-party backend. #56 further ensures that the collaboration identity comes from the declared request-scoped tool map and cannot be gained through an undeclared or ambiguous tool name.

    Consequently, an unconditional encrypted_content -> text conversion would reverse the central safety decision of #43/#52 and cannot be accepted as a general fix.

    Current behavior

    Starting with codex-relay 0.5.6, the exact 0.5.5 failure mode in this issue has changed:

    • the relay no longer forwards encrypted agent_message content and waits for a strict upstream to return 400;
    • it returns a local 422 before the Chat Completions request is sent;
    • plaintext agent_message content using input_text remains supported.

    This is safer, but it means older Codex/non-OpenAI-parent configurations where plaintext was placed inside an encrypted_content wrapper are currently rejected. That is a real compatibility gap, distinct from the unsafe pass-through originally reported.

    Proposed next discussion

    Please keep this issue open while we determine whether that older compatibility case should be supported. A potentially safe compatibility path would need request-scoped evidence rather than payload-shape heuristics—for example, matching the recipient/task and requiring the wrapped value to equal the plaintext message from the corresponding pending collaboration call. Missing, ambiguous, or non-matching cases would still fail closed.

    To scope that correctly, it would help to have the following from affected users:

    1. exact Codex CLI/Desktop version;
    2. parent provider and child provider;
    3. whether both parent and child requests pass through codex-relay;
    4. behavior when retested with codex-relay 0.5.7;
    5. whether the parent function-call item contains encrypted_function_args: [], null, or omits it.

    Until that evidence is available, the current recommendation is to retain the fail-closed behavior and not infer plaintext merely because the value does not look like Fernet ciphertext.

  3. qcnhy commented on Aug 15, 2026

    @qcnhy
    Author

    Providing the requested evidence from the topology in this report (non-OpenAI Chat Completions upstream via codex-relay):

    1. Codex version: 0.146.1, standalone install, multi-agent v2 (/home/qcnhy/.codex/packages/standalone/releases/0.146.1-x86_64-unknown-linux-musl/bin/codex, confirmed from a live sandbox process cmdline). This is the only version that has ever been installed on this host, so the 0.5.5 failure reproduced on 0.146.1 specifically.

    2. Parent/child providers: parent = Zhipu GLM (open.bigmodel.cn coding endpoint, Chat Completions); spawned sub-agents = the same GLM endpoint. No OpenAI provider anywhere in the chain.

    3. Both through relay: yes — parent and child requests all go local proxy → codex-relay (0.5.5) → GLM Chat Completions.

    4. Retest with 0.5.7: not directly applicable in our current setup — we pinned 0.5.5 and flatten agent_message/encrypted_content in the proxy layer before the relay (the workaround in the issue description), so the relay no longer sees these items and never hits the 0.5.6+ fail-closed path. We have not retested raw (non-flattened) traffic against 0.5.6/0.5.7.

    5. encrypted_function_args: not present in any of our retained captures — but our dumps are the relay's translated Chat Completions payloads (request direction), which would not carry it; the original Responses input was lost to log rotation before we could check the parent function-call item directly. We can capture a fresh raw input dump on the next multi-agent session if that would help.

    One additional observation for the compatibility decision:

    • On 0.146.1 with a non-OpenAI parent, NEW_TASK agent_message items arrive with exactly this shape:
      content: [
        {"type": "input_text", "text": "Message Type: NEW_TASK\nTask name: /root/<agent>\nSender: /root\nPayload:"},
        {"type": "encrypted_content", "encrypted_content": "<plaintext task>"}
      ]
      
      where the wrapped value is verbatim the plaintext task (observed e.g. "List files in current directory."), consistent with @CCanxue's confirmation. So for this topology the request-scoped verification you describe (wrapped value equals the plaintext message of the corresponding pending collaboration call) should be satisfiable, while missing/ambiguous/non-matching cases can still fail closed — the legacy shape looks bounded and verifiable rather than heuristic.

    Agreed on keeping fail-closed as the default; from our side the proxy-layer flattening remains stable (multi-agent sessions stream normally through 0.5.5 with it), so this is about whether raw relay users on the same legacy shape get a path forward, not an urgent break for us.

  4. mountain commented on Aug 16, 2026

    @mountain
    Collaborator

    Thanks — this narrows the compatibility case substantially. The 0.146.1 / GLM→GLM / both-sides-through-relay topology is exactly the legacy path that still needs to be distinguished from genuine opaque ciphertext.

    A fresh capture would be useful. Please use codex-relay 0.5.7 without the proxy-layer flattening and a harmless synthetic task, then provide the following two raw Responses-side items from the same spawn (before relay translation):

    1. the parent spawn_agent function-call item, including call_id, name, arguments, and whether encrypted_function_args is [], null, or absent;
    2. the resulting child agent_message item, including id, author, recipient, and the complete content part structure.

    Please also include the local 422 response produced by 0.5.7. Tokens, headers, prompts unrelated to the synthetic task, and other sensitive fields can be removed. Keeping the synthetic task_name, plaintext message, recipient, and wrapped encrypted_content value intact is important because those are the fields needed to prove an unambiguous request-scoped match.

    With that pair, the compatibility rule can be evaluated against a concrete invariant: accept only when one pending declared collaboration call matches the recipient/task and its plaintext message exactly equals the wrapped value; otherwise retain the current fail-closed behavior.

  5. qcnhy commented on Aug 16, 2026

    @qcnhy
    Author

    Captured as requested — relay 0.5.7, proxy-layer flattening fully disabled (both agent_message handling and namespace flattening), fresh multi-agent session, harmless synthetic task ("list the files in the current directory").

    1. Parent spawn_agent function-call item (as echoed in the parent's next request input, i.e. the form the relay returned and Codex accepted):

    {
      "type": "function_call",
      "id": "fc_01a00b1d-95e7-7492-beb2-e38a50114ed0",
      "name": "spawn_agent",
      "namespace": "collaboration",
      "call_id": "call_442fde489fa6422ca3e53b35",
      "arguments": "{\"fork_turns\":\"none\",\"message\":\"请列出当前目录(/home/qcnhy)下的文件,返回文件列表即可。\",\"task_name\":\"list_files\"}"
    }

    encrypted_function_args is absent — not [], not null.

    2. Resulting child agent_message item (from the child's first request input):

    {
      "type": "agent_message",
      "id": "amsg_01a00b22-b341-7cf1-bb87-5ceffa28054b",
      "author": "/root",
      "recipient": "/root/list_files",
      "content": [
        {"type": "input_text", "text": "Message Type: NEW_TASK\nTask name: /root/list_files\nSender: /root\nPayload:\n"},
        {"type": "encrypted_content", "encrypted_content": "请列出当前目录(/home/qcnhy)下的文件,返回文件列表即可。"}
      ]
    }

    3. Local 422 from 0.5.7 (identical from every relay channel; plain-text body):

    encrypted agent_message content cannot be forwarded to a Chat Completions upstream
    

    4. Request-scoped match check — the invariant you proposed holds exactly in this capture:

    • spawn_agent.arguments.task_name ("list_files") ↔ agent_message.recipient ("/root/list_files")
    • spawn_agent.arguments.message byte-equals the wrapped encrypted_content value

    So on this legacy topology (Codex 0.146.1, non-OpenAI parent, GLM→GLM through the relay) the wrapped value is verbatim the pending call's plaintext message and the recipient is derived from task_name — the compatibility rule is decidable from request-scoped evidence alone, no payload-shape heuristics involved.

    One incidental finding possibly relevant to #62: with our proxy flattening namespaces into {namespace}-{name} top-level tools (the workaround described in #61), Codex 0.146.1 rejected the flattened function form with unsupported call: collaboration-spawn_agent. Function calls inside a namespace must round-trip as {"name": "spawn_agent", "namespace": "collaboration"} — the bare-vs-namespaced asymmetry noted in #61 for custom children applies to function children too, in the opposite direction. Your #62 handling of this (namespaced encoding preserved for function children, bare names for custom children) matches what the client actually accepts.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions