Encrypt multi-agent v2 message payloads (#26210)

## Why

Multi-agent v2 currently routes agent instructions through normal tool
arguments and inter-agent context. That means the parent model can emit
plaintext task text, Codex can persist it in history/rollouts, and the
recipient can receive it as ordinary assistant-message JSON.

This changes the v2 path so agent instructions stay encrypted between
model calls: Responses encrypts the `message` argument returned by the
model, Codex forwards only that ciphertext, and Responses decrypts it
internally for the recipient model.

## What changed

- Mark the v2 `message` parameter as encrypted for `spawn_agent`,
`send_message`, and `followup_task`.
- Treat multi-agent v2 tool `message` values as ciphertext
unconditionally.
- Store v2 inter-agent task text in
`InterAgentCommunication.encrypted_content` with empty plaintext
`content`.
- Convert encrypted inter-agent communications into the Responses
`agent_message` input item before sending the child request.
- Preserve `agent_message` items across history, rollout, compaction,
telemetry, and app-server schema paths.
- Leave multi-agent v1 unchanged.

## Message shape

The model still calls the v2 tools with a `message` argument, but that
value is now ciphertext:

```json
{
  "name": "spawn_agent",
  "arguments": {
    "task_name": "worker",
    "message": "<ciphertext>"
  }
}
```

Codex stores the task as encrypted inter-agent communication:

```json
{
  "author": "/root",
  "recipient": "/root/worker",
  "content": "",
  "encrypted_content": "<ciphertext>",
  "trigger_turn": true
}
```

When Codex builds the recipient request, it forwards the ciphertext
using the new Responses input item:

```json
{
  "type": "agent_message",
  "author": "/root",
  "recipient": "/root/worker",
  "content": [
    {
      "type": "encrypted_content",
      "encrypted_content": "<ciphertext>"
    }
  ]
}
```

Responses decrypts that item internally for the recipient model.

## Context impact

- Parent context no longer carries plaintext v2 agent task instructions
from these tool arguments.
- Codex rollout/history stores ciphertext for v2 agent instructions.
- Recipient requests receive an `agent_message` item instead of
assistant commentary JSON for encrypted task delivery.
- Plaintext completion/status notifications are still plaintext because
they are Codex-generated status messages, not encrypted model tool
arguments.

## Validation

- `just test -p codex-tools`
- `just test -p codex-protocol`
- `just test -p codex-rollout`
- `just test -p codex-rollout-trace`
- `just test -p codex-otel`
- `just write-app-server-schema`
This commit is contained in:
jif
2026-06-05 10:25:57 +02:00
committed by GitHub
parent 6a6a5f925e
commit 5f4d06ef18
34 changed files with 674 additions and 59 deletions
+11
View File
@@ -715,6 +715,12 @@ pub enum ContentItem {
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AgentMessageInputContent {
EncryptedContent { encrypted_content: String },
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
pub enum ImageDetail {
@@ -758,6 +764,11 @@ pub enum ResponseItem {
#[ts(optional)]
phase: Option<MessagePhase>,
},
AgentMessage {
author: String,
recipient: String,
content: Vec<AgentMessageInputContent>,
},
Reasoning {
#[serde(default, skip_serializing)]
#[ts(skip)]
+36
View File
@@ -33,6 +33,7 @@ use crate::mcp::CallToolResult;
use crate::mcp::RequestId;
use crate::memory_citation::MemoryCitation;
use crate::models::ActivePermissionProfile;
use crate::models::AgentMessageInputContent;
use crate::models::BaseInstructions;
use crate::models::ContentItem;
use crate::models::ImageDetail;
@@ -690,6 +691,9 @@ pub struct InterAgentCommunication {
#[serde(default)]
pub other_recipients: Vec<AgentPath>,
pub content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub encrypted_content: Option<String>,
pub trigger_turn: bool,
}
@@ -706,6 +710,24 @@ impl InterAgentCommunication {
recipient,
other_recipients,
content,
encrypted_content: None,
trigger_turn,
}
}
pub fn new_encrypted(
author: AgentPath,
recipient: AgentPath,
other_recipients: Vec<AgentPath>,
encrypted_content: String,
trigger_turn: bool,
) -> Self {
Self {
author,
recipient,
other_recipients,
content: String::new(),
encrypted_content: Some(encrypted_content),
trigger_turn,
}
}
@@ -720,6 +742,19 @@ impl InterAgentCommunication {
}
}
pub fn to_model_input_item(&self) -> ResponseItem {
match &self.encrypted_content {
Some(encrypted_content) => ResponseItem::AgentMessage {
author: self.author.to_string(),
recipient: self.recipient.to_string(),
content: vec![AgentMessageInputContent::EncryptedContent {
encrypted_content: encrypted_content.clone(),
}],
},
None => self.to_response_input_item().into(),
}
}
pub fn is_message_content(content: &[ContentItem]) -> bool {
Self::from_message_content(content).is_some()
}
@@ -4063,6 +4098,7 @@ mod tests {
recipient: AgentPath::root().join("reviewer").expect("recipient path"),
other_recipients: vec![AgentPath::root().join("worker").expect("recipient path")],
content: "review the diff".to_string(),
encrypted_content: None,
trigger_turn: true,
};