app-server: reject direct input to multi-agent v2 sub-agents (#27173)

## Why

Multi-agent v2 sub-agents are owned and coordinated by their parent
agent. Allowing an app-server client to start or steer turns on a
spawned child bypasses the multi-agent messaging path and creates a
second, conflicting source of work for that sub-agent.

## What changed

- Reject direct `turn/start` and `turn/steer` requests targeting
multi-agent v2 thread-spawn sub-agents.
- Identify these targets using both the thread's resolved multi-agent
version and its `SubAgentSource::ThreadSpawn` session source, leaving
root threads, v1 agents, and other sub-agent types unchanged.
- Return a consistent invalid-request error before validating or
applying the submitted input.

## Testing

- Added an app-server integration test that spawns a real multi-agent v2
child and verifies that direct `turn/start` and `turn/steer` requests
are rejected.
This commit is contained in:
jif
2026-06-09 19:40:40 +02:00
committed by GitHub
parent 8a299fb704
commit 1026e9de1b
2 changed files with 166 additions and 6 deletions
@@ -1,6 +1,12 @@
use super::*;
use codex_protocol::protocol::AdditionalContextEntry as CoreAdditionalContextEntry;
use codex_protocol::protocol::AdditionalContextKind as CoreAdditionalContextKind;
use codex_protocol::protocol::MultiAgentVersion;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
const DIRECT_INPUT_TO_MULTI_AGENT_V2_SUBAGENT_ERROR: &str =
"direct app-server input is not allowed for multi-agent v2 sub-agents";
#[derive(Clone)]
pub(crate) struct TurnRequestProcessor {
@@ -236,6 +242,26 @@ impl TurnRequestProcessor {
Ok((thread_id, thread))
}
async fn ensure_direct_input_allowed(
&self,
request_id: &ConnectionRequestId,
thread: &CodexThread,
) -> Result<(), JSONRPCErrorError> {
if thread.multi_agent_version() == Some(MultiAgentVersion::V2)
&& matches!(
thread.config_snapshot().await.session_source,
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. })
)
{
let error = invalid_request(DIRECT_INPUT_TO_MULTI_AGENT_V2_SUBAGENT_ERROR);
self.track_error_response(request_id, &error, /*error_type*/ None);
return Err(error);
}
Ok(())
}
fn normalize_collaboration_mode(
&self,
mut collaboration_mode: CollaborationMode,
@@ -370,6 +396,14 @@ impl TurnRequestProcessor {
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
) -> Result<TurnStartResponse, JSONRPCErrorError> {
let (thread_id, thread) =
self.load_thread(&params.thread_id)
.await
.inspect_err(|error| {
self.track_error_response(&request_id, error, /*error_type*/ None);
})?;
self.ensure_direct_input_allowed(&request_id, thread.as_ref())
.await?;
if let Err(error) = Self::validate_v2_input_limit(&params.input) {
self.track_error_response(
&request_id,
@@ -378,12 +412,6 @@ impl TurnRequestProcessor {
);
return Err(error);
}
let (thread_id, thread) =
self.load_thread(&params.thread_id)
.await
.inspect_err(|error| {
self.track_error_response(&request_id, error, /*error_type*/ None);
})?;
Self::set_app_server_client_info(
thread.as_ref(),
app_server_client_name,
@@ -762,6 +790,8 @@ impl TurnRequestProcessor {
.inspect_err(|error| {
self.track_error_response(request_id, error, /*error_type*/ None);
})?;
self.ensure_direct_input_allowed(request_id, thread.as_ref())
.await?;
if params.expected_turn_id.is_empty() {
return Err(invalid_request("expectedTurnId must not be empty"));