商业产业 Hacker News Agent 2026-07-14T11:21 正文 ok 1984 words

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,同时保持加密运送负载不变,为恢复审计能力提供了一条路径。
打开原文相关 6 · 机会 1
media1.2 KB media2.5 KB media2.7 KB media93.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 不同,后者报告的是加密工具模式的请求校验失败。而当前问题关注的是加密模式被接受之后的审计与可调试性。

复现步骤

  1. 使用包含 Encrypt multi-agent v2 message payloads #26210 的构建,并启用 MultiAgentV2(即 0.137.0 之后版本)。
  2. 让模型调用 spawn_agent、send_message 或 followup_task。
  3. 检查父级的展开历史/历史/追踪中的子代理任务。
  4. 任务/消息内容被隐藏在密文之后,而不是以可读的审计文本呈现。

期望的行为是什么?

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"
| );

实现/修复规范

一套具体实现可以同时保留加密运送并恢复本地审计轨迹:

  1. 保留现有的加密 message 字段作为运送负载。
  2. 为每个 v2 通信工具增加一个必需的、非加密明文伴随字段:
    • spawn_agent:task_message
    • send_message 和 followup_task:统一命名的明文审计字段,如 task_message 或 message_text
  3. 在处理程序边界拒绝空的明文审计值。
  4. 构造同时包含两部分的 InterAgentCommunication:
    • encrypted_content 设置为加密后的 message
    • content 设置为明文审计拷贝
  5. 保持 to_model_input_item() 行为不变,使接收方模型仍然只收到密文,而非本地审计拷贝。
  6. 将明文伴随字段持久化到父工具调用/展开历史中,并保留在结构化追踪边和本地通信日志中。
  7. 使用密文/ID 而非明文相等性来匹配工具调用与交付子项。明文字段是审计元数据,不应替代加密运送标识。
  8. 对明文审计字段施加与对应委托消息相同的硬大小限制,使新增的展开/上下文项不会无限增长。

spawn_agent 部分已在下述快照提交中实现:

ignatremizov@df9a7c4

该原型在 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_agent call 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?

  1. Use a build containing Encrypt multi-agent v2 message payloads #26210 with MultiAgentV2 enabled. (aka post-0.137.0)
  2. Have the model call spawn_agent, send_message, or followup_task.
  3. Inspect the parent rollout/history/trace for the subagent task.
  4. 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:

  1. Keep the existing encrypted message field as the delivery payload.
  2. Add a required, non-encrypted plaintext companion to each v2 communication tool:
    • spawn_agent: task_message
    • send_message and followup_task: a consistently named plaintext audit field, such as task_message or message_text
  3. Reject empty plaintext audit values at the handler boundary.
  4. Construct InterAgentCommunication with both:
    • encrypted_content set to the encrypted message
    • content set to the plaintext audit copy
  5. Keep to_model_input_item() behavior unchanged so the recipient model still receives ciphertext, not the local audit copy.
  6. Persist the plaintext companion in the parent tool invocation/rollout and retain it in structured trace edges and local communication logs.
  7. 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.
  8. 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:

ignatremizov@df9a7c4

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:

plaintext audit validation

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, and followup_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.