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
Unverified
parent 6a6a5f925e
commit 5f4d06ef18
34 changed files with 674 additions and 59 deletions
@@ -167,7 +167,8 @@ pub fn create_send_message_tool() -> ToolSpec {
"message".to_string(),
JsonSchema::string(Some(
"Message text to queue on the target agent.".to_string(),
)),
))
.with_encrypted(),
),
]);
@@ -199,7 +200,8 @@ pub fn create_followup_task_tool() -> ToolSpec {
"message".to_string(),
JsonSchema::string(Some(
"Message text to send to the target agent.".to_string(),
)),
))
.with_encrypted(),
),
]);
@@ -595,7 +597,10 @@ fn spawn_agent_common_properties_v2(agent_type_description: &str) -> BTreeMap<St
BTreeMap::from([
(
"message".to_string(),
JsonSchema::string(Some("Initial plain-text task for the new agent.".to_string())),
JsonSchema::string(Some(
"Initial plain-text task for the new agent.".to_string(),
))
.with_encrypted(),
),
(
"agent_type".to_string(),
@@ -81,6 +81,12 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() {
assert!(!description.contains("hidden-model"));
assert!(properties.contains_key("task_name"));
assert!(properties.contains_key("message"));
assert_eq!(
properties
.get("message")
.and_then(|schema| schema.encrypted),
Some(true)
);
assert!(properties.contains_key("fork_turns"));
assert!(!properties.contains_key("items"));
assert!(!properties.contains_key("fork_context"));
@@ -141,6 +147,12 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() {
assert!(properties.contains_key("fork_context"));
assert!(!properties.contains_key("fork_turns"));
assert_eq!(
properties
.get("message")
.and_then(|schema| schema.encrypted),
None
);
assert_eq!(
properties
.get("model")
@@ -259,6 +271,12 @@ fn send_message_tool_requires_message_and_has_no_output_schema() {
.expect("send_message should use object params");
assert!(properties.contains_key("target"));
assert!(properties.contains_key("message"));
assert_eq!(
properties
.get("message")
.and_then(|schema| schema.encrypted),
Some(true)
);
assert!(!properties.contains_key("interrupt"));
assert!(!properties.contains_key("items"));
assert_eq!(
@@ -296,6 +314,12 @@ fn followup_task_tool_requires_message_and_has_no_output_schema() {
.expect("followup_task should use object params");
assert!(properties.contains_key("target"));
assert!(properties.contains_key("message"));
assert_eq!(
properties
.get("message")
.and_then(|schema| schema.encrypted),
Some(true)
);
assert!(!properties.contains_key("items"));
assert_eq!(
parameters.required.as_ref(),
@@ -1152,7 +1152,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat
turn.clone(),
"spawn_agent",
function_payload(json!({
"message": "inspect this repo",
"message": "encrypted-spawn-message",
"task_name": "test_process"
})),
))
@@ -1188,7 +1188,8 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat
if communication.author == AgentPath::root()
&& communication.recipient.as_str() == "/root/test_process"
&& communication.other_recipients.is_empty()
&& communication.content == "inspect this repo"
&& communication.content.is_empty()
&& communication.encrypted_content.as_deref() == Some("encrypted-spawn-message")
&& communication.trigger_turn
)
}));
@@ -1200,7 +1201,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat
"send_message",
function_payload(json!({
"target": "test_process",
"message": "continue"
"message": "encrypted-send-message"
})),
))
.await
@@ -1214,7 +1215,8 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat
if communication.author == AgentPath::root()
&& communication.recipient.as_str() == "/root/test_process"
&& communication.other_recipients.is_empty()
&& communication.content == "continue"
&& communication.content.is_empty()
&& communication.encrypted_content.as_deref() == Some("encrypted-send-message")
&& !communication.trigger_turn
)
}));
@@ -1396,7 +1398,7 @@ async fn multi_agent_v2_send_message_accepts_root_target_from_child() {
"send_message",
function_payload(json!({
"target": "/root",
"message": "done"
"message": "encrypted-done"
})),
))
.await
@@ -1410,7 +1412,8 @@ async fn multi_agent_v2_send_message_accepts_root_target_from_child() {
if communication.author == child_path
&& communication.recipient == AgentPath::root()
&& communication.other_recipients.is_empty()
&& communication.content == "done"
&& communication.content.is_empty()
&& communication.encrypted_content.as_deref() == Some("encrypted-done")
&& !communication.trigger_turn
)
}));
@@ -1500,7 +1503,7 @@ async fn multi_agent_v2_followup_task_rejects_root_target_from_child() {
}
#[tokio::test]
async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_message() {
async fn multi_agent_v2_list_agents_returns_completed_status_without_encrypted_spawn_preview() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
@@ -1586,10 +1589,7 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa
.find(|agent| agent.agent_name == "/root/worker")
.expect("worker agent should be listed");
assert_eq!(worker.agent_status, json!({"completed": "done"}));
assert_eq!(
worker.last_task_message.as_deref(),
Some("inspect this repo")
);
assert_eq!(worker.last_task_message, None);
assert_eq!(success, Some(true));
}
@@ -1868,7 +1868,8 @@ async fn multi_agent_v2_send_message_rejects_interrupt_parameter() {
if communication.author == AgentPath::root()
&& communication.recipient.as_str() == "/root/worker"
&& communication.other_recipients.is_empty()
&& communication.content == "continue"
&& communication.content.is_empty()
&& communication.encrypted_content.as_deref() == Some("continue")
&& !communication.trigger_turn
)));
}
@@ -22,6 +22,7 @@ use codex_protocol::protocol::CollabCloseBeginEvent;
use codex_protocol::protocol::CollabCloseEndEvent;
use codex_protocol::protocol::CollabWaitingBeginEvent;
use codex_protocol::protocol::CollabWaitingEndEvent;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::user_input::UserInput;
use codex_tools::ToolName;
use serde::Deserialize;
@@ -42,3 +43,17 @@ mod message_tool;
mod send_message;
mod spawn;
pub(crate) mod wait;
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,
)
}
@@ -1,4 +1,4 @@
//! Shared argument parsing and dispatch for the v2 text-only agent messaging tools.
//! Shared argument parsing and dispatch for the v2 agent messaging tools.
//!
//! `send_message` and `followup_task` share the same submission path and differ only in whether the
//! resulting `InterAgentCommunication` should wake the target immediately.
@@ -55,20 +55,21 @@ fn message_content(message: String) -> Result<String, FunctionCallError> {
Ok(message)
}
/// Handles the shared MultiAgentV2 plain-text message flow for both `send_message` and `followup_task`.
/// 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 prompt = message_content(message)?;
let message = message_content(message)?;
let ToolInvocation {
session,
turn,
call_id,
..
} = invocation;
let prompt = String::new();
let receiver_thread_id = resolve_agent_target(&session, &turn, &target).await?;
let receiver_agent = session
.services
@@ -101,15 +102,11 @@ pub(crate) async fn handle_message_string_tool(
let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| {
FunctionCallError::RespondToModel("target agent is missing an agent_path".to_string())
})?;
let communication = InterAgentCommunication::new(
turn.session_source
.get_agent_path()
.unwrap_or_else(AgentPath::root),
receiver_agent_path,
Vec::new(),
prompt.clone(),
/*trigger_turn*/ true,
);
let author = turn
.session_source
.get_agent_path()
.unwrap_or_else(AgentPath::root);
let communication = communication_from_tool_message(author, receiver_agent_path, message);
let result = session
.services
.agent_control
@@ -1,7 +1,6 @@
use super::*;
use crate::agent::control::SpawnAgentForkMode;
use crate::agent::control::SpawnAgentOptions;
use crate::agent::control::render_input_preview;
use crate::agent::next_thread_spawn_depth;
use crate::agent::role::DEFAULT_ROLE_NAME;
use crate::agent::role::apply_role_to_config;
@@ -9,7 +8,6 @@ use crate::tools::handlers::multi_agents_spec::SpawnAgentToolOptions;
use crate::tools::handlers::multi_agents_spec::create_spawn_agent_tool_v2;
use crate::turn_timing::now_unix_timestamp_ms;
use codex_protocol::AgentPath;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::protocol::Op;
use codex_tools::ToolSpec;
@@ -61,8 +59,9 @@ async fn handle_spawn_agent(
.map(str::trim)
.filter(|role| !role.is_empty());
let message = args.message.clone();
let initial_operation = parse_collab_input(Some(args.message), /*items*/ None)?;
let prompt = render_input_preview(&initial_operation);
let prompt = String::new();
let session_source = turn.session_source.clone();
let child_depth = next_thread_spawn_depth(&session_source);
@@ -129,17 +128,12 @@ async fn handle_spawn_agent(
.iter()
.all(|item| matches!(item, UserInput::Text { .. })) =>
{
Op::InterAgentCommunication {
communication: InterAgentCommunication::new(
turn.session_source
.get_agent_path()
.unwrap_or_else(AgentPath::root),
recipient,
Vec::new(),
prompt.clone(),
/*trigger_turn*/ true,
),
}
let author = turn
.session_source
.get_agent_path()
.unwrap_or_else(AgentPath::root);
let communication = communication_from_tool_message(author, recipient, message);
Op::InterAgentCommunication { communication }
}
(_, initial_operation) => initial_operation,
},
@@ -1047,6 +1047,30 @@ async fn multi_agent_feature_selects_one_agent_tool_family() {
);
}
#[tokio::test]
async fn multi_agent_v2_message_schemas_are_encrypted() {
let plan = probe(|turn| {
set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true);
})
.await;
for tool_name in ["spawn_agent", "send_message", "followup_task"] {
let ToolSpec::Function(tool) = plan.visible_spec(tool_name) else {
panic!("expected {tool_name} function spec");
};
let properties = tool
.parameters
.properties
.as_ref()
.expect("tool should use object params");
assert_eq!(
properties
.get("message")
.and_then(|schema| schema.encrypted),
Some(true)
);
}
}
#[tokio::test]
async fn tool_mode_selector_overrides_feature_flags() {
let direct = probe(|turn| {