Codex 开始加密子代理提示,审计与调试数据变为不可读
英文标题:🔶 Codex starts encrypting sub-agent prompts
OpenAI Codex 的 MultiAgentV2 消息传递在 #26210 合并后改为加密存储,使得 spawn_agent、send_message 和 followup_task 的明文内容不再出现在本地展开历史、追踪和审计界面中。该修改将模型侧 message 字段标记为加密,仅保存 encrypted_content,并置空 content,导致开发者无法直接查看子代理委派任务的内容。问题本质上不是加密模式验证失败,而是将加密运送与本地可审计性完全对立。已有社区原型在 spawn_agent 中引入非加密审计字段 task_message,同时保持加密运送负载不变,为恢复审计能力提供了一条路径。
1.2 KB
2.5 KB
2.7 KB
93.9 KB
中文
运行的是哪个 Codex CLI 版本?
上游 main 分支 #26210 (Encrypt multi-agent v2 message payloads,2026-06-05 合并) 之后。该变动影响包含此更改并启用 MultiAgentV2 的版本(即 0.137.0 之后)。
你使用的是哪种订阅?
与订阅类型无关。
你当时使用的是哪个模型?
与模型无关。此问题涉及 MultiAgentV2 的 spawn_agent、send_message 和 followup_task 消息处理。
你的电脑是什么平台?
与平台无关。
你使用的终端模拟器及版本是什么(如适用)?
与终端无关。
Codex doctor 报告
不适用。此回归源于 #26210 合并后的代码行为,而非本地环境状态。
你遇到了什么问题?
#26210 将面向模型的 message 参数标记为加密,仅存储 InterAgentCommunication.encrypted_content,并将 InterAgentCommunication.content 留空,从而使 MultiAgentV2 代理任务/消息负载对 Codex 变得不透明。
加密传送路径可以理解是一种隐私加固,但同时也从本地展开历史、追踪归约以及父侧的审计/调试界面中移除了可读的任务/消息文本。这使得回答以下基本问题变得困难:
- 此
spawn_agent调用给了子代理什么任务? - 向子代理发送了什么消息?
- 事后审查展开历史时,该子线程为何存在?
这与 #26753 不同,后者报告的是加密工具模式的请求校验失败。而当前问题关注的是加密模式被接受之后的审计与可调试性。
复现步骤
- 使用包含 Encrypt multi-agent v2 message payloads #26210 的构建,并启用 MultiAgentV2(即 0.137.0 之后版本)。
- 让模型调用
spawn_agent、send_message或followup_task。 - 检查父级的展开历史/历史/追踪中的子代理任务。
- 任务/消息内容被隐藏在密文之后,而不是以可读的审计文本呈现。
期望的行为是什么?
Codex 应在保持向接收方模型加密运送的同时,保留一份结构化的、可读的审计拷贝。一种可能的方案是保留加密的 message 字段用于模型运送,但增加一个单独的非加密审计字段用于存放可读的任务文本。该审计字段应持久化于展开历史/历史/追踪元数据中,以便用户和维护者无需解密模型运送密文即可检查委托内容。
附加信息
相关 PR/issues:
目标并非一定要撤销加密运送。担忧的是加密运送不应完全移除对子代理委托的本地人类可审计性。
源码分析
上游的 InterAgentCommunication::new_encrypted() 故意将 content 初始化为空字符串,并将负载仅存储在 encrypted_content 中:
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)]
---|---
| pub struct InterAgentCommunication {
| #[serde(default, skip_serializing_if = "Option::is_none")]
| #[ts(optional)]
| pub id: Option,
| pub author: AgentPath,
| pub recipient: AgentPath,
| #[serde(default)]
| pub other_recipients: Vec,
| pub content: String,
| #[serde(default, skip_serializing_if = "Option::is_none")]
| #[ts(optional)]
| pub encrypted_content: Option,
| #[serde(default, skip_serializing_if = "Option::is_none")]
| #[ts(optional)]
| pub internal_chat_message_metadata_passthrough: Option,
| pub trigger_turn: bool,
| }
|
| impl InterAgentCommunication {
| pub fn new(
| author: AgentPath,
| recipient: AgentPath,
| other_recipients: Vec,
| content: String,
| trigger_turn: bool,
| ) -> Self {
| Self {
| id: None,
| author,
| recipient,
| other_recipients,
| content,
| encrypted_content: None,
| internal_chat_message_metadata_passthrough: None,
| trigger_turn,
| }
| }
|
| pub fn new_encrypted(
| author: AgentPath,
| recipient: AgentPath,
| other_recipients: Vec,
| encrypted_content: String,
| trigger_turn: bool,
| ) -> Self {
| Self {
| id: None,
| author,
| recipient,
| other_recipients,
| content: String::new(),
| encrypted_content: Some(encrypted_content),
| internal_chat_message_metadata_passthrough: None,
| trigger_turn,
| }
| }
随后用于接收方历史的转换会在 encrypted_content 存在时仅输出加密负载。因此仅仅填充运行时的 content 字段并不能生成可读的持久化 ResponseItem;修复方案还需要一条显式的本地审计持久化路径:
| pub fn to_model_input_item(&self) -> ResponseItem {
---|---
| let content = match &self.encrypted_content {
| Some(encrypted_content) => {
| let message_type = if self.trigger_turn {
| "NEW_TASK"
| } else {
| "MESSAGE"
| };
| vec![
| AgentMessageInputContent::InputText {
| text: format!(
| "Message Type: {message_type}\nTask name: {}\nSender: {}\nPayload:\n",
| self.recipient, self.author
| ),
| },
| AgentMessageInputContent::EncryptedContent {
| encrypted_content: encrypted_content.clone(),
| },
| ]
| }
| None => vec![AgentMessageInputContent::InputText {
| text: self.content.clone(),
| }],
| };
| ResponseItem::AgentMessage {
| id: self.id.clone(),
| author: self.author.to_string(),
| recipient: self.recipient.to_string(),
| content,
| internal_chat_message_metadata_passthrough: self
| .internal_chat_message_metadata_passthrough
| .clone(),
| }
当前 v2 消息辅助函数使用空明文内容构建加密通信:
| pub(super) fn communication_from_tool_message(
---|---
| author: AgentPath,
| recipient: AgentPath,
| message: String,
| ) -> InterAgentCommunication {
| InterAgentCommunication::new_encrypted(
| author,
| recipient,
| Vec::new(),
| message,
| /trigger_turn/ true,
| )
send_message 和 followup_task 仍然只反序列化 target 和加密后的 message,然后将密文直接传递给共享辅助函数。没有任何可用的明文伴随数据可供持久化:
| #[derive(Debug, Deserialize)]
---|---
| #[serde(deny_unknown_fields)]
| /// Input for the MultiAgentV2 send_message tool.
| pub(crate) struct SendMessageArgs {
| pub(crate) target: String,
| pub(crate) message: String,
| }
|
| #[derive(Debug, Deserialize)]
| #[serde(deny_unknown_fields)]
| /// Input for the MultiAgentV2 followup_task tool.
| pub(crate) struct FollowupTaskArgs {
| pub(crate) target: String,
| pub(crate) message: String,
| }
|
| pub(super) fn message_content(message: String) -> Result<String, FunctionCallError> {
| if message.trim().is_empty() {
| return Err(FunctionCallError::RespondToModel(
| "Empty message can't be sent to an agent".to_string(),
| ));
| }
| Ok(message)
| }
|
| /// Handles the shared MultiAgentV2 message flow for both send_message and followup_task.
| pub(crate) async fn handle_message_string_tool(
| invocation: ToolInvocation,
| mode: MessageDeliveryMode,
| target: String,
| message: String,
| ) -> Result<FunctionToolOutput, FunctionCallError> {
| let message = message_content(message)?;
| let author = turn
---|---
| .session_source
| .get_agent_path()
| .unwrap_or_else(AgentPath::root);
| let communication =
| communication_from_tool_message(author, receiver_agent_path.clone(), message);
| let kind = match mode {
| MessageDeliveryMode::QueueOnly => AgentCommunicationKind::Message,
| MessageDeliveryMode::TriggerTurn => AgentCommunicationKind::Followup,
| };
| let context = AgentCommunicationContext::new(kind, session.thread_id);
| let result = session
| .services
| .agent_control
| .send_inter_agent_communication(receiver_thread_id, mode.apply(communication), context)
| .await
接收方记录由 to_model_input_item() 生成的面向模型的 ResponseItem。对于加密通信,该条目包含的是加密运送负载,而非可读的审计文本:
| pub(crate) async fn record_inter_agent_communication(
---|---
| &self,
| turn_context: &TurnContext,
| mut communication: InterAgentCommunication,
| ) {
| communication.set_turn_id_if_missing(&turn_context.sub_id);
| let response_item = communication.to_model_input_item();
| let items = self.prepare_conversation_items_for_history(
| turn_context,
| std::slice::from_ref(&response_item),
| );
| let items = items.as_ref();
| let response_item = items[0].clone();
| {
| let mut state = self.state.lock().await;
| state.current_time_reminder.note_recorded_items(items);
| state.record_items(
| items.iter(),
| turn_context.model_info.truncation_policy.into(),
| );
| }
| self.persist_rollout_items(&[
| RolloutItem::InterAgentCommunicationMetadata {
| trigger_turn: communication.trigger_turn,
| },
| RolloutItem::ResponseItem(response_item),
| ])
| .await;
| self.send_raw_response_items(turn_context, items).await;
结构化通信日志具有相同的回退逻辑:当 content 为空时,它将 encrypted_content 记录为事件内容:
| pub(crate) fn emit_agent_communication_send(
---|---
| communication_id: &str,
| context: &AgentCommunicationContext,
| communication: &InterAgentCommunication,
| receiver_thread_id: ThreadId,
| ) {
| tracing::info!(
| target: AGENT_COMMUNICATION_TARGET,
| {
| event.name = "codex.agent_communication",
| communication_id,
| kind = context.kind.as_str(),
| state = "send",
| sender_thread_id = %context.sender_thread_id,
| receiver_thread_id = %receiver_thread_id,
| content = if communication.content.is_empty() {
| communication.encrypted_content.as_deref().unwrap_or_default()
| } else {
| communication.content.as_str()
| },
| },
| "agent communication"
| );
实现/修复规范
一套具体实现可以同时保留加密运送并恢复本地审计轨迹:
- 保留现有的加密
message字段作为运送负载。 - 为每个 v2 通信工具增加一个必需的、非加密明文伴随字段:
spawn_agent:task_messagesend_message和followup_task:统一命名的明文审计字段,如task_message或message_text
- 在处理程序边界拒绝空的明文审计值。
- 构造同时包含两部分的
InterAgentCommunication:encrypted_content设置为加密后的messagecontent设置为明文审计拷贝
- 保持
to_model_input_item()行为不变,使接收方模型仍然只收到密文,而非本地审计拷贝。 - 将明文伴随字段持久化到父工具调用/展开历史中,并保留在结构化追踪边和本地通信日志中。
- 使用密文/ID 而非明文相等性来匹配工具调用与交付子项。明文字段是审计元数据,不应替代加密运送标识。
- 对明文审计字段施加与对应委托消息相同的硬大小限制,使新增的展开/上下文项不会无限增长。
spawn_agent 部分已在下述快照提交中实现:
该原型在 v2 spawn schema 中将 task_message 设为必需:
v2 spawn_agent schema 及必需字段 task_message
它校验该字段并将其放入 InterAgentCommunication.content,同时将加密运送负载保留在 encrypted_content 中:
它还教会展开追踪归约在关联交付时保留可读审计内容,同时仅用加密值进行工具调用与交付的匹配:
剩余的实现工作是对 send_message 和 followup_task 应用相同的双内容契约,并确保所有面向用户的历史/重放/调试界面读取的是审计拷贝,而不是回落至提供者的密文。
验收标准
- 父级展开历史/历史记录显示 v2
spawn_agent、send_message和followup_task的可读文本。 - 当加密启用时,子模型仍然只接收加密运送负载。
English / 原文
What version of Codex CLI is running?
Upstream main after #26210 (Encrypt multi-agent v2 message payloads, merged 2026-06-05). This appears to affect versions that include that change and enable MultiAgentV2 (post-0.137.0).
What subscription do you have?
Not subscription-specific.
Which model were you using?
Not model-specific. This concerns MultiAgentV2 spawn_agent, send_message, and followup_task message handling.
What platform is your computer?
Not platform-specific.
What terminal emulator and version are you using (if applicable)?
Not terminal-specific.
Codex doctor report
Not applicable. The regression is visible from the merged code behavior in #26210 rather than from local environment state.
What issue are you seeing?
#26210 makes MultiAgentV2 agent task/message payloads opaque to Codex by marking the model-facing message parameter as encrypted, storing only InterAgentCommunication.encrypted_content, and leaving InterAgentCommunication.content empty.
The encrypted delivery path is understandable as privacy hardening, but it also removes the human-readable task/message text from local rollout history, trace reduction, and parent-side audit/debug surfaces. That makes it difficult to answer basic questions such as:
- What task did this
spawn_agentcall give the child agent? - What message was sent to a subagent?
- Why did a child thread exist when reviewing a rollout after the fact?
This is different from #26753, which reports request validation failures for encrypted tool schemas. This issue is about auditability and debuggability after the encrypted schema is accepted.
What steps can reproduce the bug?
- Use a build containing Encrypt multi-agent v2 message payloads #26210 with MultiAgentV2 enabled. (aka post-0.137.0)
- Have the model call
spawn_agent,send_message, orfollowup_task. - Inspect the parent rollout/history/trace for the subagent task.
- The task/message content is hidden behind ciphertext rather than being available as human-readable audit text.
What is the expected behavior?
Codex should preserve a human-readable, structured audit copy of the subagent task/message while still allowing encrypted delivery to the recipient model.
A possible shape is to keep the encrypted message field for model delivery, but add a separate non-encrypted audit field for the readable task text. The audit field should be persisted in rollout/history/trace metadata so users and maintainers can inspect what was delegated without needing to decrypt model-delivery ciphertext.
Additional information
Related PR/issues:
The goal is not necessarily to revert encrypted delivery. The concern is that encrypted delivery should not fully remove local human auditability for subagent delegation.
Source analysis
Upstream InterAgentCommunication::new_encrypted() deliberately initializes content as an empty string and stores the payload only in encrypted_content:
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)]
---|---
| pub struct InterAgentCommunication {
| #[serde(default, skip_serializing_if = "Option::is_none")]
| #[ts(optional)]
| pub id: Option,
| pub author: AgentPath,
| pub recipient: AgentPath,
| #[serde(default)]
| pub other_recipients: Vec,
| pub content: String,
| #[serde(default, skip_serializing_if = "Option::is_none")]
| #[ts(optional)]
| pub encrypted_content: Option,
| #[serde(default, skip_serializing_if = "Option::is_none")]
| #[ts(optional)]
| pub internal_chat_message_metadata_passthrough: Option,
| pub trigger_turn: bool,
| }
|
| impl InterAgentCommunication {
| pub fn new(
| author: AgentPath,
| recipient: AgentPath,
| other_recipients: Vec,
| content: String,
| trigger_turn: bool,
| ) -> Self {
| Self {
| id: None,
| author,
| recipient,
| other_recipients,
| content,
| encrypted_content: None,
| internal_chat_message_metadata_passthrough: None,
| trigger_turn,
| }
| }
|
| pub fn new_encrypted(
| author: AgentPath,
| recipient: AgentPath,
| other_recipients: Vec,
| encrypted_content: String,
| trigger_turn: bool,
| ) -> Self {
| Self {
| id: None,
| author,
| recipient,
| other_recipients,
| content: String::new(),
| encrypted_content: Some(encrypted_content),
| internal_chat_message_metadata_passthrough: None,
| trigger_turn,
| }
| }
The conversion used for recipient history then emits only the encrypted payload whenever encrypted_content is present. Merely populating the runtime content field would therefore not create a readable persisted ResponseItem; the fix also needs an explicit local audit persistence path:
| pub fn to_model_input_item(&self) -> ResponseItem {
---|---
| let content = match &self.encrypted_content {
| Some(encrypted_content) => {
| let message_type = if self.trigger_turn {
| "NEW_TASK"
| } else {
| "MESSAGE"
| };
| vec![
| AgentMessageInputContent::InputText {
| text: format!(
| "Message Type: {message_type}\nTask name: {}\nSender: {}\nPayload:\n",
| self.recipient, self.author
| ),
| },
| AgentMessageInputContent::EncryptedContent {
| encrypted_content: encrypted_content.clone(),
| },
| ]
| }
| None => vec![AgentMessageInputContent::InputText {
| text: self.content.clone(),
| }],
| };
| ResponseItem::AgentMessage {
| id: self.id.clone(),
| author: self.author.to_string(),
| recipient: self.recipient.to_string(),
| content,
| internal_chat_message_metadata_passthrough: self
| .internal_chat_message_metadata_passthrough
| .clone(),
| }
The current v2 message helper constructs encrypted communication with empty plaintext content:
| pub(super) fn communication_from_tool_message(
---|---
| author: AgentPath,
| recipient: AgentPath,
| message: String,
| ) -> InterAgentCommunication {
| InterAgentCommunication::new_encrypted(
| author,
| recipient,
| Vec::new(),
| message,
| /trigger_turn/ true,
| )
send_message and followup_task still deserialize only target plus the encrypted message, then pass that ciphertext directly through the shared helper. There is no plaintext companion available to persist:
| #[derive(Debug, Deserialize)]
---|---
| #[serde(deny_unknown_fields)]
| /// Input for the MultiAgentV2 send_message tool.
| pub(crate) struct SendMessageArgs {
| pub(crate) target: String,
| pub(crate) message: String,
| }
|
| #[derive(Debug, Deserialize)]
| #[serde(deny_unknown_fields)]
| /// Input for the MultiAgentV2 followup_task tool.
| pub(crate) struct FollowupTaskArgs {
| pub(crate) target: String,
| pub(crate) message: String,
| }
|
| pub(super) fn message_content(message: String) -> Result<String, FunctionCallError> {
| if message.trim().is_empty() {
| return Err(FunctionCallError::RespondToModel(
| "Empty message can't be sent to an agent".to_string(),
| ));
| }
| Ok(message)
| }
|
| /// Handles the shared MultiAgentV2 message flow for both send_message and followup_task.
| pub(crate) async fn handle_message_string_tool(
| invocation: ToolInvocation,
| mode: MessageDeliveryMode,
| target: String,
| message: String,
| ) -> Result<FunctionToolOutput, FunctionCallError> {
| let message = message_content(message)?;
| let author = turn
---|---
| .session_source
| .get_agent_path()
| .unwrap_or_else(AgentPath::root);
| let communication =
| communication_from_tool_message(author, receiver_agent_path.clone(), message);
| let kind = match mode {
| MessageDeliveryMode::QueueOnly => AgentCommunicationKind::Message,
| MessageDeliveryMode::TriggerTurn => AgentCommunicationKind::Followup,
| };
| let context = AgentCommunicationContext::new(kind, session.thread_id);
| let result = session
| .services
| .agent_control
| .send_inter_agent_communication(receiver_thread_id, mode.apply(communication), context)
| .await
The receiver records the model-facing ResponseItem produced by to_model_input_item(). For encrypted communication that item contains the encrypted delivery payload, not readable audit text:
| pub(crate) async fn record_inter_agent_communication(
---|---
| &self,
| turn_context: &TurnContext,
| mut communication: InterAgentCommunication,
| ) {
| communication.set_turn_id_if_missing(&turn_context.sub_id);
| let response_item = communication.to_model_input_item();
| let items = self.prepare_conversation_items_for_history(
| turn_context,
| std::slice::from_ref(&response_item),
| );
| let items = items.as_ref();
| let response_item = items[0].clone();
| {
| let mut state = self.state.lock().await;
| state.current_time_reminder.note_recorded_items(items);
| state.record_items(
| items.iter(),
| turn_context.model_info.truncation_policy.into(),
| );
| }
| self.persist_rollout_items(&[
| RolloutItem::InterAgentCommunicationMetadata {
| trigger_turn: communication.trigger_turn,
| },
| RolloutItem::ResponseItem(response_item),
| ])
| .await;
| self.send_raw_response_items(turn_context, items).await;
The structured communication log has the same fallback: when content is empty, it records encrypted_content as the event content:
| pub(crate) fn emit_agent_communication_send(
---|---
| communication_id: &str,
| context: &AgentCommunicationContext,
| communication: &InterAgentCommunication,
| receiver_thread_id: ThreadId,
| ) {
| tracing::info!(
| target: AGENT_COMMUNICATION_TARGET,
| {
| event.name = "codex.agent_communication",
| communication_id,
| kind = context.kind.as_str(),
| state = "send",
| sender_thread_id = %context.sender_thread_id,
| receiver_thread_id = %receiver_thread_id,
| content = if communication.content.is_empty() {
| communication.encrypted_content.as_deref().unwrap_or_default()
| } else {
| communication.content.as_str()
| },
| },
| "agent communication"
| );
Implementation / fix spec
A concrete implementation can preserve encrypted delivery and restore a local audit trail:
- Keep the existing encrypted
messagefield as the delivery payload. - Add a required, non-encrypted plaintext companion to each v2 communication tool:
spawn_agent:task_messagesend_messageandfollowup_task: a consistently named plaintext audit field, such astask_messageormessage_text
- Reject empty plaintext audit values at the handler boundary.
- Construct
InterAgentCommunicationwith both:encrypted_contentset to the encryptedmessagecontentset to the plaintext audit copy
- Keep
to_model_input_item()behavior unchanged so the recipient model still receives ciphertext, not the local audit copy. - Persist the plaintext companion in the parent tool invocation/rollout and retain it in structured trace edges and local communication logs.
- Match tool calls to delivered child items using ciphertext/IDs, not plaintext equality. The plaintext field is audit metadata and should not replace the encrypted delivery identity.
- Bound the plaintext audit field with the same hard size limit as the corresponding delegated message so the new rollout/context item cannot grow without limit.
The spawn_agent half of this shape is implemented in the following snapshot commit:
That prototype makes task_message required in the v2 spawn schema:
v2 spawn_agent schema and required task_message field
It validates the field and places it in InterAgentCommunication.content while leaving the encrypted delivery payload in encrypted_content:
dual plaintext audit and encrypted delivery construction
It also teaches rollout-trace reduction to keep readable audit content while using the encrypted value only to correlate the tool invocation with delivery:
separate audit content from delivery-match content
correlate delivery while applying readable audit content
The remaining implementation work is to apply the same dual-content contract to send_message and followup_task, and to ensure every user-facing history/replay/debug surface reads the audit copy rather than falling back to provider ciphertext.
Acceptance criteria
- Parent rollout/history shows the readable text for v2
spawn_agent,send_message, andfollowup_task. - The child model still receives only the encrypted delivery payload when encryption is enabled.
- Structured rollout-trace interaction edges carry bounded plaintext
message_content. - Communication logs use plaintext audit content when present and never substitute ciphertext into a field presented as readable message text.
- Resume/replay preserves the audit copy without injecting it into the child model context.
- Existing plaintext v1 communication behavior is unchanged.
- Regression tests cover all three v2 tools and assert both sides of the contract: readable local audit data and encrypted recipient-model input.