Repository navigation
Description
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_TASKto a sub-agent via anagent_messageitem whosecontentincludes anencrypted_contentblock 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
CCanxue commentedon Aug 14, 2026
Confirmed on codex-cli 0.146.0 with DeepSeek: for a non-OpenAI parent the
encrypted_contentblock 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-fixmountain commentedon Aug 14, 2026
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_contentshape.What is confirmed
encrypted_contentpart could pass through to Chat Completions unchanged and cause the upstream 400 described here.encrypted_contentmay in fact be the plaintext task. Rewriting that particular value to text can make the reported GLM/DeepSeek topology work.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_contentvalue 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-scopedcollaboration.spawn_agent,send_message, andfollowup_taskcalls. A compatible Codex then delivers the child task as plaintextinput_text.The follow-up in #52 established the complementary fail-closed invariant: an
agent_messageis 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 -> textconversion 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:
agent_messagecontent and waits for a strict upstream to return 400;agent_messagecontent usinginput_textremains supported.This is safer, but it means older Codex/non-OpenAI-parent configurations where plaintext was placed inside an
encrypted_contentwrapper 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
messagefrom 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:
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.
qcnhy commentedon Aug 15, 2026
Providing the requested evidence from the topology in this report (non-OpenAI Chat Completions upstream via codex-relay):
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.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.
Both through relay: yes — parent and child requests all go local proxy → codex-relay (0.5.5) → GLM Chat Completions.
Retest with 0.5.7: not directly applicable in our current setup — we pinned 0.5.5 and flatten
agent_message/encrypted_contentin 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.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:
agent_messageitems arrive with exactly this shape:"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.
mountain commentedon Aug 16, 2026
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):
spawn_agentfunction-call item, includingcall_id,name,arguments, and whetherencrypted_function_argsis[],null, or absent;agent_messageitem, includingid,author,recipient, and the completecontentpart 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, plaintextmessage, recipient, and wrappedencrypted_contentvalue 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
messageexactly equals the wrapped value; otherwise retain the current fail-closed behavior.qcnhy commentedon Aug 16, 2026
Captured as requested — relay 0.5.7, proxy-layer flattening fully disabled (both
agent_messagehandling and namespace flattening), fresh multi-agent session, harmless synthetic task ("list the files in the current directory").1. Parent
spawn_agentfunction-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_argsis absent — not[], notnull.2. Resulting child
agent_messageitem (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):
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.messagebyte-equals the wrappedencrypted_contentvalueSo 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
messageand the recipient is derived fromtask_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 withunsupported 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.