From d9e0551564f7a71fe114b8c6c744750920c321aa Mon Sep 17 00:00:00 2001 From: Adrian <145513011+adrian-openai@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:23:39 -0700 Subject: [PATCH 001/112] feat: add run task identity primitives (#19047) ## Stack This is PR 1 of the simplified HAI single-run-task stack: - [#19047](https://github.com/openai/codex/pull/19047) Agent Identity assertion and task-registration primitives, including the shared run-task helper used by existing Agent Identity JWT auth. - [#19049](https://github.com/openai/codex/pull/19049) Disabled-by-default ChatGPT auth opt-in that provisions/reuses persisted Agent Identity runtime auth and its single run task. - [#19051](https://github.com/openai/codex/pull/19051) Run-scoped provider auth that uses one backend-owned task id for first-party inference and compaction requests. [#19054](https://github.com/openai/codex/pull/19054) collapsed out of the active stack because the simplified design no longer needs a separate background/control-plane task helper. ## Summary The simplified POC shape is one backend-owned task per Agent Identity run. This PR makes the first layer match that final shape directly instead of introducing task targets, caller-owned external task refs, or intermediate wrappers that later PRs would need to undo. What changed: - keeps the `AgentAssertion` wire payload as `agent_runtime_id`, `task_id`, `timestamp`, and `signature` - exposes `register_agent_task` as the single task-registration helper for both existing Agent Identity JWT auth and the ChatGPT-registration path added later in the stack - makes task registration send only the signed registration timestamp; the backend owns the returned opaque task id - removes the unused target/task-kind/external-task-ref surfaces from `codex-agent-identity` - keeps Agent Identity JWT JWKS lookup separate from agent/task registration URL derivation - updates Agent Identity JWT auth to register one run task during auth construction and share that task across cloned auth handles This PR intentionally does not enable ChatGPT-derived Agent Identity. That opt-in and config gate are added in the next PR. ## Testing - `just test -p codex-agent-identity` --- codex-rs/agent-identity/src/lib.rs | 404 +++++++++++++++++----- codex-rs/cli/src/main.rs | 9 +- codex-rs/exec/src/lib.rs | 1 + codex-rs/login/src/auth/agent_identity.rs | 220 +++++++----- codex-rs/login/src/auth/auth_tests.rs | 45 ++- codex-rs/login/src/auth/manager.rs | 58 +++- codex-rs/model-provider/src/auth.rs | 6 +- codex-rs/tui/src/lib.rs | 1 + 8 files changed, 546 insertions(+), 198 deletions(-) diff --git a/codex-rs/agent-identity/src/lib.rs b/codex-rs/agent-identity/src/lib.rs index 7aad81a34f..0e29a38700 100644 --- a/codex-rs/agent-identity/src/lib.rs +++ b/codex-rs/agent-identity/src/lib.rs @@ -1,4 +1,6 @@ use std::collections::BTreeMap; +use std::error::Error as StdError; +use std::fmt; use std::time::Duration; use anyhow::Context; @@ -34,21 +36,66 @@ const AGENT_TASK_REGISTRATION_TIMEOUT: Duration = Duration::from_secs(30); const AGENT_IDENTITY_JWKS_TIMEOUT: Duration = Duration::from_secs(10); const AGENT_IDENTITY_JWT_AUDIENCE: &str = "codex-app-server"; const AGENT_IDENTITY_JWT_ISSUER: &str = "https://chatgpt.com/codex-backend/agent-identity"; +const AGENT_REGISTRATION_TIMEOUT: Duration = Duration::from_secs(15); +const PROD_AGENT_IDENTITY_AUTHAPI_BASE_URL: &str = "https://auth.openai.com/api/accounts"; +const STAGING_AGENT_IDENTITY_AUTHAPI_BASE_URL: &str = "https://auth.api.openai.org/api/accounts"; +const AGENT_IDENTITY_KEY_SEED_BYTES: usize = 64; +const AGENT_IDENTITY_KEY_DERIVATION_CONTEXT: &[u8] = b"codex-agent-identity-ed25519-v1"; -/// Stored key material for a registered agent identity. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ChatGptEnvironment { + #[default] + Production, + Staging, +} + +impl ChatGptEnvironment { + pub fn from_chatgpt_base_url(chatgpt_base_url: &str) -> Result { + match chatgpt_base_url.trim_end_matches('/') { + "https://chatgpt.com" + | "https://chatgpt.com/backend-api" + | "https://chatgpt.com/codex" + | "https://chatgpt.com/backend-api/codex" + | "https://chat.openai.com" + | "https://chat.openai.com/backend-api" + | "https://chat.openai.com/codex" + | "https://chat.openai.com/backend-api/codex" => Ok(Self::Production), + "https://chatgpt-staging.com" + | "https://chatgpt-staging.com/backend-api" + | "https://chatgpt-staging.com/codex" + | "https://chatgpt-staging.com/backend-api/codex" => Ok(Self::Staging), + _ => anyhow::bail!( + "Agent Identity only supports production and staging ChatGPT environments" + ), + } + } + + pub fn chatgpt_base_url(self) -> &'static str { + match self { + Self::Production => "https://chatgpt.com/backend-api", + Self::Staging => "https://chatgpt-staging.com/backend-api", + } + } + + pub fn agent_identity_authapi_base_url(self) -> &'static str { + match self { + Self::Production => PROD_AGENT_IDENTITY_AUTHAPI_BASE_URL, + Self::Staging => STAGING_AGENT_IDENTITY_AUTHAPI_BASE_URL, + } + } +} + +/// Borrowed durable signing material for a registered agent identity. +/// +/// This intentionally does not include a task id. Task ids are scoped to a +/// single Codex run, while the agent runtime id and private key are the +/// reusable identity material used to register and sign that run task. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct AgentIdentityKey<'a> { pub agent_runtime_id: &'a str, pub private_key_pkcs8_base64: &'a str, } -/// Task binding to use when constructing a task-scoped AgentAssertion. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct AgentTaskAuthorizationTarget<'a> { - pub agent_runtime_id: &'a str, - pub task_id: &'a str, -} - #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct AgentBillOfMaterials { pub agent_version: String, @@ -103,23 +150,92 @@ struct RegisterTaskResponse { encrypted_task_id_camel: Option, } +#[derive(Debug, Serialize)] +struct RegisterAgentRequest { + abom: AgentBillOfMaterials, + agent_public_key: String, + capabilities: Vec, + ttl: Option, +} + +#[derive(Debug, Deserialize)] +struct RegisterAgentResponse { + agent_runtime_id: String, +} + +/// HTTP status failure returned by Agent Identity registration endpoints. +#[derive(Debug)] +pub struct AgentIdentityRegistrationHttpError { + operation: &'static str, + status: reqwest::StatusCode, + body: String, +} + +impl AgentIdentityRegistrationHttpError { + fn new(operation: &'static str, status: reqwest::StatusCode, body: String) -> Self { + Self { + operation, + status, + body, + } + } + + /// HTTP status returned by the registration endpoint. + pub fn status(&self) -> reqwest::StatusCode { + self.status + } +} + +impl fmt::Display for AgentIdentityRegistrationHttpError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.body.is_empty() { + write!(f, "{} failed with status {}", self.operation, self.status) + } else { + write!( + f, + "{} failed with status {}: {}", + self.operation, self.status, self.body + ) + } + } +} + +impl StdError for AgentIdentityRegistrationHttpError {} + +/// Returns whether an Agent Identity registration error is safe to retry. +pub fn is_retryable_registration_error(error: &anyhow::Error) -> bool { + error.chain().any(is_retryable_registration_cause) +} + +fn is_retryable_registration_cause(cause: &(dyn StdError + 'static)) -> bool { + if let Some(error) = cause.downcast_ref::() { + return is_retryable_registration_status(error.status()); + } + + if let Some(error) = cause.downcast_ref::() { + if let Some(status) = error.status() { + return is_retryable_registration_status(status); + } + return error.is_timeout() || error.is_connect() || error.is_request(); + } + + false +} + +fn is_retryable_registration_status(status: reqwest::StatusCode) -> bool { + status == reqwest::StatusCode::TOO_MANY_REQUESTS || status.is_server_error() +} + pub fn authorization_header_for_agent_task( key: AgentIdentityKey<'_>, - target: AgentTaskAuthorizationTarget<'_>, + task_id: &str, ) -> Result { - anyhow::ensure!( - key.agent_runtime_id == target.agent_runtime_id, - "agent task runtime {} does not match stored agent identity {}", - target.agent_runtime_id, - key.agent_runtime_id - ); - let timestamp = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); let envelope = AgentAssertionEnvelope { - agent_runtime_id: target.agent_runtime_id.to_string(), - task_id: target.task_id.to_string(), + agent_runtime_id: key.agent_runtime_id.to_string(), + task_id: task_id.to_string(), timestamp: timestamp.clone(), - signature: sign_agent_assertion_payload(key, target.task_id, ×tamp)?, + signature: sign_agent_assertion_payload(key, task_id, ×tamp)?, }; let serialized_assertion = serialize_agent_assertion(&envelope)?; Ok(format!("AgentAssertion {serialized_assertion}")) @@ -127,10 +243,10 @@ pub fn authorization_header_for_agent_task( pub async fn fetch_agent_identity_jwks( client: &reqwest::Client, - chatgpt_base_url: &str, + agent_identity_jwt_base_url: &str, ) -> Result { let response = client - .get(agent_identity_jwks_url(chatgpt_base_url)) + .get(agent_identity_jwks_url(agent_identity_jwt_base_url)) .timeout(AGENT_IDENTITY_JWKS_TIMEOUT) .send() .await @@ -195,7 +311,7 @@ pub fn sign_task_registration_payload( pub async fn register_agent_task( client: &reqwest::Client, - chatgpt_base_url: &str, + agent_identity_authapi_base_url: &str, key: AgentIdentityKey<'_>, ) -> Result { let timestamp = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); @@ -203,7 +319,7 @@ pub async fn register_agent_task( signature: sign_task_registration_payload(key, ×tamp)?, timestamp, }; - let url = agent_task_registration_url(chatgpt_base_url, key.agent_runtime_id); + let url = agent_task_registration_url(agent_identity_authapi_base_url, key.agent_runtime_id); let response = client .post(url) @@ -220,7 +336,12 @@ pub async fn register_agent_task( } else { body }; - anyhow::bail!("failed to register agent task with status {status}: {body}"); + return Err(AgentIdentityRegistrationHttpError::new( + "agent task registration", + status, + body, + ) + .into()); } let response = response @@ -231,6 +352,45 @@ pub async fn register_agent_task( task_id_from_register_task_response(key, response) } +pub async fn register_agent_identity( + client: &reqwest::Client, + agent_identity_authapi_base_url: &str, + access_token: &str, + is_fedramp_account: bool, + key_material: &GeneratedAgentKeyMaterial, + abom: AgentBillOfMaterials, + capabilities: Vec, +) -> Result { + let url = agent_registration_url(agent_identity_authapi_base_url); + let request = RegisterAgentRequest { + abom, + agent_public_key: key_material.public_key_ssh.clone(), + capabilities, + ttl: None, + }; + + let mut request_builder = client + .post(&url) + .bearer_auth(access_token) + .json(&request) + .timeout(AGENT_REGISTRATION_TIMEOUT); + if is_fedramp_account { + request_builder = request_builder.header("X-OpenAI-Fedramp", "true"); + } + + let response = request_builder + .send() + .await + .with_context(|| format!("failed to send agent identity registration request to {url}"))? + .error_for_status() + .with_context(|| format!("agent identity registration failed for {url}"))? + .json::() + .await + .with_context(|| format!("failed to parse agent identity response from {url}"))?; + + Ok(response.agent_runtime_id) +} + fn task_id_from_register_task_response( key: AgentIdentityKey<'_>, response: RegisterTaskResponse, @@ -260,10 +420,17 @@ pub fn decrypt_task_id_response( } pub fn generate_agent_key_material() -> Result { - let mut secret_key_bytes = [0u8; 32]; + let mut seed_material = [0u8; AGENT_IDENTITY_KEY_SEED_BYTES]; OsRng - .try_fill_bytes(&mut secret_key_bytes) - .context("failed to generate agent identity private key bytes")?; + .try_fill_bytes(&mut seed_material) + .context("failed to generate agent identity private key seed material")?; + // Ed25519 stores a 32-byte seed, so derive it from all sampled seed material. + let mut digest = Sha512::new(); + digest.update(AGENT_IDENTITY_KEY_DERIVATION_CONTEXT); + digest.update(seed_material); + let digest = digest.finalize(); + let mut secret_key_bytes = [0u8; 32]; + secret_key_bytes.copy_from_slice(&digest[..32]); let signing_key = SigningKey::from_bytes(&secret_key_bytes); let private_key_pkcs8 = signing_key .to_pkcs8_der() @@ -296,23 +463,22 @@ pub fn curve25519_secret_key_from_private_key_pkcs8_base64( Ok(curve25519_secret_key_from_signing_key(&signing_key)) } -pub fn agent_registration_url(chatgpt_base_url: &str) -> String { - let trimmed = chatgpt_base_url.trim_end_matches('/'); - format!("{trimmed}/v1/agent/register") +pub fn agent_registration_url(agent_identity_authapi_base_url: &str) -> String { + agent_identity_authapi_url(agent_identity_authapi_base_url, "/v1/agent/register") } -pub fn agent_task_registration_url(chatgpt_base_url: &str, agent_runtime_id: &str) -> String { - let trimmed = chatgpt_base_url.trim_end_matches('/'); - format!("{trimmed}/v1/agent/{agent_runtime_id}/task/register") +pub fn agent_task_registration_url( + agent_identity_authapi_base_url: &str, + agent_runtime_id: &str, +) -> String { + agent_identity_authapi_url( + agent_identity_authapi_base_url, + &format!("/v1/agent/{agent_runtime_id}/task/register"), + ) } -pub fn agent_identity_biscuit_url(chatgpt_base_url: &str) -> String { - let trimmed = chatgpt_base_url.trim_end_matches('/'); - format!("{trimmed}/authenticate_app_v2") -} - -pub fn agent_identity_jwks_url(chatgpt_base_url: &str) -> String { - let trimmed = chatgpt_base_url.trim_end_matches('/'); +pub fn agent_identity_jwks_url(agent_identity_jwt_base_url: &str) -> String { + let trimmed = agent_identity_jwt_base_url.trim_end_matches('/'); if trimmed.contains("/backend-api") { format!("{trimmed}/wham/agent-identities/jwks") } else { @@ -320,15 +486,9 @@ pub fn agent_identity_jwks_url(chatgpt_base_url: &str) -> String { } } -pub fn agent_identity_request_id() -> Result { - let mut request_id_bytes = [0u8; 16]; - OsRng - .try_fill_bytes(&mut request_id_bytes) - .context("failed to generate agent identity request id")?; - Ok(format!( - "codex-agent-identity-{}", - URL_SAFE_NO_PAD.encode(request_id_bytes) - )) +fn agent_identity_authapi_url(agent_identity_authapi_base_url: &str, api_path: &str) -> String { + let base_url = agent_identity_authapi_base_url.trim_end_matches('/'); + format!("{base_url}{api_path}") } pub fn build_abom(session_source: SessionSource) -> AgentBillOfMaterials { @@ -412,6 +572,24 @@ mod tests { use super::*; + #[test] + fn register_task_request_uses_single_run_task_shape() { + let request = RegisterTaskRequest { + timestamp: "2026-04-23T00:00:00Z".to_string(), + signature: "signature".to_string(), + }; + + let serialized = serde_json::to_value(request).expect("serialize request"); + + assert_eq!( + serialized, + serde_json::json!({ + "timestamp": "2026-04-23T00:00:00Z", + "signature": "signature", + }) + ); + } + #[test] fn authorization_header_for_agent_task_serializes_signed_agent_assertion() { let signing_key = SigningKey::from_bytes(&[7u8; 32]); @@ -422,13 +600,9 @@ mod tests { agent_runtime_id: "agent-123", private_key_pkcs8_base64: &BASE64_STANDARD.encode(private_key.as_bytes()), }; - let target = AgentTaskAuthorizationTarget { - agent_runtime_id: "agent-123", - task_id: "task-123", - }; - let header = - authorization_header_for_agent_task(key, target).expect("build agent assertion header"); + let header = authorization_header_for_agent_task(key, "task-123") + .expect("build agent assertion header"); let token = header .strip_prefix("AgentAssertion ") .expect("agent assertion scheme"); @@ -464,31 +638,6 @@ mod tests { .expect("signature should verify"); } - #[test] - fn authorization_header_for_agent_task_rejects_mismatched_runtime() { - let signing_key = SigningKey::from_bytes(&[7u8; 32]); - let private_key = signing_key - .to_pkcs8_der() - .expect("encode test key material"); - let private_key_pkcs8_base64 = BASE64_STANDARD.encode(private_key.as_bytes()); - let key = AgentIdentityKey { - agent_runtime_id: "agent-123", - private_key_pkcs8_base64: &private_key_pkcs8_base64, - }; - let target = AgentTaskAuthorizationTarget { - agent_runtime_id: "agent-456", - task_id: "task-123", - }; - - let error = authorization_header_for_agent_task(key, target) - .expect_err("runtime mismatch should fail"); - - assert_eq!( - error.to_string(), - "agent task runtime agent-456 does not match stored agent identity agent-123" - ); - } - #[test] fn decode_agent_identity_jwt_reads_claims() { let jwt = jwt_with_payload(serde_json::json!({ @@ -704,7 +853,98 @@ J1bwkqKZTB5dHolX9A58e/xXnfZ5P8f3Z83+Izap3FwqQulk7b1WO1MQcHuVg2NN } #[test] - fn agent_identity_jwks_url_uses_backend_api_base_url() { + fn chatgpt_environment_maps_known_urls_to_authapi() -> anyhow::Result<()> { + assert_eq!( + ChatGptEnvironment::from_chatgpt_base_url("https://chatgpt.com/backend-api/codex")?, + ChatGptEnvironment::Production + ); + assert_eq!( + ChatGptEnvironment::Production.agent_identity_authapi_base_url(), + "https://auth.openai.com/api/accounts" + ); + assert_eq!( + ChatGptEnvironment::from_chatgpt_base_url("https://chatgpt-staging.com/backend-api")?, + ChatGptEnvironment::Staging + ); + assert_eq!( + ChatGptEnvironment::Staging.agent_identity_authapi_base_url(), + "https://auth.api.openai.org/api/accounts" + ); + Ok(()) + } + + #[test] + fn chatgpt_environment_rejects_custom_urls() { + assert!(ChatGptEnvironment::from_chatgpt_base_url("http://localhost:8080").is_err(),); + } + + #[test] + fn agent_registration_url_appends_to_authapi_base_url() { + assert_eq!( + agent_registration_url("https://auth.openai.com/api/accounts"), + "https://auth.openai.com/api/accounts/v1/agent/register" + ); + assert_eq!( + agent_registration_url("http://localhost:8080"), + "http://localhost:8080/v1/agent/register" + ); + assert_eq!( + agent_registration_url("http://localhost:8080/backend-api"), + "http://localhost:8080/backend-api/v1/agent/register" + ); + } + + #[test] + fn agent_task_registration_url_appends_to_authapi_base_url() { + assert_eq!( + agent_task_registration_url("https://auth.openai.com/api/accounts", "agent-runtime-id"), + "https://auth.openai.com/api/accounts/v1/agent/agent-runtime-id/task/register" + ); + assert_eq!( + agent_task_registration_url( + "https://auth.openai.com/api/accounts/", + "agent-runtime-id" + ), + "https://auth.openai.com/api/accounts/v1/agent/agent-runtime-id/task/register" + ); + assert_eq!( + agent_task_registration_url("http://localhost:8080", "agent-runtime-id"), + "http://localhost:8080/v1/agent/agent-runtime-id/task/register" + ); + } + + #[test] + fn retryable_registration_error_accepts_429_and_5xx() { + let too_many_requests = anyhow::Error::new(AgentIdentityRegistrationHttpError::new( + "agent registration", + reqwest::StatusCode::TOO_MANY_REQUESTS, + "rate limited".to_string(), + )); + let unavailable = anyhow::Error::new(AgentIdentityRegistrationHttpError::new( + "agent registration", + reqwest::StatusCode::SERVICE_UNAVAILABLE, + "try later".to_string(), + )); + + assert!(is_retryable_registration_error(&too_many_requests)); + assert!(is_retryable_registration_error(&unavailable)); + } + + #[test] + fn retryable_registration_error_rejects_hard_failures() { + let forbidden = anyhow::Error::new(AgentIdentityRegistrationHttpError::new( + "agent registration", + reqwest::StatusCode::FORBIDDEN, + "not allowed".to_string(), + )); + let malformed = anyhow::anyhow!("failed to sign registration request"); + + assert!(!is_retryable_registration_error(&forbidden)); + assert!(!is_retryable_registration_error(&malformed)); + } + + #[test] + fn agent_identity_jwks_url_uses_agent_identity_jwt_route() { assert_eq!( agent_identity_jwks_url("https://chatgpt.com/backend-api"), "https://chatgpt.com/backend-api/wham/agent-identities/jwks" @@ -716,7 +956,7 @@ J1bwkqKZTB5dHolX9A58e/xXnfZ5P8f3Z83+Izap3FwqQulk7b1WO1MQcHuVg2NN } #[test] - fn agent_identity_jwks_url_uses_codex_api_base_url() { + fn agent_identity_jwks_url_uses_jwt_issuer_base_url() { assert_eq!( agent_identity_jwks_url("http://localhost:8080/api/codex"), "http://localhost:8080/api/codex/agent-identities/jwks" diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 1d06ee7872..f6c5fbfb98 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1731,9 +1731,12 @@ async fn load_exec_server_remote_auth_provider( let agent_identity_jwt = read_codex_access_token_from_env().ok_or_else(|| { anyhow::anyhow!("CODEX_ACCESS_TOKEN is required when --use-agent-identity-auth is set") })?; - let auth = - CodexAuth::from_agent_identity_jwt(&agent_identity_jwt, Some(&config.chatgpt_base_url)) - .await?; + let auth = CodexAuth::from_agent_identity_jwt( + &agent_identity_jwt, + Some(&config.chatgpt_base_url), + /*agent_identity_authapi_base_url_override*/ None, + ) + .await?; return Ok(codex_model_provider::auth_provider_from_auth(&auth)); } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 482785c5cf..05bd5cc1df 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -475,6 +475,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result forced_login_method: config.forced_login_method, forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id.clone(), chatgpt_base_url: Some(config.chatgpt_base_url.clone()), + agent_identity_authapi_base_url: None, }) .await { diff --git a/codex-rs/login/src/auth/agent_identity.rs b/codex-rs/login/src/auth/agent_identity.rs index 3644713328..d4c4f3e513 100644 --- a/codex-rs/login/src/auth/agent_identity.rs +++ b/codex-rs/login/src/auth/agent_identity.rs @@ -1,75 +1,84 @@ +use std::sync::Arc; + use codex_agent_identity::AgentIdentityKey; use codex_agent_identity::register_agent_task; use codex_protocol::account::PlanType as AccountPlanType; -use std::env; use crate::default_client::build_reqwest_client; use super::storage::AgentIdentityAuthRecord; -const PROD_AGENT_IDENTITY_AUTHAPI_BASE_URL: &str = "https://auth.openai.com/api/accounts"; -const CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL_ENV_VAR: &str = "CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL"; - #[derive(Clone, Debug)] pub struct AgentIdentityAuth { + inner: Arc, +} + +#[derive(Debug)] +struct AgentIdentityAuthInner { record: AgentIdentityAuthRecord, - process_task_id: String, + run_task_id: String, } impl AgentIdentityAuth { - pub async fn load(record: AgentIdentityAuthRecord) -> std::io::Result { - let agent_identity_authapi_base_url = agent_identity_authapi_base_url(); - let process_task_id = register_agent_task( + pub async fn load( + record: AgentIdentityAuthRecord, + agent_identity_authapi_base_url: &str, + ) -> std::io::Result { + let run_task_id = register_agent_task( &build_reqwest_client(), - &agent_identity_authapi_base_url, - key(&record), + agent_identity_authapi_base_url, + key_for_record(&record), ) .await .map_err(std::io::Error::other)?; Ok(Self { - record, - process_task_id, + inner: Arc::new(AgentIdentityAuthInner { + record, + run_task_id, + }), }) } - pub fn record(&self) -> &AgentIdentityAuthRecord { - &self.record + #[cfg(test)] + fn from_initialized_record(record: AgentIdentityAuthRecord, run_task_id: String) -> Self { + Self { + inner: Arc::new(AgentIdentityAuthInner { + record, + run_task_id, + }), + } } - pub fn process_task_id(&self) -> &str { - &self.process_task_id + pub fn record(&self) -> &AgentIdentityAuthRecord { + &self.inner.record + } + + pub fn run_task_id(&self) -> &str { + &self.inner.run_task_id } pub fn account_id(&self) -> &str { - &self.record.account_id + &self.inner.record.account_id } pub fn chatgpt_user_id(&self) -> &str { - &self.record.chatgpt_user_id + &self.inner.record.chatgpt_user_id } pub fn email(&self) -> &str { - &self.record.email + &self.inner.record.email } pub fn plan_type(&self) -> AccountPlanType { - self.record.plan_type + self.inner.record.plan_type } pub fn is_fedramp_account(&self) -> bool { - self.record.chatgpt_account_is_fedramp + self.inner.record.chatgpt_account_is_fedramp } } -fn agent_identity_authapi_base_url() -> String { - env::var(CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL_ENV_VAR) - .ok() - .map(|base_url| base_url.trim().trim_end_matches('/').to_string()) - .filter(|base_url| !base_url.is_empty()) - .unwrap_or_else(|| PROD_AGENT_IDENTITY_AUTHAPI_BASE_URL.to_string()) -} - -fn key(record: &AgentIdentityAuthRecord) -> AgentIdentityKey<'_> { +fn key_for_record(record: &AgentIdentityAuthRecord) -> AgentIdentityKey<'_> { AgentIdentityKey { agent_runtime_id: &record.agent_runtime_id, private_key_pkcs8_base64: &record.agent_private_key, @@ -78,63 +87,110 @@ fn key(record: &AgentIdentityAuthRecord) -> AgentIdentityKey<'_> { #[cfg(test)] mod tests { + use std::sync::Arc; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + + use codex_agent_identity::generate_agent_key_material; + use pretty_assertions::assert_eq; + use serde_json::json; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + use super::*; - use serial_test::serial; - #[test] - #[serial(codex_auth_env)] - fn agent_identity_authapi_base_url_prefers_env_value() { - let _guard = EnvVarGuard::set( - CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL_ENV_VAR, - "https://authapi.example.test/api/accounts/", - ); - assert_eq!( - agent_identity_authapi_base_url(), - "https://authapi.example.test/api/accounts" - ); - } - - #[test] - #[serial(codex_auth_env)] - fn agent_identity_authapi_base_url_uses_prod_authapi_by_default() { - let _guard = EnvVarGuard::remove(CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL_ENV_VAR); - assert_eq!( - agent_identity_authapi_base_url(), - PROD_AGENT_IDENTITY_AUTHAPI_BASE_URL - ); - } - - struct EnvVarGuard { - key: &'static str, - original: Option, - } - - impl EnvVarGuard { - fn set(key: &'static str, value: &str) -> Self { - let original = env::var_os(key); - unsafe { - env::set_var(key, value); - } - Self { key, original } - } - - fn remove(key: &'static str) -> Self { - let original = env::var_os(key); - unsafe { - env::remove_var(key); - } - Self { key, original } + fn agent_identity_record(private_key: String) -> AgentIdentityAuthRecord { + AgentIdentityAuthRecord { + agent_runtime_id: "agent-runtime-1".to_string(), + agent_private_key: private_key, + account_id: "account-1".to_string(), + chatgpt_user_id: "user-1".to_string(), + email: "agent@example.com".to_string(), + plan_type: AccountPlanType::Plus, + chatgpt_account_is_fedramp: false, } } - impl Drop for EnvVarGuard { - fn drop(&mut self) { - unsafe { - match &self.original { - Some(value) => env::set_var(self.key, value), - None => env::remove_var(self.key), + fn agent_identity_record_with_generated_key() -> AgentIdentityAuthRecord { + let key_material = generate_agent_key_material().expect("generate key material"); + agent_identity_record(key_material.private_key_pkcs8_base64) + } + + #[tokio::test] + async fn load_registers_run_task() -> anyhow::Result<()> { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/agent/agent-runtime-1/task/register")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "task_id": "task-run-1", + }))) + .expect(1) + .mount(&server) + .await; + + let auth = + AgentIdentityAuth::load(agent_identity_record_with_generated_key(), &server.uri()) + .await?; + + assert_eq!(auth.run_task_id(), "task-run-1"); + let requests = server + .received_requests() + .await + .expect("failed to fetch task registration request"); + let request_body = requests[0] + .body_json::() + .expect("task registration request should be JSON"); + let request_body = request_body + .as_object() + .expect("request body should be object"); + assert!(request_body.get("timestamp").is_some()); + assert!(request_body.get("signature").is_some()); + assert_eq!(request_body.len(), 2); + Ok(()) + } + + #[test] + fn run_task_is_shared_across_clones() { + let auth = AgentIdentityAuth::from_initialized_record( + agent_identity_record_with_generated_key(), + "task-run-1".to_string(), + ); + let cloned = auth.clone(); + + assert!(Arc::ptr_eq(&auth.inner, &cloned.inner)); + assert_eq!(cloned.run_task_id(), "task-run-1"); + } + + #[tokio::test] + async fn failed_run_task_registration_can_be_retried_on_next_call() -> anyhow::Result<()> { + let server = MockServer::start().await; + let request_count = Arc::new(AtomicUsize::new(0)); + let response_count = Arc::clone(&request_count); + Mock::given(method("POST")) + .and(path("/v1/agent/agent-runtime-1/task/register")) + .respond_with(move |_request: &wiremock::Request| { + if response_count.fetch_add(1, Ordering::SeqCst) == 0 { + ResponseTemplate::new(500) + } else { + ResponseTemplate::new(200).set_body_json(json!({ + "task_id": "task-run-1", + })) } - } - } + }) + .expect(2) + .mount(&server) + .await; + let record = agent_identity_record_with_generated_key(); + AgentIdentityAuth::load(record.clone(), &server.uri()) + .await + .expect_err("first registration should fail"); + let auth = AgentIdentityAuth::load(record, &server.uri()).await?; + + assert_eq!(request_count.load(Ordering::SeqCst), 2); + assert_eq!(auth.run_task_id(), "task-run-1"); + Ok(()) } } diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index 465dd62175..aa1dcd9c86 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -348,6 +348,7 @@ async fn pro_account_with_no_api_key_uses_chatgpt_auth() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, ) .await .unwrap() @@ -408,6 +409,7 @@ async fn loads_api_key_from_auth_json() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, ) .await .unwrap() @@ -500,6 +502,7 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, ) .await .expect("load auth") @@ -519,6 +522,7 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, ) .await .expect("updated auth should parse"); @@ -824,6 +828,7 @@ async fn build_config( forced_login_method, forced_chatgpt_workspace_id, chatgpt_base_url: None, + agent_identity_authapi_base_url: None, } } @@ -886,7 +891,7 @@ async fn load_auth_reads_access_token_from_env() { .mount(&server) .await; Mock::given(method("POST")) - .and(path("/backend-api/v1/agent/agent-runtime-id/task/register")) + .and(path("/v1/agent/agent-runtime-id/task/register")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "task_id": "task-123", }))) @@ -895,9 +900,8 @@ async fn load_auth_reads_access_token_from_env() { .await; let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, &agent_identity); - let chatgpt_base_url = format!("{}/backend-api", server.uri()); - let _authapi_guard = - EnvVarGuard::set("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", &chatgpt_base_url); + let authapi_base_url = server.uri(); + let chatgpt_base_url = format!("{authapi_base_url}/backend-api"); let auth = super::load_auth( codex_home.path(), /*enable_codex_api_key_env*/ false, @@ -905,6 +909,7 @@ async fn load_auth_reads_access_token_from_env() { /*forced_chatgpt_workspace_id*/ None, Some(&chatgpt_base_url), AuthKeyringBackendKind::Direct, + Some(&authapi_base_url), ) .await .expect("env auth should load") @@ -914,7 +919,7 @@ async fn load_auth_reads_access_token_from_env() { panic!("env auth should load as agent identity"); }; assert_eq!(agent_identity.record(), &expected_record); - assert_eq!(agent_identity.process_task_id(), "task-123"); + assert_eq!(agent_identity.run_task_id(), "task-123"); assert!( !get_auth_file(codex_home.path()).exists(), "env auth should not write auth.json" @@ -951,6 +956,7 @@ async fn load_auth_reads_personal_access_token_from_env() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*agent_identity_authapi_base_url*/ None, ) .await .expect("env auth should load") @@ -1119,6 +1125,7 @@ async fn load_auth_keeps_codex_api_key_env_precedence() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, ) .await .expect("env auth should load") @@ -1226,6 +1233,7 @@ async fn enforce_login_restrictions_logs_out_for_personal_access_token_workspace forced_login_method: None, forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), chatgpt_base_url: None, + agent_identity_authapi_base_url: None, }; let err = super::enforce_login_restrictions(&config) @@ -1317,16 +1325,15 @@ async fn enforce_login_restrictions_logs_out_for_agent_identity_workspace_mismat .mount(&server) .await; Mock::given(method("POST")) - .and(path("/backend-api/v1/agent/agent-runtime-id/task/register")) + .and(path("/v1/agent/agent-runtime-id/task/register")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "task_id": "task-123", }))) .expect(1) .mount(&server) .await; - let chatgpt_base_url = format!("{}/backend-api", server.uri()); - let _authapi_guard = - EnvVarGuard::set("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", &chatgpt_base_url); + let authapi_base_url = server.uri(); + let chatgpt_base_url = format!("{authapi_base_url}/backend-api"); save_auth( codex_home.path(), &AuthDotJson { @@ -1350,6 +1357,7 @@ async fn enforce_login_restrictions_logs_out_for_agent_identity_workspace_mismat forced_login_method: None, forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), chatgpt_base_url: Some(chatgpt_base_url), + agent_identity_authapi_base_url: Some(authapi_base_url), }; let err = super::enforce_login_restrictions(&config) @@ -1563,19 +1571,19 @@ async fn assert_agent_identity_plan_alias( .mount(&server) .await; Mock::given(method("POST")) - .and(path("/backend-api/v1/agent/agent-runtime-id/task/register")) + .and(path("/v1/agent/agent-runtime-id/task/register")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "task_id": "task-123", }))) .expect(1) .mount(&server) .await; - let chatgpt_base_url = format!("{}/backend-api", server.uri()); - let _authapi_guard = - EnvVarGuard::set("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", &chatgpt_base_url); - let auth = CodexAuth::from_agent_identity_jwt(&jwt, Some(&chatgpt_base_url)) - .await - .expect("agent identity auth"); + let authapi_base_url = server.uri(); + let chatgpt_base_url = format!("{authapi_base_url}/backend-api"); + let auth = + CodexAuth::from_agent_identity_jwt(&jwt, Some(&chatgpt_base_url), Some(&authapi_base_url)) + .await + .expect("agent identity auth"); pretty_assertions::assert_eq!(auth.account_plan_type(), Some(expected_plan_type)); server.verify().await; @@ -1603,6 +1611,7 @@ async fn plan_type_maps_known_plan() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, ) .await .expect("load auth") @@ -1633,6 +1642,7 @@ async fn plan_type_maps_self_serve_business_usage_based_plan() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, ) .await .expect("load auth") @@ -1666,6 +1676,7 @@ async fn plan_type_maps_enterprise_cbp_usage_based_plan() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, ) .await .expect("load auth") @@ -1699,6 +1710,7 @@ async fn plan_type_maps_unknown_to_unknown() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, ) .await .expect("load auth") @@ -1729,6 +1741,7 @@ async fn missing_plan_type_maps_to_unknown() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, ) .await .expect("load auth") diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index b7d61153fd..f4ed2a9369 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -18,6 +18,7 @@ use std::sync::atomic::Ordering; use tokio::sync::Semaphore; use tokio::sync::watch; +use codex_agent_identity::ChatGptEnvironment; use codex_agent_identity::decode_agent_identity_jwt; use codex_agent_identity::fetch_agent_identity_jwks; use codex_app_server_protocol::AuthMode; @@ -104,7 +105,6 @@ const REFRESH_TOKEN_INVALIDATED_MESSAGE: &str = "Your access token could not be const REFRESH_TOKEN_UNKNOWN_MESSAGE: &str = "Your access token could not be refreshed. Please log out and sign in again."; const REFRESH_TOKEN_ACCOUNT_MISMATCH_MESSAGE: &str = "Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again."; -const DEFAULT_CHATGPT_BACKEND_BASE_URL: &str = "https://chatgpt.com/backend-api"; const REFRESH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; pub(super) const REVOKE_TOKEN_URL: &str = "https://auth.openai.com/oauth/revoke"; pub const REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REFRESH_TOKEN_URL_OVERRIDE"; @@ -218,6 +218,7 @@ impl CodexAuth { auth_credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: Option<&str>, keyring_backend_kind: AuthKeyringBackendKind, + agent_identity_authapi_base_url: Option<&str>, ) -> std::io::Result { let auth_mode = auth_dot_json.resolved_mode(); let client = create_client(); @@ -233,7 +234,12 @@ impl CodexAuth { "agent identity auth is missing an agent identity token.", )); }; - return Self::from_agent_identity_jwt(&agent_identity, chatgpt_base_url).await; + return Self::from_agent_identity_jwt( + &agent_identity, + chatgpt_base_url, + agent_identity_authapi_base_url, + ) + .await; } if auth_mode == ApiAuthMode::PersonalAccessToken { let Some(personal_access_token) = auth_dot_json.personal_access_token.as_deref() else { @@ -292,6 +298,7 @@ impl CodexAuth { /*forced_chatgpt_workspace_id*/ None, chatgpt_base_url, keyring_backend_kind, + /*agent_identity_authapi_base_url*/ None, ) .await } @@ -299,13 +306,19 @@ impl CodexAuth { pub async fn from_agent_identity_jwt( jwt: &str, chatgpt_base_url: Option<&str>, + agent_identity_authapi_base_url_override: Option<&str>, ) -> std::io::Result { let base_url = chatgpt_base_url - .unwrap_or(DEFAULT_CHATGPT_BACKEND_BASE_URL) + .unwrap_or(ChatGptEnvironment::default().chatgpt_base_url()) .trim_end_matches('/') .to_string(); + let resolved_authapi_base_url = resolve_agent_identity_authapi_base_url( + Some(&base_url), + agent_identity_authapi_base_url_override, + )?; let record = verified_agent_identity_record(jwt, &base_url).await?; - Ok(Self::AgentIdentity(AgentIdentityAuth::load(record).await?)) + let auth = AgentIdentityAuth::load(record, &resolved_authapi_base_url).await?; + Ok(Self::AgentIdentity(auth)) } pub async fn from_personal_access_token(access_token: &str) -> std::io::Result { @@ -580,6 +593,22 @@ async fn verified_agent_identity_record( Ok(claims.into()) } +fn resolve_agent_identity_authapi_base_url( + chatgpt_base_url: Option<&str>, + agent_identity_authapi_base_url_override: Option<&str>, +) -> std::io::Result { + if let Some(base_url) = agent_identity_authapi_base_url_override { + return Ok(base_url.trim_end_matches('/').to_string()); + } + + let environment = match chatgpt_base_url { + Some(chatgpt_base_url) => ChatGptEnvironment::from_chatgpt_base_url(chatgpt_base_url) + .map_err(std::io::Error::other)?, + None => ChatGptEnvironment::default(), + }; + Ok(environment.agent_identity_authapi_base_url().to_string()) +} + /// Delete the auth.json file inside `codex_home` if it exists. Returns `Ok(true)` /// if a file was removed, `Ok(false)` if no auth file was present. pub fn logout( @@ -672,7 +701,7 @@ pub async fn login_with_access_token( } CodexAccessToken::AgentIdentityJwt(jwt) => { let base_url = chatgpt_base_url - .unwrap_or(DEFAULT_CHATGPT_BACKEND_BASE_URL) + .unwrap_or(ChatGptEnvironment::default().chatgpt_base_url()) .trim_end_matches('/') .to_string(); verified_agent_identity_record(jwt, &base_url).await?; @@ -763,6 +792,7 @@ pub struct AuthConfig { pub keyring_backend_kind: AuthKeyringBackendKind, pub forced_login_method: Option, pub chatgpt_base_url: Option, + pub agent_identity_authapi_base_url: Option, pub forced_chatgpt_workspace_id: Option>, } @@ -774,6 +804,7 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result< /*forced_chatgpt_workspace_id*/ None, config.chatgpt_base_url.as_deref(), config.keyring_backend_kind, + config.agent_identity_authapi_base_url.as_deref(), ) .await? else { @@ -916,6 +947,7 @@ async fn load_auth( forced_chatgpt_workspace_id: Option<&[String]>, chatgpt_base_url: Option<&str>, keyring_backend_kind: AuthKeyringBackendKind, + agent_identity_authapi_base_url: Option<&str>, ) -> std::io::Result> { // API key via env var takes precedence over any other auth method. if enable_codex_api_key_env && let Some(api_key) = read_codex_api_key_from_env() { @@ -936,6 +968,7 @@ async fn load_auth( AuthCredentialsStoreMode::Ephemeral, chatgpt_base_url, keyring_backend_kind, + agent_identity_authapi_base_url, ) .await?; if let CodexAuth::PersonalAccessToken(auth) = &auth { @@ -951,11 +984,13 @@ async fn load_auth( ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, &auth)?; Ok(Some(CodexAuth::PersonalAccessToken(auth))) } - CodexAccessToken::AgentIdentityJwt(jwt) => { - CodexAuth::from_agent_identity_jwt(jwt, chatgpt_base_url) - .await - .map(Some) - } + CodexAccessToken::AgentIdentityJwt(jwt) => CodexAuth::from_agent_identity_jwt( + jwt, + chatgpt_base_url, + agent_identity_authapi_base_url, + ) + .await + .map(Some), }; } @@ -981,6 +1016,7 @@ async fn load_auth( auth_credentials_store_mode, chatgpt_base_url, keyring_backend_kind, + agent_identity_authapi_base_url, ) .await?; if let CodexAuth::PersonalAccessToken(auth) = &auth { @@ -1573,6 +1609,7 @@ impl AuthManager { forced_chatgpt_workspace_id.as_deref(), chatgpt_base_url.as_deref(), keyring_backend_kind, + /*agent_identity_authapi_base_url*/ None, ) .await .ok() @@ -1799,6 +1836,7 @@ impl AuthManager { forced_chatgpt_workspace_id.as_deref(), self.chatgpt_base_url.as_deref(), self.keyring_backend_kind, + /*agent_identity_authapi_base_url*/ None, ) .await .ok() diff --git a/codex-rs/model-provider/src/auth.rs b/codex-rs/model-provider/src/auth.rs index 8f5e02b175..5553120274 100644 --- a/codex-rs/model-provider/src/auth.rs +++ b/codex-rs/model-provider/src/auth.rs @@ -1,7 +1,6 @@ use std::sync::Arc; use codex_agent_identity::AgentIdentityKey; -use codex_agent_identity::AgentTaskAuthorizationTarget; use codex_agent_identity::authorization_header_for_agent_task; use codex_api::AuthProvider; use codex_api::SharedAuthProvider; @@ -30,10 +29,7 @@ impl AuthProvider for AgentIdentityAuthProvider { agent_runtime_id: &record.agent_runtime_id, private_key_pkcs8_base64: &record.agent_private_key, }, - AgentTaskAuthorizationTarget { - agent_runtime_id: &record.agent_runtime_id, - task_id: self.auth.process_task_id(), - }, + self.auth.run_task_id(), ) .map_err(std::io::Error::other); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 002e647263..b5540f878b 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1161,6 +1161,7 @@ pub async fn run_main( forced_login_method: config.forced_login_method, forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id.clone(), chatgpt_base_url: Some(config.chatgpt_base_url.clone()), + agent_identity_authapi_base_url: None, }) .await { From 7dc7096ae116df18c917d5d2f7c75243d98a0419 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:52:18 -0700 Subject: [PATCH 002/112] [codex] Restore thread recency with compatible migration history (#28671) ## Summary - Revert #28655, restoring the thread `recencyAt` behavior introduced by #27910. - Move `threads_recency_at` to migration 0039 so it no longer collides with `external_agent_config_imports` at version 0038. - Repair databases that already applied the recency migration as version 38 by moving the matching migration-history row to version 39 before SQLx validation. The current version-38 migration can then apply normally. ## Validation - `just test -p codex-state migrations::tests::repairs_recency_migration_that_was_applied_as_version_38` - `just test -p codex-state -p codex-rollout -p codex-thread-store -p codex-app-server-protocol -p codex-tui`: 3,439 passed; six TUI tests could not open the machine's existing read-only incident database at `~/.codex/sqlite/state_5.sqlite`. - `just fix -p codex-state` - `just fmt` - Verified that state migration versions are unique. --- .../analytics/src/analytics_client_tests.rs | 1 + codex-rs/analytics/src/client_tests.rs | 1 + .../schema/json/ClientRequest.json | 3 +- .../schema/json/ServerNotification.json | 8 + .../codex_app_server_protocol.schemas.json | 11 +- .../codex_app_server_protocol.v2.schemas.json | 11 +- .../schema/json/v2/ThreadForkResponse.json | 8 + .../schema/json/v2/ThreadListParams.json | 3 +- .../schema/json/v2/ThreadListResponse.json | 8 + .../json/v2/ThreadMetadataUpdateResponse.json | 8 + .../schema/json/v2/ThreadReadResponse.json | 8 + .../schema/json/v2/ThreadResumeResponse.json | 8 + .../json/v2/ThreadRollbackResponse.json | 8 + .../schema/json/v2/ThreadStartResponse.json | 8 + .../json/v2/ThreadStartedNotification.json | 8 + .../json/v2/ThreadUnarchiveResponse.json | 8 + .../schema/typescript/v2/Thread.ts | 4 + .../schema/typescript/v2/ThreadSortKey.ts | 2 +- .../src/protocol/common.rs | 2 + .../src/protocol/v2/tests.rs | 10 + .../src/protocol/v2/thread.rs | 1 + .../src/protocol/v2/thread_data.rs | 3 + codex-rs/app-server/README.md | 7 +- .../app-server/src/bespoke_event_handling.rs | 1 + .../request_processors/thread_processor.rs | 5 + .../thread_processor_tests.rs | 1 + .../thread_resume_redaction.rs | 1 + .../src/request_processors/thread_summary.rs | 1 + codex-rs/app-server/src/thread_status.rs | 1 + .../app-server/tests/suite/v2/thread_list.rs | 83 +++++ .../tests/suite/v2/thread_resume.rs | 26 +- codex-rs/core/src/realtime_context_tests.rs | 4 + codex-rs/exec/src/lib_tests.rs | 2 + codex-rs/rollout/src/list.rs | 71 ++-- codex-rs/rollout/src/metadata.rs | 1 + codex-rs/rollout/src/metadata_tests.rs | 1 + codex-rs/rollout/src/recorder.rs | 67 +++- codex-rs/rollout/src/recorder_tests.rs | 3 + codex-rs/rollout/src/state_db.rs | 7 +- codex-rs/rollout/src/state_db_tests.rs | 16 + codex-rs/rollout/src/tests.rs | 11 + .../migrations/0039_threads_recency_at.sql | 28 ++ codex-rs/state/src/extract.rs | 1 + codex-rs/state/src/migrations.rs | 47 +++ codex-rs/state/src/migrations_tests.rs | 198 ++++++++++ codex-rs/state/src/model/thread_metadata.rs | 26 +- codex-rs/state/src/runtime.rs | 53 ++- codex-rs/state/src/runtime/memories.rs | 2 + codex-rs/state/src/runtime/test_support.rs | 1 + codex-rs/state/src/runtime/threads.rs | 345 ++++++++++++++++-- codex-rs/thread-store/src/in_memory.rs | 3 + .../thread-store/src/local/archive_thread.rs | 1 + codex-rs/thread-store/src/local/helpers.rs | 2 + .../thread-store/src/local/list_threads.rs | 1 + codex-rs/thread-store/src/local/mod.rs | 92 +++++ .../thread-store/src/local/read_thread.rs | 9 + .../thread-store/src/local/search_threads.rs | 16 +- .../src/local/search_threads_tests.rs | 29 ++ .../src/local/unarchive_thread.rs | 1 + .../src/local/update_thread_metadata.rs | 18 + .../thread-store/src/thread_metadata_sync.rs | 31 +- codex-rs/thread-store/src/types.rs | 10 + codex-rs/tui/src/app/loaded_threads.rs | 1 + codex-rs/tui/src/app/tests.rs | 4 + codex-rs/tui/src/app/thread_session_state.rs | 1 + codex-rs/tui/src/app_server_session.rs | 1 + codex-rs/tui/src/resume_picker.rs | 12 +- 67 files changed, 1274 insertions(+), 100 deletions(-) create mode 100644 codex-rs/state/migrations/0039_threads_recency_at.sql create mode 100644 codex-rs/state/src/migrations_tests.rs create mode 100644 codex-rs/thread-store/src/local/search_threads_tests.rs diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 4aae796562..d7be7f1df9 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -178,6 +178,7 @@ fn sample_thread_with_metadata( model_provider: "openai".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(2), status: AppServerThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index 1835932e8e..18bb8c2159 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -285,6 +285,7 @@ fn sample_thread(thread_id: &str) -> Thread { model_provider: "openai".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(2), status: AppServerThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index cb1d594ff6..4f04698e37 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -4164,7 +4164,8 @@ "ThreadSortKey": { "enum": [ "created_at", - "updated_at" + "updated_at", + "recency_at" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index d64befa0a0..a0d49cc548 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3582,6 +3582,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 6251f7dc7a..5cbf96f24c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -16645,6 +16645,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -18904,7 +18912,8 @@ "ThreadSortKey": { "enum": [ "created_at", - "updated_at" + "updated_at", + "recency_at" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 9a121fa87b..e5a922ea78 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -14445,6 +14445,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" @@ -16704,7 +16712,8 @@ "ThreadSortKey": { "enum": [ "created_at", - "updated_at" + "updated_at", + "recency_at" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 4a666672b6..2b7caeca7e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1055,6 +1055,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json index 789d9b61fc..11f7da47d3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json @@ -24,7 +24,8 @@ "ThreadSortKey": { "enum": [ "created_at", - "updated_at" + "updated_at", + "recency_at" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index c1d1b6759a..0caa0daf5d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -870,6 +870,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 801d1cf131..a98d3586ec 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -870,6 +870,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 01b5ed907e..51e56f14b8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -870,6 +870,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 032d1dd74e..e8c309507e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1055,6 +1055,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index c1ad534756..52cba55edb 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -870,6 +870,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 33373b9f72..245cc946d4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1055,6 +1055,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 577df57e05..bf497ecfa2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -870,6 +870,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index fcc42e6261..14f25cfcdc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -870,6 +870,14 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "sessionId": { "description": "Session id shared by threads that belong to the same session tree.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts index 5fa30b64e3..1c288ccbd4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts @@ -41,6 +41,10 @@ createdAt: number, * Unix timestamp (in seconds) when the thread was last updated. */ updatedAt: number, +/** + * Unix timestamp (in seconds) used for thread recency ordering. + */ +recencyAt: number | null, /** * Current runtime status for the thread. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSortKey.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSortKey.ts index dbf1b6c40f..d93f1c47bf 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSortKey.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSortKey.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ThreadSortKey = "created_at" | "updated_at"; +export type ThreadSortKey = "created_at" | "updated_at" | "recency_at"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 801e9b46b8..f22cab25f1 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2501,6 +2501,7 @@ mod tests { model_provider: "openai".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(3), status: v2::ThreadStatus::Idle, path: None, cwd: cwd.clone(), @@ -2544,6 +2545,7 @@ mod tests { "modelProvider": "openai", "createdAt": 1, "updatedAt": 2, + "recencyAt": 3, "status": { "type": "idle" }, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index de138d5c2e..39bc52717a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -174,6 +174,7 @@ fn thread_resume_response_round_trips_initial_turns_page() { model_provider: "openai".to_string(), created_at: 1, updated_at: 1, + recency_at: Some(1), status: ThreadStatus::Idle, path: None, cwd: absolute_path("tmp"), @@ -3600,6 +3601,7 @@ fn thread_lifecycle_responses_default_missing_optional_fields() { assert_eq!(start.instruction_sources, Vec::::new()); assert_eq!(start.thread.parent_thread_id, None); + assert_eq!(start.thread.recency_at, None); assert_eq!(resume.instruction_sources, Vec::::new()); assert_eq!(fork.instruction_sources, Vec::::new()); assert_eq!(start.active_permission_profile, None); @@ -3608,6 +3610,14 @@ fn thread_lifecycle_responses_default_missing_optional_fields() { assert_eq!(fork.active_permission_profile, None); } +#[test] +fn thread_recency_sort_key_serializes_as_snake_case() { + assert_eq!( + serde_json::to_value(ThreadSortKey::RecencyAt).expect("sort key should serialize"), + json!("recency_at") + ); +} + #[test] fn turn_start_params_preserve_explicit_null_service_tier() { let params: TurnStartParams = serde_json::from_value(json!({ diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 0252c4b048..b5809f09b8 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -1086,6 +1086,7 @@ pub enum ThreadSourceKind { pub enum ThreadSortKey { CreatedAt, UpdatedAt, + RecencyAt, } #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs index fc0762361f..52a8d7017a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs @@ -152,6 +152,9 @@ pub struct Thread { /// Unix timestamp (in seconds) when the thread was last updated. #[ts(type = "number")] pub updated_at: i64, + /// Unix timestamp (in seconds) used for thread recency ordering. + #[ts(type = "number | null")] + pub recency_at: Option, /// Current runtime status for the thread. pub status: ThreadStatus, /// [UNSTABLE] Path to the thread on disk. diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 62c40555e6..ab001cc682 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -364,7 +364,8 @@ Like `thread/resume`, experimental clients can pass `excludeTurns: true` to `thr - `cursor` — opaque string from a prior response; omit for the first page. - `limit` — server defaults to a reasonable page size if unset. -- `sortKey` — `created_at` (default) or `updated_at`. +- `sortKey` — `created_at` (default), `updated_at`, or `recency_at`. +- `recencyAt` is initialized when the thread is created and advances when a turn starts. Unlike `updatedAt`, background output and other persisted mutations do not advance it. - `sortDirection` — `desc` (default) or `asc`. - `modelProviders` — restrict results to specific providers; unset, null, or an empty array will include all providers. - `sourceKinds` — restrict results to specific sources; omit or pass `[]` for interactive sessions only (`cli`, `vscode`). @@ -386,8 +387,8 @@ Example: } } { "id": 20, "result": { "data": [ - { "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111, "updatedAt": 1730831111, "status": { "type": "notLoaded" }, "agentNickname": "Atlas", "agentRole": "explorer" }, - { "id": "thr_b", "preview": "Fix tests", "modelProvider": "openai", "createdAt": 1730750000, "updatedAt": 1730750000, "status": { "type": "notLoaded" } } + { "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111, "updatedAt": 1730831111, "recencyAt": 1730831111, "status": { "type": "notLoaded" }, "agentNickname": "Atlas", "agentRole": "explorer" }, + { "id": "thr_b", "preview": "Fix tests", "modelProvider": "openai", "createdAt": 1730750000, "updatedAt": 1730750000, "recencyAt": 1730750000, "status": { "type": "notLoaded" } } ], "nextCursor": "opaque-token-or-null", "backwardsCursor": "opaque-token-or-null" diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index a78876a343..ed135271a9 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -2261,6 +2261,7 @@ mod tests { reasoning_effort: None, created_at, updated_at: created_at, + recency_at: created_at, archived_at: None, cwd: test_path_buf("/tmp").abs().into(), cli_version: "0.0.0".to_string(), diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index a5299f2c72..907b05a03b 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1897,6 +1897,7 @@ impl ThreadRequestProcessor { let store_sort_key = match sort_key.unwrap_or(ThreadSortKey::CreatedAt) { ThreadSortKey::CreatedAt => StoreThreadSortKey::CreatedAt, ThreadSortKey::UpdatedAt => StoreThreadSortKey::UpdatedAt, + ThreadSortKey::RecencyAt => StoreThreadSortKey::RecencyAt, }; let sort_direction = sort_direction.unwrap_or(SortDirection::Desc); let (stored_threads, next_cursor) = self @@ -1978,6 +1979,7 @@ impl ThreadRequestProcessor { let store_sort_key = match sort_key.unwrap_or(ThreadSortKey::CreatedAt) { ThreadSortKey::CreatedAt => StoreThreadSortKey::CreatedAt, ThreadSortKey::UpdatedAt => StoreThreadSortKey::UpdatedAt, + ThreadSortKey::RecencyAt => StoreThreadSortKey::RecencyAt, }; let store_sort_direction = sort_direction.unwrap_or(SortDirection::Desc); let (allowed_sources, source_kind_filter) = compute_source_filters(source_kinds); @@ -3695,6 +3697,7 @@ fn thread_backwards_cursor_for_sort_key( let timestamp = match sort_key { StoreThreadSortKey::CreatedAt => thread.created_at, StoreThreadSortKey::UpdatedAt => thread.updated_at, + StoreThreadSortKey::RecencyAt => thread.recency_at, }; // The state DB stores unique millisecond timestamps. Offset the reverse cursor by one // millisecond so the opposite-direction query includes the page anchor. @@ -4139,6 +4142,7 @@ pub(crate) fn thread_from_stored_thread( }, created_at: thread.created_at.timestamp(), updated_at: thread.updated_at.timestamp(), + recency_at: Some(thread.recency_at.timestamp()), status: ThreadStatus::NotLoaded, path, cwd, @@ -4344,6 +4348,7 @@ fn build_thread_from_snapshot( model_provider: config_snapshot.model_provider_id.clone(), created_at: now, updated_at: now, + recency_at: Some(now), status: ThreadStatus::NotLoaded, path, cwd: config_snapshot.cwd().clone(), diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 42711e8570..1f333f1eb1 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -478,6 +478,7 @@ mod thread_processor_behavior_tests { reasoning_effort: None, created_at: created_at.with_timezone(&Utc), updated_at: updated_at.with_timezone(&Utc), + recency_at: updated_at.with_timezone(&Utc), archived_at: None, cwd: PathBuf::from("/tmp"), cli_version: "0.0.0".to_string(), diff --git a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs index e970a88a9f..110d6d5cbf 100644 --- a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs +++ b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs @@ -178,6 +178,7 @@ mod tests { model_provider: "mock_provider".to_string(), created_at: 0, updated_at: 0, + recency_at: Some(0), status: ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index 0fb1320055..67d2d9eabc 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -320,6 +320,7 @@ pub(crate) fn summary_to_thread( model_provider, created_at: created_at.map(|dt| dt.timestamp()).unwrap_or(0), updated_at: updated_at.map(|dt| dt.timestamp()).unwrap_or(0), + recency_at: updated_at.map(|dt| dt.timestamp()), status: ThreadStatus::NotLoaded, path: (!path.as_os_str().is_empty()).then_some(path), cwd, diff --git a/codex-rs/app-server/src/thread_status.rs b/codex-rs/app-server/src/thread_status.rs index 6d66a32d6a..a5997a58f5 100644 --- a/codex-rs/app-server/src/thread_status.rs +++ b/codex-rs/app-server/src/thread_status.rs @@ -897,6 +897,7 @@ mod tests { model_provider: "mock-provider".to_string(), created_at: 0, updated_at: 0, + recency_at: Some(0), status: ThreadStatus::NotLoaded, path: None, cwd: test_path_buf("/tmp").abs(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 3f6adc167e..9bdff3bb8b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -1689,6 +1689,89 @@ async fn thread_list_sort_updated_at_orders_by_mtime() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_list_sort_recency_at_uses_state_db_order_with_provider_filter() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let id_old = create_fake_rollout( + codex_home.path(), + "2025-01-01T10-00-00", + "2025-01-01T10:00:00Z", + "Hello", + Some("mock_provider"), + /*git_info*/ None, + )?; + let id_new = create_fake_rollout( + codex_home.path(), + "2025-01-01T11-00-00", + "2025-01-01T11:00:00Z", + "Hello", + Some("mock_provider"), + /*git_info*/ None, + )?; + set_rollout_mtime( + rollout_path(codex_home.path(), "2025-01-01T10-00-00", &id_old).as_path(), + "2025-01-03T00:00:00Z", + )?; + + let state_db = + codex_state::StateRuntime::init(codex_home.path().to_path_buf(), "mock_provider".into()) + .await?; + state_db + .mark_backfill_complete(/*last_watermark*/ None) + .await?; + let rollout_config = codex_rollout::RolloutConfig { + codex_home: codex_home.path().to_path_buf(), + sqlite_home: codex_home.path().to_path_buf(), + cwd: codex_home.path().to_path_buf(), + model_provider_id: "mock_provider".to_string(), + generate_memories: false, + }; + codex_core::RolloutRecorder::list_threads( + Some(state_db.clone()), + &rollout_config, + /*page_size*/ 10, + /*cursor*/ None, + codex_core::ThreadSortKey::CreatedAt, + codex_core::SortDirection::Desc, + codex_core::INTERACTIVE_SESSION_SOURCES.as_slice(), + /*model_providers*/ None, + /*cwd_filters*/ None, + "mock_provider", + /*search_term*/ None, + ) + .await?; + state_db + .touch_thread_recency_at( + ThreadId::from_string(&id_new)?, + DateTime::::from_timestamp(1_800_000_000, 0).expect("timestamp"), + ) + .await?; + + let mut mcp = init_mcp(codex_home.path()).await?; + let ThreadListResponse { data, .. } = list_threads_with_sort( + &mut mcp, + /*cursor*/ None, + Some(10), + Some(vec!["mock_provider".to_string()]), + /*source_kinds*/ None, + Some(ThreadSortKey::RecencyAt), + /*archived*/ None, + ) + .await?; + + assert_eq!( + data.iter() + .map(|thread| thread.id.as_str()) + .collect::>(), + vec![id_new.as_str(), id_old.as_str()] + ); + assert!(data.iter().all(|thread| thread.recency_at.is_some())); + + Ok(()) +} + #[tokio::test] async fn thread_list_updated_at_paginates_with_cursor() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index e67184bf01..03e30e373e 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -2200,6 +2200,7 @@ async fn thread_resume_defers_updated_at_until_turn_start() -> Result<()> { let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; assert_eq!(thread.updated_at, before_resume.updated_at); + assert_eq!(thread.recency_at, before_resume.recency_at); assert_eq!(thread.status, ThreadStatus::Idle); let after_modified = std::fs::metadata(&rollout.rollout_file_path)?.modified()?; @@ -2234,7 +2235,7 @@ async fn thread_resume_defers_updated_at_until_turn_start() -> Result<()> { let turn_id = mcp .send_turn_start_request(TurnStartParams { - thread_id, + thread_id: thread_id.clone(), input: vec![UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -2247,6 +2248,29 @@ async fn thread_resume_defers_updated_at_until_turn_start() -> Result<()> { mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), ) .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/started"), + ) + .await??; + + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: thread_id.clone(), + include_turns: false, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadReadResponse { + thread: after_turn_start, + .. + } = to_response::(read_resp)?; + assert!(after_turn_start.recency_at > before_resume.recency_at); + timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("turn/completed"), diff --git a/codex-rs/core/src/realtime_context_tests.rs b/codex-rs/core/src/realtime_context_tests.rs index f04282a65f..352d6ae8e4 100644 --- a/codex-rs/core/src/realtime_context_tests.rs +++ b/codex-rs/core/src/realtime_context_tests.rs @@ -47,6 +47,10 @@ fn stored_thread(cwd: &str, title: &str, first_user_message: &str) -> StoredThre .timestamp_opt(1_709_251_200, 0) .single() .expect("valid timestamp"), + recency_at: Utc + .timestamp_opt(1_709_251_200, 0) + .single() + .expect("valid timestamp"), archived_at: None, cwd: PathBuf::from(cwd), cli_version: "test".to_string(), diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 6b27d01116..526c5fc487 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -342,6 +342,7 @@ fn turn_items_for_thread_returns_matching_turn_items() { model_provider: "openai".to_string(), created_at: 0, updated_at: 0, + recency_at: Some(0), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/project").abs(), @@ -760,6 +761,7 @@ fn sample_thread_start_response() -> ThreadStartResponse { model_provider: "openai".to_string(), created_at: 0, updated_at: 0, + recency_at: Some(0), status: codex_app_server_protocol::ThreadStatus::Idle, path: Some(PathBuf::from("/tmp/rollout.jsonl")), cwd: test_path_buf("/tmp").abs(), diff --git a/codex-rs/rollout/src/list.rs b/codex-rs/rollout/src/list.rs index 845145aaef..f5013e7aae 100644 --- a/codex-rs/rollout/src/list.rs +++ b/codex-rs/rollout/src/list.rs @@ -77,6 +77,8 @@ pub struct ThreadItem { pub created_at: Option, /// RFC3339 timestamp string for the most recent update (from file mtime). pub updated_at: Option, + /// RFC3339 timestamp string used for product recency ordering. + pub recency_at: Option, } #[allow(dead_code)] @@ -115,6 +117,7 @@ const USER_EVENT_SCAN_LIMIT: usize = 200; pub enum ThreadSortKey { CreatedAt, UpdatedAt, + RecencyAt, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -137,20 +140,29 @@ pub struct ThreadListConfig<'a> { pub layout: ThreadListLayout, } -/// Pagination cursor identifying the timestamp of the last item in a page. +/// Pagination cursor identifying the last item in a page. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Cursor { ts: OffsetDateTime, + id: Option, } impl Cursor { - fn new(ts: OffsetDateTime) -> Self { - Self { ts } + pub(crate) fn new(ts: OffsetDateTime) -> Self { + Self { ts, id: None } + } + + pub(crate) fn with_thread_id(ts: OffsetDateTime, id: ThreadId) -> Self { + Self { ts, id: Some(id) } } pub(crate) fn timestamp(&self) -> OffsetDateTime { self.ts } + + pub(crate) fn thread_id(&self) -> Option { + self.id + } } /// Keeps track of where a paginated listing left off. As the file scan goes newest -> oldest, @@ -287,7 +299,10 @@ impl serde::Serialize for Cursor { .ts .format(&Rfc3339) .map_err(|e| serde::ser::Error::custom(format!("format error: {e}")))?; - serializer.serialize_str(&ts_str) + match self.id { + Some(id) => serializer.serialize_str(&format!("{ts_str}|{id}")), + None => serializer.serialize_str(&ts_str), + } } } @@ -308,7 +323,7 @@ impl From for Cursor { .timestamp_nanos_opt() .and_then(|nanos| OffsetDateTime::from_unix_timestamp_nanos(nanos as i128).ok()) .unwrap_or(OffsetDateTime::UNIX_EPOCH); - Self::new(ts) + Self { ts, id: anchor.id } } } @@ -419,7 +434,7 @@ async fn traverse_directories_for_paths( ) .await } - ThreadSortKey::UpdatedAt => { + ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => { traverse_directories_for_paths_updated( root, page_size, @@ -454,7 +469,7 @@ async fn traverse_flat_paths( ) .await } - ThreadSortKey::UpdatedAt => { + ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => { traverse_flat_paths_updated( root, page_size, @@ -702,35 +717,48 @@ async fn traverse_flat_paths_updated( }) } -/// Pagination cursor token format: an RFC3339 timestamp. +/// Pagination cursor token format: an RFC3339 timestamp with an optional thread ID tie-breaker. pub fn parse_cursor(token: &str) -> Option { - if token.contains('|') { - return None; - } + let (timestamp, id) = match token.rsplit_once('|') { + Some((timestamp, id)) => (timestamp, Some(ThreadId::from_string(id).ok()?)), + None => (token, None), + }; - let ts = OffsetDateTime::parse(token, &Rfc3339).ok().or_else(|| { - let format: &[FormatItem] = - format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); - PrimitiveDateTime::parse(token, format) - .ok() - .map(PrimitiveDateTime::assume_utc) - })?; + let ts = OffsetDateTime::parse(timestamp, &Rfc3339) + .ok() + .or_else(|| { + let format: &[FormatItem] = + format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); + PrimitiveDateTime::parse(timestamp, format) + .ok() + .map(PrimitiveDateTime::assume_utc) + })?; - Some(Cursor::new(ts)) + Some(Cursor { ts, id }) } fn build_next_cursor(items: &[ThreadItem], sort_key: ThreadSortKey) -> Option { let last = items.last()?; let file_name = last.path.file_name()?.to_string_lossy(); - let (created_ts, _id) = parse_timestamp_uuid_from_filename(&file_name)?; + let (created_ts, id) = parse_timestamp_uuid_from_filename(&file_name)?; let ts = match sort_key { ThreadSortKey::CreatedAt => created_ts, ThreadSortKey::UpdatedAt => { let updated_at = last.updated_at.as_deref()?; OffsetDateTime::parse(updated_at, &Rfc3339).ok()? } + ThreadSortKey::RecencyAt => { + let recency_at = last.recency_at.as_deref().or(last.updated_at.as_deref())?; + OffsetDateTime::parse(recency_at, &Rfc3339).ok()? + } }; - Some(Cursor::new(ts)) + match sort_key { + ThreadSortKey::RecencyAt => Some(Cursor::with_thread_id( + ts, + ThreadId::from_string(&id.to_string()).ok()?, + )), + ThreadSortKey::CreatedAt | ThreadSortKey::UpdatedAt => Some(Cursor::new(ts)), + } } async fn build_thread_item( @@ -806,6 +834,7 @@ async fn build_thread_item( model_provider, cli_version, created_at, + recency_at: summary_updated_at.clone(), updated_at: summary_updated_at, }); } diff --git a/codex-rs/rollout/src/metadata.rs b/codex-rs/rollout/src/metadata.rs index 19e9a9320c..dcb38b6d2d 100644 --- a/codex-rs/rollout/src/metadata.rs +++ b/codex-rs/rollout/src/metadata.rs @@ -115,6 +115,7 @@ pub async fn extract_metadata_from_rollout( } if let Some(updated_at) = file_modified_time_utc(rollout_path).await { metadata.updated_at = updated_at; + metadata.recency_at = updated_at; } Ok(ExtractionOutcome { metadata, diff --git a/codex-rs/rollout/src/metadata_tests.rs b/codex-rs/rollout/src/metadata_tests.rs index 03d251e1b3..01e56f569d 100644 --- a/codex-rs/rollout/src/metadata_tests.rs +++ b/codex-rs/rollout/src/metadata_tests.rs @@ -71,6 +71,7 @@ async fn extract_metadata_from_rollout_uses_session_meta() { let mut expected = builder.build("openai"); apply_rollout_item(&mut expected, &rollout_line.item, "openai"); expected.updated_at = file_modified_time_utc(&path).await.expect("mtime"); + expected.recency_at = expected.updated_at; assert_eq!(outcome.metadata, expected); assert_eq!(outcome.memory_mode, None); diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index f12593f009..7ba111e944 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -543,6 +543,27 @@ impl RolloutRecorder { ) .await; } + if sort_key == ThreadSortKey::RecencyAt { + if let Some(repaired_db_page) = state_db::list_threads_db( + state_db_ctx.as_deref(), + codex_home, + page_size, + cursor, + sort_key, + sort_direction, + allowed_sources, + model_providers, + cwd_filters, + /*parent_thread_id*/ None, + archived, + search_term, + ) + .await + { + return Ok(repaired_db_page.into()); + } + return Ok(db_page.into()); + } codex_state::record_fallback( "list_threads", "metadata_filter", @@ -985,6 +1006,11 @@ fn truncate_fs_page( let cursor_token = match sort_key { ThreadSortKey::CreatedAt => created_at.format(&Rfc3339).ok()?, ThreadSortKey::UpdatedAt => item.updated_at.as_deref()?.to_string(), + ThreadSortKey::RecencyAt => item + .recency_at + .as_deref() + .or(item.updated_at.as_deref())? + .to_string(), }; parse_cursor(cursor_token.as_str()) }); @@ -1049,6 +1075,7 @@ fn fill_missing_thread_item_metadata(item: &mut ThreadItem, state_item: ThreadIt cli_version, created_at, updated_at, + recency_at, } = state_item; if item.first_user_message.is_none() { @@ -1093,6 +1120,9 @@ fn fill_missing_thread_item_metadata(item: &mut ThreadItem, state_item: ThreadIt if item.updated_at.is_none() { item.updated_at = updated_at; } + if recency_at.is_some() { + item.recency_at = recency_at; + } } #[allow(clippy::too_many_arguments)] @@ -1269,9 +1299,20 @@ async fn list_threads_from_files_asc( .collect::>(); if let Some(cursor) = cursor { - let anchor = cursor.timestamp(); - all_items - .retain(|item| thread_item_sort_key(item, sort_key).is_some_and(|key| key.0 > anchor)); + let anchor = ( + cursor.timestamp(), + cursor + .thread_id() + .and_then(|id| uuid::Uuid::parse_str(&id.to_string()).ok()), + ); + all_items.retain(|item| { + thread_item_sort_key(item, sort_key).is_some_and(|key| match anchor.1 { + Some(anchor_id) if sort_key == ThreadSortKey::RecencyAt => { + key > (anchor.0, anchor_id) + } + _ => key.0 > anchor.0, + }) + }); } let more_matches_available = all_items.len() > page_size || reached_scan_cap; @@ -1329,14 +1370,27 @@ fn thread_item_sort_key( let updated_at = item.updated_at.as_deref().or(item.created_at.as_deref())?; OffsetDateTime::parse(updated_at, &Rfc3339).ok()? } + ThreadSortKey::RecencyAt => { + let recency_at = item + .recency_at + .as_deref() + .or(item.updated_at.as_deref()) + .or(item.created_at.as_deref())?; + OffsetDateTime::parse(recency_at, &Rfc3339).ok()? + } }; Some((timestamp, id)) } fn cursor_from_thread_item(item: &ThreadItem, sort_key: ThreadSortKey) -> Option { - let (timestamp, _id) = thread_item_sort_key(item, sort_key)?; - let cursor_token = timestamp.format(&Rfc3339).ok()?; - parse_cursor(cursor_token.as_str()) + let (timestamp, id) = thread_item_sort_key(item, sort_key)?; + match sort_key { + ThreadSortKey::RecencyAt => Some(Cursor::with_thread_id( + timestamp, + ThreadId::from_string(&id.to_string()).ok()?, + )), + ThreadSortKey::CreatedAt | ThreadSortKey::UpdatedAt => Some(Cursor::new(timestamp)), + } } struct LogFileInfo { @@ -1725,6 +1779,7 @@ fn thread_item_from_state_metadata(item: codex_state::ThreadMetadata) -> ThreadI cli_version: Some(item.cli_version), created_at: Some(item.created_at.to_rfc3339_opts(SecondsFormat::Secs, true)), updated_at: Some(item.updated_at.to_rfc3339_opts(SecondsFormat::Millis, true)), + recency_at: Some(item.recency_at.to_rfc3339_opts(SecondsFormat::Millis, true)), } } diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index c49a477944..2558750f15 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -984,6 +984,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi model_provider: None, cli_version: None, created_at: None, + recency_at: Some("2025-01-03T15:59:00.000Z".to_string()), updated_at: None, }; let state_item = ThreadItem { @@ -1002,6 +1003,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi model_provider: Some("state-provider".to_string()), cli_version: Some("state-version".to_string()), created_at: Some("2025-01-03T16:00:00Z".to_string()), + recency_at: Some("2025-01-03T16:00:30.001Z".to_string()), updated_at: Some("2025-01-03T16:01:02.003Z".to_string()), }; @@ -1027,6 +1029,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi assert_eq!(item.model_provider.as_deref(), Some("state-provider")); assert_eq!(item.cli_version.as_deref(), Some("state-version")); assert_eq!(item.created_at.as_deref(), Some("2025-01-03T16:00:00Z")); + assert_eq!(item.recency_at.as_deref(), Some("2025-01-03T16:00:30.001Z")); assert_eq!(item.updated_at.as_deref(), Some("2025-01-03T16:01:02.003Z")); } diff --git a/codex-rs/rollout/src/state_db.rs b/codex-rs/rollout/src/state_db.rs index ab93305fa6..96fbf7af1c 100644 --- a/codex-rs/rollout/src/state_db.rs +++ b/codex-rs/rollout/src/state_db.rs @@ -290,7 +290,10 @@ fn cursor_to_anchor(cursor: Option<&Cursor>) -> Option { let millis = cursor.timestamp().unix_timestamp_nanos() / 1_000_000; let millis = i64::try_from(millis).ok()?; let ts = chrono::DateTime::::from_timestamp_millis(millis)?; - Some(codex_state::Anchor { ts }) + Some(codex_state::Anchor { + ts, + id: cursor.thread_id(), + }) } pub fn normalize_cwd_for_state_db(cwd: &Path) -> PathBuf { @@ -336,6 +339,7 @@ pub async fn list_thread_ids_db( match sort_key { ThreadSortKey::CreatedAt => codex_state::SortKey::CreatedAt, ThreadSortKey::UpdatedAt => codex_state::SortKey::UpdatedAt, + ThreadSortKey::RecencyAt => codex_state::SortKey::RecencyAt, }, allowed_sources.as_slice(), model_providers.as_deref(), @@ -401,6 +405,7 @@ pub async fn list_threads_db( sort_key: match sort_key { ThreadSortKey::CreatedAt => codex_state::SortKey::CreatedAt, ThreadSortKey::UpdatedAt => codex_state::SortKey::UpdatedAt, + ThreadSortKey::RecencyAt => codex_state::SortKey::RecencyAt, }, sort_direction: match sort_direction { SortDirection::Asc => codex_state::SortDirection::Asc, diff --git a/codex-rs/rollout/src/state_db_tests.rs b/codex-rs/rollout/src/state_db_tests.rs index a84ede199b..ba92ca27f1 100644 --- a/codex-rs/rollout/src/state_db_tests.rs +++ b/codex-rs/rollout/src/state_db_tests.rs @@ -28,6 +28,22 @@ fn cursor_to_anchor_normalizes_timestamp_format() { .expect("nanosecond"); assert_eq!(anchor.ts, expected_ts); + assert_eq!(anchor.id, None); +} + +#[test] +fn cursor_to_anchor_preserves_recency_tie_breaker() { + let id = ThreadId::from_string("00000000-0000-0000-0000-000000000123") + .expect("thread id should parse"); + let token = format!("2026-01-27T12:34:56Z|{id}"); + let cursor = parse_cursor(&token).expect("cursor should parse"); + let anchor = cursor_to_anchor(Some(&cursor)).expect("anchor should parse"); + + assert_eq!(anchor.id, Some(id)); + assert_eq!( + serde_json::to_string(&cursor).expect("cursor should serialize"), + format!("\"{token}\"") + ); } #[tokio::test] diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index 3c1fc8bb57..0e3ae912dd 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -612,6 +612,7 @@ async fn test_list_conversations_latest_first() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-01-03T12-00-00".into()), + recency_at: updated_times.first().cloned().flatten(), updated_at: updated_times.first().cloned().flatten(), }, ThreadItem { @@ -630,6 +631,7 @@ async fn test_list_conversations_latest_first() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-01-02T12-00-00".into()), + recency_at: updated_times.get(1).cloned().flatten(), updated_at: updated_times.get(1).cloned().flatten(), }, ThreadItem { @@ -648,6 +650,7 @@ async fn test_list_conversations_latest_first() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-01-01T12-00-00".into()), + recency_at: updated_times.get(2).cloned().flatten(), updated_at: updated_times.get(2).cloned().flatten(), }, ], @@ -759,6 +762,7 @@ async fn test_pagination_cursor() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-05T09-00-00".into()), + recency_at: updated_page1.first().cloned().flatten(), updated_at: updated_page1.first().cloned().flatten(), }, ThreadItem { @@ -777,6 +781,7 @@ async fn test_pagination_cursor() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-04T09-00-00".into()), + recency_at: updated_page1.get(1).cloned().flatten(), updated_at: updated_page1.get(1).cloned().flatten(), }, ], @@ -831,6 +836,7 @@ async fn test_pagination_cursor() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-03T09-00-00".into()), + recency_at: updated_page2.first().cloned().flatten(), updated_at: updated_page2.first().cloned().flatten(), }, ThreadItem { @@ -849,6 +855,7 @@ async fn test_pagination_cursor() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-02T09-00-00".into()), + recency_at: updated_page2.get(1).cloned().flatten(), updated_at: updated_page2.get(1).cloned().flatten(), }, ], @@ -895,6 +902,7 @@ async fn test_pagination_cursor() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-01T09-00-00".into()), + recency_at: updated_page3.first().cloned().flatten(), updated_at: updated_page3.first().cloned().flatten(), }], next_cursor: None, @@ -1066,6 +1074,7 @@ async fn test_get_thread_contents() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some(ts.into()), + recency_at: page.items[0].updated_at.clone(), updated_at: page.items[0].updated_at.clone(), }], next_cursor: None, @@ -1421,6 +1430,7 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some(ts.to_string()), + recency_at: updated_page1.first().cloned().flatten(), updated_at: updated_page1.first().cloned().flatten(), }, ThreadItem { @@ -1439,6 +1449,7 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() { model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some(ts.to_string()), + recency_at: updated_page1.get(1).cloned().flatten(), updated_at: updated_page1.get(1).cloned().flatten(), }, ], diff --git a/codex-rs/state/migrations/0039_threads_recency_at.sql b/codex-rs/state/migrations/0039_threads_recency_at.sql new file mode 100644 index 0000000000..ccbf79f05f --- /dev/null +++ b/codex-rs/state/migrations/0039_threads_recency_at.sql @@ -0,0 +1,28 @@ +ALTER TABLE threads ADD COLUMN recency_at INTEGER NOT NULL DEFAULT 0; +ALTER TABLE threads ADD COLUMN recency_at_ms INTEGER NOT NULL DEFAULT 0; + +UPDATE threads +SET recency_at = updated_at, + recency_at_ms = updated_at_ms; + +-- Older binaries can open databases migrated by newer binaries. Seed recency +-- when one of those binaries inserts a thread without the new columns. +CREATE TRIGGER threads_recency_at_after_insert +AFTER INSERT ON threads +WHEN NEW.recency_at_ms = 0 +BEGIN + UPDATE threads + SET recency_at = NEW.updated_at, + recency_at_ms = COALESCE(NEW.updated_at_ms, NEW.updated_at * 1000) + WHERE id = NEW.id; +END; + +CREATE INDEX idx_threads_recency_at_ms + ON threads(recency_at_ms DESC, id DESC); + +CREATE INDEX idx_threads_archived_cwd_recency_at_ms + ON threads(archived, cwd, recency_at_ms DESC, id DESC); + +CREATE INDEX idx_threads_visible_recency_at_ms + ON threads(archived, recency_at_ms DESC, id DESC) + WHERE preview <> ''; diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 6f5c87b51f..4d25965c44 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -544,6 +544,7 @@ mod tests { rollout_path: PathBuf::from("/tmp/a.jsonl"), created_at, updated_at: created_at, + recency_at: created_at, source: "cli".to_string(), thread_source: None, agent_path: None, diff --git a/codex-rs/state/src/migrations.rs b/codex-rs/state/src/migrations.rs index 0e12f17c45..187e1fb33d 100644 --- a/codex-rs/state/src/migrations.rs +++ b/codex-rs/state/src/migrations.rs @@ -1,5 +1,6 @@ use std::borrow::Cow; +use sqlx::SqlitePool; use sqlx::migrate::Migrator; pub(crate) static STATE_MIGRATOR: Migrator = sqlx::migrate!("./migrations"); @@ -39,3 +40,49 @@ pub(crate) fn runtime_goals_migrator() -> Migrator { pub(crate) fn runtime_memories_migrator() -> Migrator { runtime_migrator(&MEMORIES_MIGRATOR) } + +pub(crate) async fn repair_legacy_recency_migration_version( + pool: &SqlitePool, + migrator: &Migrator, +) -> anyhow::Result<()> { + let Some(recency_migration) = migrator + .migrations + .iter() + .find(|migration| migration.version == 39) + else { + return Ok(()); + }; + let migrations_table_exists = sqlx::query_scalar::<_, i64>( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_sqlx_migrations'", + ) + .fetch_optional(pool) + .await? + .is_some(); + if !migrations_table_exists { + return Ok(()); + } + + sqlx::query( + r#" +UPDATE _sqlx_migrations +SET version = ?, description = ? +WHERE version = ? + AND checksum = ? + AND NOT EXISTS ( + SELECT 1 FROM _sqlx_migrations WHERE version = ? + ) + "#, + ) + .bind(recency_migration.version) + .bind(recency_migration.description.as_ref()) + .bind(38_i64) + .bind(recency_migration.checksum.as_ref()) + .bind(recency_migration.version) + .execute(pool) + .await?; + Ok(()) +} + +#[cfg(test)] +#[path = "migrations_tests.rs"] +mod tests; diff --git a/codex-rs/state/src/migrations_tests.rs b/codex-rs/state/src/migrations_tests.rs new file mode 100644 index 0000000000..d11cc13ea1 --- /dev/null +++ b/codex-rs/state/src/migrations_tests.rs @@ -0,0 +1,198 @@ +use std::borrow::Cow; + +use sqlx::Row; +use sqlx::migrate::Migration; +use sqlx::migrate::Migrator; +use sqlx::sqlite::SqlitePoolOptions; + +use super::STATE_MIGRATOR; +use super::repair_legacy_recency_migration_version; + +fn migrator_through(version: i64) -> Migrator { + Migrator { + migrations: Cow::Owned( + STATE_MIGRATOR + .migrations + .iter() + .filter(|migration| migration.version <= version) + .cloned() + .collect(), + ), + ignore_missing: STATE_MIGRATOR.ignore_missing, + locking: STATE_MIGRATOR.locking, + table_name: STATE_MIGRATOR.table_name.clone(), + create_schemas: STATE_MIGRATOR.create_schemas.clone(), + no_tx: STATE_MIGRATOR.no_tx, + } +} + +#[tokio::test] +async fn recency_migration_backfills_and_seeds_old_binary_inserts() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("in-memory database should open"); + migrator_through(/*version*/ 37) + .run(&pool) + .await + .expect("pre-recency migrations should apply"); + + sqlx::query( + r#" +INSERT INTO threads ( + id, + rollout_path, + created_at, + updated_at, + created_at_ms, + updated_at_ms, + source, + model_provider, + cwd, + title, + sandbox_policy, + approval_mode +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "#, + ) + .bind("00000000-0000-0000-0000-000000000001") + .bind("/tmp/first.jsonl") + .bind(1_700_000_000_i64) + .bind(1_700_000_100_i64) + .bind(1_700_000_000_123_i64) + .bind(1_700_000_100_456_i64) + .bind("cli") + .bind("openai") + .bind("/tmp") + .bind("") + .bind("read-only") + .bind("on-request") + .execute(&pool) + .await + .expect("legacy row should insert"); + + STATE_MIGRATOR + .run(&pool) + .await + .expect("recency migration should apply"); + + let backfilled = sqlx::query( + "SELECT updated_at, updated_at_ms, recency_at, recency_at_ms FROM threads WHERE id = ?", + ) + .bind("00000000-0000-0000-0000-000000000001") + .fetch_one(&pool) + .await + .expect("backfilled row should load"); + assert_eq!(backfilled.get::("recency_at"), 1_700_000_100); + assert_eq!(backfilled.get::("recency_at_ms"), 1_700_000_100_456); + + sqlx::query( + r#" +INSERT INTO threads ( + id, + rollout_path, + created_at, + updated_at, + created_at_ms, + updated_at_ms, + source, + model_provider, + cwd, + title, + sandbox_policy, + approval_mode +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "#, + ) + .bind("00000000-0000-0000-0000-000000000002") + .bind("/tmp/second.jsonl") + .bind(1_700_000_200_i64) + .bind(1_700_000_300_i64) + .bind(1_700_000_200_123_i64) + .bind(1_700_000_300_456_i64) + .bind("cli") + .bind("openai") + .bind("/tmp") + .bind("") + .bind("read-only") + .bind("on-request") + .execute(&pool) + .await + .expect("old-binary row should insert"); + + let seeded = sqlx::query("SELECT recency_at, recency_at_ms FROM threads WHERE id = ?") + .bind("00000000-0000-0000-0000-000000000002") + .fetch_one(&pool) + .await + .expect("old-binary row should load"); + assert_eq!(seeded.get::("recency_at"), 1_700_000_300); + assert_eq!(seeded.get::("recency_at_ms"), 1_700_000_300_456); +} + +#[tokio::test] +async fn repairs_recency_migration_that_was_applied_as_version_38() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("in-memory database should open"); + migrator_through(/*version*/ 37) + .run(&pool) + .await + .expect("pre-recency migrations should apply"); + + let recency_migration = STATE_MIGRATOR + .migrations + .iter() + .find(|migration| migration.version == 39) + .expect("recency migration should exist"); + let mut legacy_migrations = STATE_MIGRATOR + .migrations + .iter() + .filter(|migration| migration.version <= 37) + .cloned() + .collect::>(); + legacy_migrations.push(Migration::new( + 38, + recency_migration.description.clone(), + recency_migration.migration_type, + recency_migration.sql.clone(), + recency_migration.no_tx, + )); + let legacy_recency_migrator = Migrator::with_migrations(legacy_migrations); + legacy_recency_migrator + .run(&pool) + .await + .expect("legacy recency migration should apply as version 38"); + + repair_legacy_recency_migration_version(&pool, &STATE_MIGRATOR) + .await + .expect("legacy migration history should be repaired"); + STATE_MIGRATOR + .run(&pool) + .await + .expect("current migrations should apply after repair"); + + let applied = sqlx::query( + "SELECT version, checksum FROM _sqlx_migrations WHERE version >= 38 ORDER BY version", + ) + .fetch_all(&pool) + .await + .expect("applied migrations should load") + .into_iter() + .map(|row| { + ( + row.get::("version"), + row.get::, _>("checksum"), + ) + }) + .collect::>(); + let expected = STATE_MIGRATOR + .migrations + .iter() + .filter(|migration| migration.version >= 38) + .map(|migration| (migration.version, migration.checksum.to_vec())) + .collect::>(); + assert_eq!(applied, expected); +} diff --git a/codex-rs/state/src/model/thread_metadata.rs b/codex-rs/state/src/model/thread_metadata.rs index a30f7e479b..c25c458986 100644 --- a/codex-rs/state/src/model/thread_metadata.rs +++ b/codex-rs/state/src/model/thread_metadata.rs @@ -18,6 +18,8 @@ pub enum SortKey { CreatedAt, /// Sort by the thread's last update timestamp. UpdatedAt, + /// Sort by the thread's product recency timestamp. + RecencyAt, } /// Sort direction to use when listing threads. @@ -32,6 +34,8 @@ pub enum SortDirection { pub struct Anchor { /// The timestamp component of the anchor. pub ts: DateTime, + /// The thread ID component used to disambiguate equal recency timestamps. + pub id: Option, } /// A single page of thread metadata results. @@ -67,6 +71,8 @@ pub struct ThreadMetadata { pub created_at: DateTime, /// The last update timestamp. pub updated_at: DateTime, + /// The product recency timestamp. + pub recency_at: DateTime, /// The session source (stringified enum). pub source: String, /// Optional analytics source classification for this thread. @@ -120,6 +126,8 @@ pub struct ThreadMetadataBuilder { pub created_at: DateTime, /// The last update timestamp, if known. pub updated_at: Option>, + /// The product recency timestamp, if known. + pub recency_at: Option>, /// The session source. pub source: SessionSource, /// Optional analytics source classification for this thread. @@ -163,6 +171,7 @@ impl ThreadMetadataBuilder { rollout_path, created_at, updated_at: None, + recency_at: None, source, thread_source: None, agent_nickname: None, @@ -190,11 +199,16 @@ impl ThreadMetadataBuilder { .updated_at .map(canonicalize_datetime) .unwrap_or(created_at); + let recency_at = self + .recency_at + .map(canonicalize_datetime) + .unwrap_or(updated_at); ThreadMetadata { id: self.id, rollout_path: self.rollout_path.clone(), created_at, updated_at, + recency_at, source, thread_source: self.thread_source.clone(), agent_nickname: self.agent_nickname.clone(), @@ -340,6 +354,7 @@ pub(crate) struct ThreadRow { rollout_path: String, created_at: i64, updated_at: i64, + recency_at: i64, source: String, thread_source: Option, agent_nickname: Option, @@ -369,6 +384,7 @@ impl ThreadRow { rollout_path: row.try_get("rollout_path")?, created_at: row.try_get("created_at")?, updated_at: row.try_get("updated_at")?, + recency_at: row.try_get("recency_at")?, source: row.try_get("source")?, thread_source: row.try_get("thread_source")?, agent_nickname: row.try_get("agent_nickname")?, @@ -402,6 +418,7 @@ impl TryFrom for ThreadMetadata { rollout_path, created_at, updated_at, + recency_at, source, thread_source, agent_nickname, @@ -432,6 +449,7 @@ impl TryFrom for ThreadMetadata { rollout_path: PathBuf::from(rollout_path), created_at: epoch_millis_to_datetime(created_at)?, updated_at: epoch_millis_to_datetime(updated_at)?, + recency_at: epoch_millis_to_datetime(recency_at)?, source, thread_source, agent_nickname, @@ -461,8 +479,12 @@ pub(crate) fn anchor_from_item(item: &ThreadMetadata, sort_key: SortKey) -> Opti let ts = match sort_key { SortKey::CreatedAt => item.created_at, SortKey::UpdatedAt => item.updated_at, + SortKey::RecencyAt => item.recency_at, }; - Some(Anchor { ts }) + Some(Anchor { + ts, + id: (sort_key == SortKey::RecencyAt).then_some(item.id), + }) } pub(crate) fn datetime_to_epoch_millis(dt: DateTime) -> i64 { @@ -519,6 +541,7 @@ mod tests { rollout_path: "/tmp/rollout-123.jsonl".to_string(), created_at: 1_700_000_000, updated_at: 1_700_000_100, + recency_at: 1_700_000_100, source: "cli".to_string(), thread_source: None, agent_nickname: None, @@ -549,6 +572,7 @@ mod tests { rollout_path: PathBuf::from("/tmp/rollout-123.jsonl"), created_at: DateTime::::from_timestamp(1_700_000_000, 0).expect("timestamp"), updated_at: DateTime::::from_timestamp(1_700_000_100, 0).expect("timestamp"), + recency_at: DateTime::::from_timestamp(1_700_000_100, 0).expect("timestamp"), source: "cli".to_string(), thread_source: None, agent_nickname: None, diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index 0e5dc85586..4b1d264d0b 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -17,6 +17,7 @@ use crate::ThreadMetadata; use crate::ThreadMetadataBuilder; use crate::ThreadsPage; use crate::apply_rollout_item; +use crate::migrations::repair_legacy_recency_migration_version; use crate::migrations::runtime_goals_migrator; use crate::migrations::runtime_logs_migrator; use crate::migrations::runtime_memories_migrator; @@ -160,6 +161,7 @@ pub struct StateRuntime { thread_goals: GoalStore, memories: MemoryStore, thread_updated_at_millis: Arc, + thread_recency_at_millis: Arc, } impl StateRuntime { @@ -262,32 +264,36 @@ impl StateRuntime { return Err(err); } let started = Instant::now(); - let thread_updated_at_millis_result: anyhow::Result> = - sqlx::query_scalar("SELECT MAX(threads.updated_at_ms) FROM threads") - .fetch_one(pool.as_ref()) - .await - .map_err(anyhow::Error::from); + let thread_timestamp_millis_result: anyhow::Result<(Option, Option)> = + sqlx::query_as( + "SELECT MAX(threads.updated_at_ms), MAX(threads.recency_at_ms) FROM threads", + ) + .fetch_one(pool.as_ref()) + .await + .map_err(anyhow::Error::from); crate::telemetry::record_init_result( telemetry_override, DbKind::State, "post_init_query", started.elapsed(), - &thread_updated_at_millis_result, + &thread_timestamp_millis_result, ); - let thread_updated_at_millis = match thread_updated_at_millis_result { - Ok(value) => value, - Err(err) => { - close_sqlite_pools(&[ - pool.as_ref(), - logs_pool.as_ref(), - goals_pool.as_ref(), - memories_pool.as_ref(), - ]) - .await; - return Err(err); - } - }; + let (thread_updated_at_millis, thread_recency_at_millis) = + match thread_timestamp_millis_result { + Ok(value) => value, + Err(err) => { + close_sqlite_pools(&[ + pool.as_ref(), + logs_pool.as_ref(), + goals_pool.as_ref(), + memories_pool.as_ref(), + ]) + .await; + return Err(err); + } + }; let thread_updated_at_millis = thread_updated_at_millis.unwrap_or(0); + let thread_recency_at_millis = thread_recency_at_millis.unwrap_or(0); let runtime = Arc::new(Self { thread_goals: GoalStore::new(Arc::clone(&goals_pool)), memories: MemoryStore::new(Arc::clone(&memories_pool), Arc::clone(&pool)), @@ -296,6 +302,7 @@ impl StateRuntime { codex_home, default_provider, thread_updated_at_millis: Arc::new(AtomicI64::new(thread_updated_at_millis)), + thread_recency_at_millis: Arc::new(AtomicI64::new(thread_recency_at_millis)), }); if let Err(err) = runtime.run_logs_startup_maintenance().await { warn!( @@ -420,7 +427,13 @@ async fn open_sqlite( let pool = pool_result .map_err(|source| recovery::RuntimeDbInitError::new(spec.label, "open", path, source))?; let started = Instant::now(); - let migrate_result = migrator.run(&pool).await.map_err(anyhow::Error::from); + let migrate_result = async { + if matches!(spec.kind, DbKind::State) { + repair_legacy_recency_migration_version(&pool, migrator).await?; + } + migrator.run(&pool).await.map_err(anyhow::Error::from) + } + .await; crate::telemetry::record_init_result( telemetry_override, spec.kind, diff --git a/codex-rs/state/src/runtime/memories.rs b/codex-rs/state/src/runtime/memories.rs index 895c254350..9b2c337f5f 100644 --- a/codex-rs/state/src/runtime/memories.rs +++ b/codex-rs/state/src/runtime/memories.rs @@ -175,6 +175,7 @@ SELECT threads.rollout_path, threads.created_at_ms AS created_at, threads.updated_at_ms AS updated_at, + threads.recency_at_ms AS recency_at, threads.source, threads.thread_source, threads.agent_path, @@ -545,6 +546,7 @@ SELECT threads.rollout_path, threads.created_at_ms AS created_at, threads.updated_at_ms AS updated_at, + threads.recency_at_ms AS recency_at, threads.source, threads.thread_source, threads.agent_nickname, diff --git a/codex-rs/state/src/runtime/test_support.rs b/codex-rs/state/src/runtime/test_support.rs index 848a193335..c029a0ee2d 100644 --- a/codex-rs/state/src/runtime/test_support.rs +++ b/codex-rs/state/src/runtime/test_support.rs @@ -47,6 +47,7 @@ pub(super) fn test_thread_metadata( rollout_path: codex_home.join(format!("rollout-{thread_id}.jsonl")), created_at: now, updated_at: now, + recency_at: now, source: "cli".to_string(), thread_source: None, agent_nickname: None, diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index 6cebf27e87..576f760264 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -1,6 +1,7 @@ use super::*; use crate::SortDirection; use codex_protocol::protocol::SessionSource; +use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; impl StateRuntime { @@ -12,6 +13,7 @@ SELECT threads.rollout_path, threads.created_at_ms AS created_at, threads.updated_at_ms AS updated_at, + threads.recency_at_ms AS recency_at, threads.source, threads.thread_source, threads.agent_nickname, @@ -498,6 +500,7 @@ ON CONFLICT(child_thread_id) DO NOTHING metadata: &crate::ThreadMetadata, ) -> anyhow::Result { let updated_at = self.allocate_thread_updated_at(metadata.updated_at)?; + let recency_at = self.allocate_thread_recency_at(metadata.recency_at)?; let preview = metadata_preview(metadata); let result = sqlx::query( r#" @@ -506,8 +509,10 @@ INSERT INTO threads ( rollout_path, created_at, updated_at, + recency_at, created_at_ms, updated_at_ms, + recency_at_ms, source, thread_source, agent_nickname, @@ -530,7 +535,7 @@ INSERT INTO threads ( git_branch, git_origin_url, memory_mode -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO NOTHING "#, ) @@ -538,8 +543,10 @@ ON CONFLICT(id) DO NOTHING .bind(metadata.rollout_path.display().to_string()) .bind(datetime_to_epoch_seconds(metadata.created_at)) .bind(datetime_to_epoch_seconds(updated_at)) + .bind(datetime_to_epoch_seconds(recency_at)) .bind(datetime_to_epoch_millis(metadata.created_at)) .bind(datetime_to_epoch_millis(updated_at)) + .bind(datetime_to_epoch_millis(recency_at)) .bind(metadata.source.as_str()) .bind( metadata @@ -621,6 +628,32 @@ ON CONFLICT(id) DO NOTHING Ok(result.rows_affected() > 0) } + pub async fn touch_thread_recency_at( + &self, + thread_id: ThreadId, + recency_at: DateTime, + ) -> anyhow::Result { + let recency_at = self.allocate_thread_recency_at(recency_at)?; + let recency_at_seconds = datetime_to_epoch_seconds(recency_at); + let recency_at_millis = datetime_to_epoch_millis(recency_at); + let result = sqlx::query( + r#" +UPDATE threads +SET + recency_at = MAX(?, MAX(?, recency_at_ms + 1) / 1000), + recency_at_ms = MAX(?, recency_at_ms + 1) +WHERE id = ? + "#, + ) + .bind(recency_at_seconds) + .bind(recency_at_millis) + .bind(recency_at_millis) + .bind(thread_id.to_string()) + .execute(self.pool.as_ref()) + .await?; + Ok(result.rows_affected() > 0) + } + /// Allocate a persisted `updated_at` value for thread-list cursor ordering. /// /// We keep a process-local high-water mark so hot rollout writes can get unique, @@ -631,42 +664,56 @@ ON CONFLICT(id) DO NOTHING &self, updated_at: DateTime, ) -> anyhow::Result> { - let candidate = datetime_to_epoch_millis(updated_at); - let allocated = loop { - let current = self.thread_updated_at_millis.load(Ordering::Relaxed); - - // New wall-clock time: advance the process-local high-water mark and use it as-is. - if candidate > current { - if self - .thread_updated_at_millis - .compare_exchange(current, candidate, Ordering::Relaxed, Ordering::Relaxed) - .is_ok() - { - break candidate; - } - continue; - } - - // Older timestamps come from backfill/repair paths that preserve rollout mtimes. - // Do not drag historical rows forward just because this process has seen newer writes. - if candidate.saturating_add(1000) <= current { - break candidate; - } - - // Same hot one-second bucket as the current high-water mark. Allocate the next - // millisecond so updated_at remains unique and cursor-orderable inside the process. - let bumped = current.saturating_add(1); - if self - .thread_updated_at_millis - .compare_exchange(current, bumped, Ordering::Relaxed, Ordering::Relaxed) - .is_ok() - { - break bumped; - } - }; - epoch_millis_to_datetime(allocated) + allocate_thread_timestamp(self.thread_updated_at_millis.as_ref(), updated_at) } + fn allocate_thread_recency_at( + &self, + recency_at: DateTime, + ) -> anyhow::Result> { + allocate_thread_timestamp(self.thread_recency_at_millis.as_ref(), recency_at) + } +} + +fn allocate_thread_timestamp( + high_water_mark: &AtomicI64, + timestamp: DateTime, +) -> anyhow::Result> { + let candidate = datetime_to_epoch_millis(timestamp); + let allocated = loop { + let current = high_water_mark.load(Ordering::Relaxed); + + // New wall-clock time: advance the process-local high-water mark and use it as-is. + if candidate > current { + if high_water_mark + .compare_exchange(current, candidate, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + break candidate; + } + continue; + } + + // Older timestamps come from backfill/repair paths that preserve rollout mtimes. + // Do not drag historical rows forward just because this process has seen newer writes. + if candidate.saturating_add(1000) <= current { + break candidate; + } + + // Same hot one-second bucket as the current high-water mark. Allocate the next + // millisecond so the timestamp remains unique and cursor-orderable inside the process. + let bumped = current.saturating_add(1); + if high_water_mark + .compare_exchange(current, bumped, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + break bumped; + } + }; + epoch_millis_to_datetime(allocated) +} + +impl StateRuntime { pub async fn update_thread_git_info( &self, thread_id: ThreadId, @@ -702,6 +749,7 @@ WHERE id = ? creation_memory_mode: Option<&str>, ) -> anyhow::Result<()> { let updated_at = self.allocate_thread_updated_at(metadata.updated_at)?; + let insert_recency_at = self.allocate_thread_recency_at(metadata.recency_at)?; let preview = metadata_preview(metadata); // Backfill/reconcile callers merge existing git info before upserting, but that // read/modify/write is not atomic. Preserve non-null SQLite git fields here so @@ -713,8 +761,10 @@ INSERT INTO threads ( rollout_path, created_at, updated_at, + recency_at, created_at_ms, updated_at_ms, + recency_at_ms, source, thread_source, agent_nickname, @@ -737,13 +787,15 @@ INSERT INTO threads ( git_branch, git_origin_url, memory_mode -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET rollout_path = excluded.rollout_path, created_at = excluded.created_at, updated_at = excluded.updated_at, + recency_at = threads.recency_at, created_at_ms = excluded.created_at_ms, updated_at_ms = excluded.updated_at_ms, + recency_at_ms = threads.recency_at_ms, source = excluded.source, thread_source = excluded.thread_source, agent_nickname = excluded.agent_nickname, @@ -771,8 +823,10 @@ ON CONFLICT(id) DO UPDATE SET .bind(metadata.rollout_path.display().to_string()) .bind(datetime_to_epoch_seconds(metadata.created_at)) .bind(datetime_to_epoch_seconds(updated_at)) + .bind(datetime_to_epoch_seconds(insert_recency_at)) .bind(datetime_to_epoch_millis(metadata.created_at)) .bind(datetime_to_epoch_millis(updated_at)) + .bind(datetime_to_epoch_millis(insert_recency_at)) .bind(metadata.source.as_str()) .bind( metadata @@ -1079,6 +1133,7 @@ SELECT threads.rollout_path, threads.created_at_ms AS created_at, threads.updated_at_ms AS updated_at, + threads.recency_at_ms AS recency_at, threads.source, threads.thread_source, threads.agent_nickname, @@ -1197,6 +1252,7 @@ pub(super) fn push_thread_filters<'a>( let column = match sort_key { SortKey::CreatedAt => "threads.created_at_ms", SortKey::UpdatedAt => "threads.updated_at_ms", + SortKey::RecencyAt => "threads.recency_at_ms", }; let operator = match sort_direction { SortDirection::Asc => ">", @@ -1208,6 +1264,19 @@ pub(super) fn push_thread_filters<'a>( builder.push(operator); builder.push(" "); builder.push_bind(anchor_ts); + if sort_key == SortKey::RecencyAt + && let Some(anchor_id) = anchor.id + { + builder.push(" OR ("); + builder.push(column); + builder.push(" = "); + builder.push_bind(anchor_ts); + builder.push(" AND threads.id "); + builder.push(operator); + builder.push(" "); + builder.push_bind(anchor_id.to_string()); + builder.push(")"); + } builder.push(")"); } } @@ -1232,6 +1301,7 @@ pub(super) fn push_thread_order_and_limit( let order_column = match sort_key { SortKey::CreatedAt => "threads.created_at_ms", SortKey::UpdatedAt => "threads.updated_at_ms", + SortKey::RecencyAt => "threads.recency_at_ms", }; let order_direction = match sort_direction { SortDirection::Asc => "ASC", @@ -1247,6 +1317,10 @@ pub(super) fn push_thread_order_and_limit( builder.push(order_column); builder.push(" "); builder.push(order_direction); + if sort_key == SortKey::RecencyAt { + builder.push(", threads.id "); + builder.push(order_direction); + } builder.push(" LIMIT "); builder.push_bind(limit as i64); } @@ -1521,6 +1595,7 @@ mod tests { let anchor = Anchor { ts: older_updated_at, + id: None, }; let model_providers = ["test-provider".to_string()]; let page = runtime @@ -1547,6 +1622,7 @@ mod tests { Some(Anchor { ts: DateTime::::from_timestamp_millis(1_700_000_200_000) .expect("valid timestamp"), + id: None, }) ); @@ -1631,6 +1707,7 @@ mod tests { Some(Anchor { ts: DateTime::::from_timestamp_millis(1_700_000_300_000) .expect("valid timestamp"), + id: None, }) ); @@ -1693,6 +1770,7 @@ mod tests { ]; let anchor = Anchor { ts: DateTime::::from_timestamp(1_700_000_000, 0).expect("valid timestamp"), + id: None, }; for (sort_key, visible_index, cwd_index) in [ ( @@ -1705,6 +1783,11 @@ mod tests { "idx_threads_visible_updated_at_ms", "idx_threads_archived_cwd_updated_at_ms", ), + ( + SortKey::RecencyAt, + "idx_threads_visible_recency_at_ms", + "idx_threads_archived_cwd_recency_at_ms", + ), ] { for (cwd_filters, anchor, expected_index, expect_temp_sort) in [ (None, None, visible_index, false), @@ -2279,6 +2362,186 @@ mod tests { assert_eq!(persisted.preview.as_deref(), Some("first-user-message")); } + #[tokio::test] + async fn touch_thread_recency_at_is_monotonic_and_survives_stale_upsert() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) + .await + .expect("state db should initialize"); + let thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000792").expect("valid thread id"); + let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone()); + let original_recency_at = metadata.recency_at; + runtime + .upsert_thread(&metadata) + .await + .expect("initial upsert should succeed"); + + let touched_at = + DateTime::::from_timestamp_millis(1_700_001_111_123).expect("timestamp"); + assert!( + runtime + .touch_thread_recency_at(thread_id, touched_at) + .await + .expect("touch should succeed") + ); + + metadata.updated_at = + DateTime::::from_timestamp_millis(1_700_001_222_456).expect("timestamp"); + metadata.title = "updated metadata".to_string(); + assert_eq!(metadata.recency_at, original_recency_at); + runtime + .upsert_thread(&metadata) + .await + .expect("stale metadata upsert should succeed"); + + let persisted = runtime + .get_thread(thread_id) + .await + .expect("thread should load") + .expect("thread should exist"); + assert_eq!(persisted.recency_at, touched_at); + assert_eq!(persisted.updated_at, metadata.updated_at); + assert_eq!(persisted.title, "updated metadata"); + + assert!( + runtime + .touch_thread_recency_at(thread_id, original_recency_at) + .await + .expect("older touch should succeed") + ); + let persisted = runtime + .get_thread(thread_id) + .await + .expect("thread should load") + .expect("thread should exist"); + assert_eq!( + datetime_to_epoch_millis(persisted.recency_at), + datetime_to_epoch_millis(touched_at) + 1 + ); + } + + #[tokio::test] + async fn list_threads_orders_and_pages_by_recency_at() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) + .await + .expect("state db should initialize"); + let first_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000793").expect("valid thread id"); + let second_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000794").expect("valid thread id"); + let third_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000795").expect("valid thread id"); + let recency_at = + DateTime::::from_timestamp_millis(1_700_002_000_456).expect("timestamp"); + + for thread_id in [first_id, second_id, third_id] { + let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone()); + metadata.recency_at = recency_at; + runtime + .upsert_thread(&metadata) + .await + .expect("thread insert should succeed"); + } + sqlx::query("UPDATE threads SET recency_at = ?, recency_at_ms = ?") + .bind(datetime_to_epoch_seconds(recency_at)) + .bind(datetime_to_epoch_millis(recency_at)) + .execute(runtime.pool.as_ref()) + .await + .expect("recency timestamps should match"); + + let first_page = runtime + .list_threads( + /*page_size*/ 1, + ThreadFilterOptions { + archived_only: false, + allowed_sources: &[], + model_providers: None, + cwd_filters: None, + anchor: None, + sort_key: SortKey::RecencyAt, + sort_direction: SortDirection::Desc, + search_term: None, + }, + ) + .await + .expect("list should succeed"); + assert_eq!( + first_page + .items + .iter() + .map(|item| item.id) + .collect::>(), + vec![third_id] + ); + assert_eq!( + first_page.next_anchor, + Some(Anchor { + ts: recency_at, + id: Some(third_id), + }) + ); + + let second_page = runtime + .list_threads( + /*page_size*/ 1, + ThreadFilterOptions { + archived_only: false, + allowed_sources: &[], + model_providers: None, + cwd_filters: None, + anchor: first_page.next_anchor.as_ref(), + sort_key: SortKey::RecencyAt, + sort_direction: SortDirection::Desc, + search_term: None, + }, + ) + .await + .expect("second list should succeed"); + assert_eq!( + second_page + .items + .iter() + .map(|item| item.id) + .collect::>(), + vec![second_id] + ); + assert_eq!( + second_page.next_anchor, + Some(Anchor { + ts: recency_at, + id: Some(second_id), + }) + ); + + let third_page = runtime + .list_threads( + /*page_size*/ 1, + ThreadFilterOptions { + archived_only: false, + allowed_sources: &[], + model_providers: None, + cwd_filters: None, + anchor: second_page.next_anchor.as_ref(), + sort_key: SortKey::RecencyAt, + sort_direction: SortDirection::Desc, + search_term: None, + }, + ) + .await + .expect("third list should succeed"); + assert_eq!( + third_page + .items + .iter() + .map(|item| item.id) + .collect::>(), + vec![first_id] + ); + assert_eq!(third_page.next_anchor, None); + } + #[tokio::test] async fn thread_updated_at_uses_unique_epoch_millis_and_reads_legacy_seconds() { let codex_home = unique_temp_dir(); @@ -2295,8 +2558,10 @@ mod tests { DateTime::::from_timestamp_millis(1_700_001_111_123).expect("timestamp millis"); let mut first = test_thread_metadata(&codex_home, first_id, codex_home.clone()); first.updated_at = updated_at; + first.recency_at = updated_at; let mut second = test_thread_metadata(&codex_home, second_id, codex_home.clone()); second.updated_at = updated_at; + second.recency_at = updated_at; runtime .upsert_thread(&first) @@ -2325,6 +2590,14 @@ mod tests { datetime_to_epoch_millis(second.updated_at), 1_700_001_111_124 ); + assert_eq!( + datetime_to_epoch_millis(first.recency_at), + 1_700_001_111_123 + ); + assert_eq!( + datetime_to_epoch_millis(second.recency_at), + 1_700_001_111_124 + ); let second_row: (i64, i64, Option, Option) = sqlx::query_as( "SELECT created_at, updated_at, created_at_ms, updated_at_ms FROM threads WHERE id = ?", ) diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index d02b78d237..7eda54a9c0 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -535,6 +535,9 @@ fn stored_thread_from_state( updated_at: metadata .and_then(|metadata| metadata.updated_at) .unwrap_or_else(Utc::now), + recency_at: metadata + .and_then(|metadata| metadata.advance_recency_at.or(metadata.updated_at)) + .unwrap_or_else(Utc::now), archived_at: None, cwd: metadata .and_then(|metadata| metadata.cwd.clone()) diff --git a/codex-rs/thread-store/src/local/archive_thread.rs b/codex-rs/thread-store/src/local/archive_thread.rs index bbe5c3de51..eb4a48b680 100644 --- a/codex-rs/thread-store/src/local/archive_thread.rs +++ b/codex-rs/thread-store/src/local/archive_thread.rs @@ -174,5 +174,6 @@ mod tests { .expect("thread metadata should exist"); assert_eq!(updated.rollout_path, archived_path); assert!(updated.archived_at.is_some()); + assert_eq!(updated.recency_at, metadata.recency_at); } } diff --git a/codex-rs/thread-store/src/local/helpers.rs b/codex-rs/thread-store/src/local/helpers.rs index 5acdcf09b6..f48a0136c0 100644 --- a/codex-rs/thread-store/src/local/helpers.rs +++ b/codex-rs/thread-store/src/local/helpers.rs @@ -106,6 +106,7 @@ pub(super) fn stored_thread_from_rollout_item( .or_else(|| thread_id_from_rollout_path(item.path.as_path()))?; let created_at = parse_rfc3339(item.created_at.as_deref()).unwrap_or_else(Utc::now); let updated_at = parse_rfc3339(item.updated_at.as_deref()).unwrap_or(created_at); + let recency_at = parse_rfc3339(item.recency_at.as_deref()).unwrap_or(updated_at); let archived_at = archived.then_some(updated_at); let git_info = git_info_from_parts( item.git_sha.clone(), @@ -136,6 +137,7 @@ pub(super) fn stored_thread_from_rollout_item( reasoning_effort: None, created_at, updated_at, + recency_at, archived_at, cwd: item.cwd.unwrap_or_default(), cli_version: item.cli_version.unwrap_or_default(), diff --git a/codex-rs/thread-store/src/local/list_threads.rs b/codex-rs/thread-store/src/local/list_threads.rs index fc3b5034ad..03ae163afe 100644 --- a/codex-rs/thread-store/src/local/list_threads.rs +++ b/codex-rs/thread-store/src/local/list_threads.rs @@ -34,6 +34,7 @@ pub(super) async fn list_threads( let sort_key = match params.sort_key { ThreadSortKey::CreatedAt => codex_rollout::ThreadSortKey::CreatedAt, ThreadSortKey::UpdatedAt => codex_rollout::ThreadSortKey::UpdatedAt, + ThreadSortKey::RecencyAt => codex_rollout::ThreadSortKey::RecencyAt, }; let sort_direction = match params.sort_direction { SortDirection::Asc => codex_rollout::SortDirection::Asc, diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index dd49fd2a59..550e7d56bb 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -314,10 +314,16 @@ mod tests { use codex_protocol::ThreadId; use codex_protocol::models::BaseInstructions; + use codex_protocol::models::FunctionCallOutputPayload; + use codex_protocol::models::MessagePhase; + use codex_protocol::models::ResponseItem; + use codex_protocol::protocol::AgentMessageEvent; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::ThreadMemoryMode; + use codex_protocol::protocol::TurnCompleteEvent; + use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; use tempfile::TempDir; @@ -449,6 +455,92 @@ mod tests { assert_eq!(metadata.title, "observed append"); } + #[tokio::test] + async fn live_thread_output_advances_updated_at_but_not_recency_at() { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + let runtime = codex_state::StateRuntime::init( + config.sqlite_home.clone(), + config.default_model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + let store = Arc::new(LocalThreadStore::new(config, Some(runtime.clone()))); + let thread_id = ThreadId::default(); + let live_thread = LiveThread::create(store, create_thread_params(thread_id)) + .await + .expect("create live thread"); + + live_thread + .append_items(&[user_message_item("start thread")]) + .await + .expect("append initial user message"); + live_thread.flush().await.expect("flush thread"); + let before_turn_start = runtime + .get_thread(thread_id) + .await + .expect("sqlite metadata read") + .expect("sqlite metadata"); + + live_thread + .append_items(&[RolloutItem::EventMsg(EventMsg::TurnStarted( + TurnStartedEvent { + turn_id: "turn-1".to_string(), + trace_id: None, + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }, + ))]) + .await + .expect("append turn start"); + live_thread.flush().await.expect("flush thread"); + let after_turn_start = runtime + .get_thread(thread_id) + .await + .expect("sqlite metadata read") + .expect("sqlite metadata"); + assert!(after_turn_start.recency_at > before_turn_start.recency_at); + + live_thread + .append_items(&[ + RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent { + message: "commentary".to_string(), + phase: Some(MessagePhase::Commentary), + memory_citation: None, + })), + RolloutItem::ResponseItem(ResponseItem::FunctionCallOutput { + call_id: "call-1".to_string(), + output: FunctionCallOutputPayload::from_text("tool output".to_string()), + metadata: None, + }), + RolloutItem::EventMsg(EventMsg::TokenCount( + codex_protocol::protocol::TokenCountEvent { + info: None, + rate_limits: None, + }, + )), + RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + })), + ]) + .await + .expect("append post-start items"); + live_thread.flush().await.expect("flush thread"); + let completed = runtime + .get_thread(thread_id) + .await + .expect("sqlite metadata read") + .expect("sqlite metadata"); + + assert!(completed.updated_at > after_turn_start.updated_at); + assert_eq!(completed.recency_at, after_turn_start.recency_at); + } + #[tokio::test] async fn live_thread_shutdown_does_not_materialize_empty_thread_metadata() { let home = TempDir::new().expect("temp dir"); diff --git a/codex-rs/thread-store/src/local/read_thread.rs b/codex-rs/thread-store/src/local/read_thread.rs index 4778dd69d9..a1e1c59190 100644 --- a/codex-rs/thread-store/src/local/read_thread.rs +++ b/codex-rs/thread-store/src/local/read_thread.rs @@ -55,6 +55,7 @@ pub(super) async fn read_thread( && (params.include_archived || rollout_thread.archived_at.is_none()) && !rollout_thread.preview.is_empty() { + rollout_thread.recency_at = thread.recency_at; if thread.name.is_some() { rollout_thread.name = thread.name; } @@ -115,6 +116,7 @@ pub(super) async fn read_thread_by_rollout_path( }); } if let Some(metadata) = read_sqlite_metadata(store, thread.thread_id).await { + thread.recency_at = metadata.recency_at; let existing_git_info = thread.git_info.take(); let (fallback_sha, fallback_branch, fallback_origin_url) = match existing_git_info { Some(info) => ( @@ -340,6 +342,7 @@ async fn stored_thread_from_sqlite_metadata( reasoning_effort: metadata.reasoning_effort, created_at: metadata.created_at, updated_at: metadata.updated_at, + recency_at: metadata.recency_at, archived_at: metadata.archived_at, cwd: metadata.cwd, cli_version: metadata.cli_version, @@ -406,6 +409,7 @@ fn stored_thread_from_meta_line( reasoning_effort: None, created_at, updated_at, + recency_at: updated_at, archived_at: archived.then_some(updated_at), cwd: meta_line.meta.cwd, cli_version: meta_line.meta.cli_version, @@ -547,6 +551,10 @@ mod tests { ); builder.model_provider = Some(config.default_model_provider_id.clone()); builder.git_branch = Some("sqlite-branch".to_string()); + let recency_at = chrono::DateTime::parse_from_rfc3339("2026-01-03T12:00:00Z") + .expect("timestamp should parse") + .with_timezone(&Utc); + builder.recency_at = Some(recency_at); runtime .upsert_thread(&builder.build(config.default_model_provider_id.as_str())) .await @@ -562,6 +570,7 @@ mod tests { .expect("read thread by rollout path"); let git_info = thread.git_info.expect("git info should be present"); + assert_eq!(thread.recency_at, recency_at); assert_eq!(git_info.branch.as_deref(), Some("sqlite-branch")); assert_eq!( git_info.commit_hash.as_ref().map(|sha| sha.0.as_str()), diff --git a/codex-rs/thread-store/src/local/search_threads.rs b/codex-rs/thread-store/src/local/search_threads.rs index 1b7f18a32f..fc2ddd6e71 100644 --- a/codex-rs/thread-store/src/local/search_threads.rs +++ b/codex-rs/thread-store/src/local/search_threads.rs @@ -23,6 +23,10 @@ use crate::ThreadSortKey; use crate::ThreadStoreError; use crate::ThreadStoreResult; +#[cfg(test)] +#[path = "search_threads_tests.rs"] +mod tests; + struct ThreadSearchItem { item: codex_rollout::ThreadItem, snippet: String, @@ -50,6 +54,7 @@ pub(super) async fn search_threads( let sort_key = match params.sort_key { ThreadSortKey::CreatedAt => codex_rollout::ThreadSortKey::CreatedAt, ThreadSortKey::UpdatedAt => codex_rollout::ThreadSortKey::UpdatedAt, + ThreadSortKey::RecencyAt => codex_rollout::ThreadSortKey::RecencyAt, }; let sort_direction = match params.sort_direction { SortDirection::Asc => codex_rollout::SortDirection::Asc, @@ -179,8 +184,17 @@ fn cursor_from_thread_search_item( .updated_at .as_deref() .or(item.item.created_at.as_deref())?, + ThreadSortKey::RecencyAt => item + .item + .recency_at + .as_deref() + .or(item.item.updated_at.as_deref()) + .or(item.item.created_at.as_deref())?, }; - parse_cursor(timestamp) + match sort_key { + ThreadSortKey::RecencyAt => parse_cursor(&format!("{timestamp}|{}", item.item.thread_id?)), + ThreadSortKey::CreatedAt | ThreadSortKey::UpdatedAt => parse_cursor(timestamp), + } } async fn set_thread_search_result_names( diff --git a/codex-rs/thread-store/src/local/search_threads_tests.rs b/codex-rs/thread-store/src/local/search_threads_tests.rs new file mode 100644 index 0000000000..bfcf768276 --- /dev/null +++ b/codex-rs/thread-store/src/local/search_threads_tests.rs @@ -0,0 +1,29 @@ +use codex_protocol::ThreadId; +use codex_rollout::ThreadItem; +use pretty_assertions::assert_eq; + +use super::ThreadSearchItem; +use super::cursor_from_thread_search_item; +use crate::ThreadSortKey; + +#[test] +fn recency_cursor_includes_thread_id_tie_breaker() { + let thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000123") + .expect("thread ID should parse"); + let item = ThreadSearchItem { + item: ThreadItem { + thread_id: Some(thread_id), + recency_at: Some("2026-01-27T12:34:56Z".to_string()), + ..Default::default() + }, + snippet: String::new(), + }; + + let cursor = cursor_from_thread_search_item(&item, ThreadSortKey::RecencyAt) + .expect("cursor should build"); + + assert_eq!( + serde_json::to_string(&cursor).expect("cursor should serialize"), + format!("\"2026-01-27T12:34:56Z|{thread_id}\"") + ); +} diff --git a/codex-rs/thread-store/src/local/unarchive_thread.rs b/codex-rs/thread-store/src/local/unarchive_thread.rs index ad41db69ac..ee25b43424 100644 --- a/codex-rs/thread-store/src/local/unarchive_thread.rs +++ b/codex-rs/thread-store/src/local/unarchive_thread.rs @@ -196,5 +196,6 @@ mod tests { .expect("thread metadata should exist"); assert_eq!(updated.rollout_path, restored_path); assert_eq!(updated.archived_at, None); + assert_eq!(updated.recency_at, metadata.recency_at); } } diff --git a/codex-rs/thread-store/src/local/update_thread_metadata.rs b/codex-rs/thread-store/src/local/update_thread_metadata.rs index 820cb68918..d1c76ad327 100644 --- a/codex-rs/thread-store/src/local/update_thread_metadata.rs +++ b/codex-rs/thread-store/src/local/update_thread_metadata.rs @@ -204,6 +204,7 @@ async fn apply_metadata_update( .map_err(|err| ThreadStoreError::Internal { message: format!("failed to read thread metadata for {thread_id}: {err}"), })?; + let advance_recency_at = patch.advance_recency_at; if existing.is_none() && rollout_path.is_none() { let resolved = resolve_rollout_path(store, thread_id, include_archived).await?; rollout_path_archived = resolved.archived; @@ -260,6 +261,11 @@ async fn apply_metadata_update( if let Some(updated_at) = patch.updated_at { metadata.updated_at = updated_at; } + if existing.is_none() + && let Some(recency_at) = advance_recency_at + { + metadata.recency_at = recency_at; + } if let Some(source) = patch.source { metadata.source = enum_to_string(&source); } @@ -310,6 +316,18 @@ async fn apply_metadata_update( .map_err(|err| ThreadStoreError::Internal { message: format!("failed to update thread metadata for {thread_id}: {err}"), })?; + if existing.is_some() + && let Some(recency_at) = advance_recency_at + { + state_db + .touch_thread_recency_at(thread_id, recency_at) + .await + .map_err(|err| ThreadStoreError::Internal { + message: format!( + "failed to advance thread recency_at for {thread_id}: {err}" + ), + })?; + } if let Some(memory_mode) = patch.memory_mode { state_db .set_thread_memory_mode(thread_id, memory_mode_as_str(memory_mode)) diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index a595ba0919..b2953d7ef9 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -154,11 +154,17 @@ impl ThreadMetadataSync { let affects_metadata = items .iter() .any(codex_state::rollout_item_affects_thread_metadata); - let update = if affects_metadata { + let advances_recency = items + .iter() + .any(|item| matches!(item, RolloutItem::EventMsg(EventMsg::TurnStarted(_)))); + let mut update = if affects_metadata { self.observe_items(items)? } else { thread_updated_at_touch() }; + if advances_recency { + update.advance_recency_at = Some(Utc::now()); + } self.merge_pending_update(Some(update)); if !affects_metadata && !self @@ -347,6 +353,7 @@ fn update_has_metadata_facts(update: &ThreadMetadataPatch) -> bool { || update.model.is_some() || update.reasoning_effort.is_some() || update.created_at.is_some() + || update.advance_recency_at.is_some() || update.source.is_some() || update.thread_source.is_some() || update.agent_nickname.is_some() @@ -379,6 +386,7 @@ mod tests { use codex_protocol::protocol::ThreadGoal; use codex_protocol::protocol::ThreadGoalStatus; use codex_protocol::protocol::ThreadGoalUpdatedEvent; + use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; use pretty_assertions::assert_eq; @@ -495,6 +503,27 @@ mod tests { ); } + #[test] + fn turn_start_advances_recency_at_without_changing_updated_at_behavior() { + let thread_id = ThreadId::new(); + let mut sync = ThreadMetadataSync::for_resume(&resume_params(thread_id, Vec::new())); + + let update = sync + .observe_appended_items(&[RolloutItem::EventMsg(EventMsg::TurnStarted( + TurnStartedEvent { + turn_id: "turn-1".to_string(), + trace_id: None, + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }, + ))]) + .expect("turn start metadata update"); + + assert!(update.patch.updated_at.is_some()); + assert!(update.patch.advance_recency_at.is_some()); + } + #[test] fn resume_history_waits_for_append_before_flushing_metadata() { let thread_id = ThreadId::new(); diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index bb8bf2f6dc..b969c6b957 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -160,6 +160,8 @@ pub enum ThreadSortKey { CreatedAt, /// Sort by the thread last-update timestamp. UpdatedAt, + /// Sort by the thread's product recency timestamp. + RecencyAt, } /// The direction to use when listing stored threads. @@ -397,6 +399,8 @@ pub struct StoredThread { pub created_at: DateTime, /// Thread last-update timestamp. pub updated_at: DateTime, + /// Thread product-recency timestamp. + pub recency_at: DateTime, /// Thread archive timestamp, if archived. pub archived_at: Option>, /// Working directory captured for the thread. @@ -504,6 +508,8 @@ pub struct ThreadMetadataPatch { pub created_at: Option>, /// Last update timestamp for this metadata observation. pub updated_at: Option>, + /// Advance product recency to at least this timestamp. + pub advance_recency_at: Option>, /// Session source. pub source: Option, /// Optional analytics source classification. @@ -586,6 +592,9 @@ impl ThreadMetadataPatch { if next.updated_at.is_some() { self.updated_at = next.updated_at; } + if next.advance_recency_at.is_some() { + self.advance_recency_at = next.advance_recency_at; + } if next.source.is_some() { self.source = next.source; } @@ -639,6 +648,7 @@ impl ThreadMetadataPatch { && self.reasoning_effort.is_none() && self.created_at.is_none() && self.updated_at.is_none() + && self.advance_recency_at.is_none() && self.source.is_none() && self.thread_source.is_none() && self.agent_nickname.is_none() diff --git a/codex-rs/tui/src/app/loaded_threads.rs b/codex-rs/tui/src/app/loaded_threads.rs index aba08f08d9..6bd260c0b6 100644 --- a/codex-rs/tui/src/app/loaded_threads.rs +++ b/codex-rs/tui/src/app/loaded_threads.rs @@ -136,6 +136,7 @@ mod tests { model_provider: "openai".to_string(), created_at: 0, updated_at: 0, + recency_at: Some(0), status: ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 6a88c4ccf0..0d4ef9ee3d 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -2881,6 +2881,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re model_provider: "agent-provider".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: Some(rollout_path.clone()), cwd: test_path_buf("/tmp/agent").abs(), @@ -2973,6 +2974,7 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_ model_provider: "agent-provider".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/agent").abs(), @@ -3032,6 +3034,7 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() { model_provider: "read-provider".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/read").abs(), @@ -5644,6 +5647,7 @@ async fn thread_rollback_response_discards_queued_active_thread_events() { model_provider: "openai".to_string(), created_at: 0, updated_at: 0, + recency_at: Some(0), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/project").abs(), diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index bcc172ef93..ceafb71db9 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -417,6 +417,7 @@ mod tests { model_provider: "read-provider".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/read").abs(), diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 5e36fb1c9d..4049ca14a6 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -2319,6 +2319,7 @@ mod tests { model_provider: "openai".to_string(), created_at: 1, updated_at: 2, + recency_at: Some(2), status: ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/project").abs(), diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 18313bcca6..08191eaf2b 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -608,7 +608,7 @@ fn spawn_app_server_page_loader( fn sort_key_label(sort_key: ThreadSortKey) -> &'static str { match sort_key { ThreadSortKey::CreatedAt => "Created", - ThreadSortKey::UpdatedAt => "Updated", + ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => "Updated", } } @@ -1614,7 +1614,7 @@ impl PickerState { fn toggle_sort_key(&mut self) { self.sort_key = match self.sort_key { ThreadSortKey::CreatedAt => ThreadSortKey::UpdatedAt, - ThreadSortKey::UpdatedAt => ThreadSortKey::CreatedAt, + ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => ThreadSortKey::CreatedAt, }; self.start_initial_load(); } @@ -2613,7 +2613,7 @@ fn render_dense_session_lines( let updated = format_relative_time(reference, row.updated_at.or(row.created_at)); let date = match state.sort_key { ThreadSortKey::CreatedAt => created, - ThreadSortKey::UpdatedAt => updated, + ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => updated, }; let mut lines = vec![dense_summary_line(DenseSummaryInput { marker, @@ -2742,7 +2742,7 @@ fn render_footer_lines( ) -> Vec> { let date = match sort_key { ThreadSortKey::CreatedAt => created, - ThreadSortKey::UpdatedAt => updated, + ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => updated, }; let mut parts = vec![FooterPart::Date(date.to_string())]; if show_cwd { @@ -5728,6 +5728,7 @@ session_picker_view = "dense" model_provider: String::from("openai"), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), @@ -5763,6 +5764,7 @@ session_picker_view = "dense" model_provider: String::from("openai"), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), @@ -5832,6 +5834,7 @@ session_picker_view = "dense" model_provider: String::from("openai"), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), @@ -5890,6 +5893,7 @@ session_picker_view = "dense" model_provider: String::from("openai"), created_at: 1, updated_at: 2, + recency_at: Some(2), status: codex_app_server_protocol::ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp").abs(), From b668abb4888c209538df355f3deff7bc0201dbda Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Wed, 17 Jun 2026 11:57:19 -0700 Subject: [PATCH 003/112] Extract TUI plugin catalog rendering (#28768) This mechanically extracts the existing TUI plugin catalog and detail popup rendering from `chatwidget/plugins.rs` into a new `chatwidget/plugin_catalog.rs` module. `plugins.rs` now keeps the stateful plugin workflow and orchestration, while `plugin_catalog.rs` owns the presentation-heavy catalog/detail popup construction and its pure helpers. The goal is to keep `plugins.rs` focused before later plugin sharing work adds more catalog behavior. - Moves existing catalog/detail popup builders and related pure helpers into `plugin_catalog.rs` - Leaves plugin fetch/state/key handling in `plugins.rs` - Adds only minimal sibling-module visibility/import wiring - Intentionally makes no product behavior or UI changes beyond the code move --- codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui/src/chatwidget/plugin_catalog.rs | 1425 +++++++++++++++++ codex-rs/tui/src/chatwidget/plugins.rs | 1418 +--------------- 3 files changed, 1438 insertions(+), 1406 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/plugin_catalog.rs diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 60973c13d2..ab6d493d49 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -365,6 +365,7 @@ use self::skills::collect_tool_mentions; use self::skills::find_app_mentions; use self::skills::find_skill_mentions_with_tool_mentions; use self::skills::is_app_mentionable; +mod plugin_catalog; mod plugins; use self::plugins::PluginInstallAuthFlowState; use self::plugins::PluginListFetchState; diff --git a/codex-rs/tui/src/chatwidget/plugin_catalog.rs b/codex-rs/tui/src/chatwidget/plugin_catalog.rs new file mode 100644 index 0000000000..c51e2e8ea9 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/plugin_catalog.rs @@ -0,0 +1,1425 @@ +use std::path::Path; +use std::time::Duration; +use std::time::Instant; + +use super::ChatWidget; +use super::plugins::ADD_MARKETPLACE_TAB_ID; +use super::plugins::ALL_PLUGINS_TAB_ID; +use super::plugins::PLUGINS_SELECTION_VIEW_ID; +use super::plugins::PluginsCacheState; +use crate::app_event::AppEvent; +use crate::app_event::PluginLocation; +use crate::bottom_pane::ColumnWidthMode; +use crate::bottom_pane::SelectionAction; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionRowDisplay; +use crate::bottom_pane::SelectionTab; +use crate::bottom_pane::SelectionToggle; +use crate::bottom_pane::SelectionViewParams; +use crate::key_hint; +use crate::legacy_core::config::Config; +use crate::motion::MotionMode; +use crate::motion::shimmer_text; +use crate::onboarding::mark_url_hyperlink; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; +use crate::tui::FrameRequester; +use codex_app_server_protocol::PluginAvailability; +use codex_app_server_protocol::PluginDetail; +use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginListResponse; +use codex_app_server_protocol::PluginMarketplaceEntry; +use codex_app_server_protocol::PluginSource; +use codex_app_server_protocol::PluginSummary; +use codex_core_plugins::is_openai_curated_marketplace_name; +use codex_core_plugins::remote::REMOTE_WORKSPACE_MARKETPLACE_NAME; +use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME; +use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME; +use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME; +use crossterm::event::KeyCode; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::prelude::Widget; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::WidgetRef; +use ratatui::widgets::Wrap; +use unicode_width::UnicodeWidthStr; + +const INSTALLED_PLUGINS_TAB_ID: &str = "installed-plugins"; +const MARKETPLACE_TAB_ID_PREFIX: &str = "marketplace:"; +const OPENAI_CURATED_TAB_ID: &str = "marketplace:openai-curated"; +const PLUGIN_ROW_PREFIX_WIDTH: usize = 6; +const LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1); +const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100); +const APPS_HELP_ARTICLE_URL: &str = "https://help.openai.com/en/articles/11487775-apps-in-chatgpt"; + +struct DelayedLoadingHeader { + started_at: Instant, + frame_requester: FrameRequester, + animations_enabled: bool, + loading_text: String, + note: Option, +} + +impl DelayedLoadingHeader { + fn new( + frame_requester: FrameRequester, + animations_enabled: bool, + loading_text: String, + note: Option, + ) -> Self { + Self { + started_at: Instant::now(), + frame_requester, + animations_enabled, + loading_text, + note, + } + } +} + +impl Renderable for DelayedLoadingHeader { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.is_empty() { + return; + } + + let mut lines = Vec::with_capacity(3); + lines.push(Line::from("Plugins".bold())); + + let now = Instant::now(); + let elapsed = now.saturating_duration_since(self.started_at); + if elapsed < LOADING_ANIMATION_DELAY { + self.frame_requester + .schedule_frame_in(LOADING_ANIMATION_DELAY - elapsed); + lines.push(Line::from(self.loading_text.as_str().dim())); + } else if self.animations_enabled { + self.frame_requester + .schedule_frame_in(LOADING_ANIMATION_INTERVAL); + lines.push(Line::from(shimmer_text( + self.loading_text.as_str(), + MotionMode::Animated, + ))); + } else { + lines.push(Line::from(self.loading_text.as_str().dim())); + } + + if let Some(note) = &self.note { + lines.push(Line::from(note.as_str().dim())); + } + + Paragraph::new(lines).render_ref(area, buf); + } + + fn desired_height(&self, _width: u16) -> u16 { + 2 + u16::from(self.note.is_some()) + } +} + +struct PluginDisclosureLine { + line: Line<'static>, +} + +impl Renderable for PluginDisclosureLine { + fn render(&self, area: Rect, buf: &mut Buffer) { + Paragraph::new(self.line.clone()) + .wrap(Wrap { trim: false }) + .render(area, buf); + mark_url_hyperlink(buf, area, APPS_HELP_ARTICLE_URL); + } + + fn desired_height(&self, width: u16) -> u16 { + Paragraph::new(self.line.clone()) + .wrap(Wrap { trim: false }) + .line_count(width) + .try_into() + .unwrap_or(u16::MAX) + } +} + +impl ChatWidget { + pub(super) fn plugins_loading_popup_params(&self) -> SelectionViewParams { + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(DelayedLoadingHeader::new( + self.frame_requester.clone(), + self.config.animations, + "Loading available plugins...".to_string(), + Some("This updates when the marketplace list is ready.".to_string()), + )), + items: vec![SelectionItem { + name: "Loading plugins...".to_string(), + description: Some("This updates when the marketplace list is ready.".to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + + pub(super) fn marketplace_add_loading_popup_params(&self) -> SelectionViewParams { + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(DelayedLoadingHeader::new( + self.frame_requester.clone(), + self.config.animations, + "Adding marketplace...".to_string(), + /*note*/ None, + )), + items: vec![SelectionItem { + name: "Adding marketplace...".to_string(), + description: Some( + "This updates when marketplace installation completes.".to_string(), + ), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + + pub(super) fn marketplace_remove_confirmation_popup_params( + &self, + plugins_response: &PluginListResponse, + marketplace_name: String, + marketplace_display_name: String, + ) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from( + format!("Remove {marketplace_display_name} marketplace?").dim(), + )); + header.push(Line::from( + "This removes the configured marketplace from Codex.".dim(), + )); + + let cwd_for_remove = self.config.cwd.to_path_buf(); + let cwd_for_cancel = self.config.cwd.to_path_buf(); + let cwd_for_on_cancel = self.config.cwd.to_path_buf(); + let plugins_response_for_cancel = plugins_response.clone(); + let plugins_response_for_on_cancel = plugins_response.clone(); + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + footer_hint: Some(Line::from(vec![ + Span::from(key_hint::plain(KeyCode::Enter)), + " select".dim(), + " · ".into(), + "esc close".dim(), + ])), + items: vec![ + SelectionItem { + name: "Remove marketplace".to_string(), + description: Some( + "Remove this marketplace from the available plugin list.".to_string(), + ), + selected_description: Some( + "Remove this marketplace from the available plugin list.".to_string(), + ), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenMarketplaceRemoveLoading { + marketplace_display_name: marketplace_display_name.clone(), + }); + tx.send(AppEvent::FetchMarketplaceRemove { + cwd: cwd_for_remove.clone(), + marketplace_name: marketplace_name.clone(), + marketplace_display_name: marketplace_display_name.clone(), + }); + })], + ..Default::default() + }, + SelectionItem { + name: "Back to plugins".to_string(), + description: Some("Keep this marketplace installed.".to_string()), + selected_description: Some("Keep this marketplace installed.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::PluginsLoaded { + cwd: cwd_for_cancel.clone(), + result: Ok(plugins_response_for_cancel.clone()), + }); + })], + ..Default::default() + }, + ], + on_cancel: Some(Box::new(move |tx| { + tx.send(AppEvent::PluginsLoaded { + cwd: cwd_for_on_cancel.clone(), + result: Ok(plugins_response_for_on_cancel.clone()), + }); + })), + ..Default::default() + } + } + + pub(super) fn marketplace_remove_loading_popup_params( + &self, + marketplace_display_name: &str, + ) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from( + format!("Removing {marketplace_display_name}...").dim(), + )); + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + items: vec![SelectionItem { + name: "Removing marketplace...".to_string(), + description: Some("This updates when marketplace removal completes.".to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + + pub(super) fn marketplace_upgrade_loading_popup_params( + &self, + marketplace_name: Option<&str>, + ) -> SelectionViewParams { + let loading_text = marketplace_name + .map(|name| format!("Upgrading {name} marketplace...")) + .unwrap_or_else(|| "Upgrading marketplaces...".to_string()); + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(DelayedLoadingHeader::new( + self.frame_requester.clone(), + self.config.animations, + loading_text.clone(), + /*note*/ None, + )), + items: vec![SelectionItem { + name: loading_text, + description: Some("This updates when marketplace upgrade completes.".to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + + pub(super) fn plugin_detail_loading_popup_params( + &self, + plugin_display_name: &str, + ) -> SelectionViewParams { + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(DelayedLoadingHeader::new( + self.frame_requester.clone(), + self.config.animations, + format!("Loading details for {plugin_display_name}..."), + /*note*/ None, + )), + items: vec![SelectionItem { + name: "Loading plugin details...".to_string(), + description: Some("This updates when plugin details load.".to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + + pub(super) fn plugin_install_loading_popup_params( + &self, + plugin_display_name: &str, + ) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from( + format!("Installing {plugin_display_name}...").dim(), + )); + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + items: vec![SelectionItem { + name: "Installing plugin...".to_string(), + description: Some("This updates when plugin installation completes.".to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + + pub(super) fn plugin_uninstall_loading_popup_params( + &self, + plugin_display_name: &str, + ) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from( + format!("Uninstalling {plugin_display_name}...").dim(), + )); + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + items: vec![SelectionItem { + name: "Uninstalling plugin...".to_string(), + description: Some("This updates when the plugin removal completes.".to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + + pub(super) fn plugins_error_popup_params(&self, err: &str) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from("Failed to load plugins.".dim())); + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + items: vec![SelectionItem { + name: "Plugin marketplace unavailable".to_string(), + description: Some(err.to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + + pub(super) fn marketplace_add_error_popup_params(&self) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from("Failed to add marketplace.".dim())); + + let mut items = vec![ + SelectionItem { + name: "Marketplace add failed".to_string(), + description: Some( + "Failed to add marketplace from the provided source.".to_string(), + ), + is_disabled: true, + ..Default::default() + }, + SelectionItem { + name: "Try again".to_string(), + description: Some("Enter a marketplace source.".to_string()), + selected_description: Some("Enter a marketplace source.".to_string()), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenMarketplaceAddPrompt); + })], + ..Default::default() + }, + ]; + + if let PluginsCacheState::Ready(plugins_response) = self.plugins_cache_for_current_cwd() { + let cwd = self.config.cwd.to_path_buf(); + items.push(SelectionItem { + name: "Back to plugins".to_string(), + description: Some("Return to the plugin list.".to_string()), + selected_description: Some("Return to the plugin list.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::PluginsLoaded { + cwd: cwd.clone(), + result: Ok(plugins_response.clone()), + }); + })], + ..Default::default() + }); + } + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + footer_hint: Some(plugin_detail_hint_line()), + items, + ..Default::default() + } + } + + pub(super) fn marketplace_remove_error_popup_params( + &self, + marketplace_name: &str, + marketplace_display_name: &str, + ) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from("Failed to remove marketplace.".dim())); + + let marketplace_name = marketplace_name.to_string(); + let marketplace_display_name = marketplace_display_name.to_string(); + let mut items = vec![ + SelectionItem { + name: "Marketplace removal failed".to_string(), + description: Some("Failed to remove the selected marketplace.".to_string()), + is_disabled: true, + ..Default::default() + }, + SelectionItem { + name: "Try again".to_string(), + description: Some("Review the confirmation prompt again.".to_string()), + selected_description: Some("Review the confirmation prompt again.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenMarketplaceRemoveConfirm { + marketplace_name: marketplace_name.clone(), + marketplace_display_name: marketplace_display_name.clone(), + }); + })], + ..Default::default() + }, + ]; + + if let PluginsCacheState::Ready(plugins_response) = self.plugins_cache_for_current_cwd() { + let cwd = self.config.cwd.to_path_buf(); + items.push(SelectionItem { + name: "Back to plugins".to_string(), + description: Some("Return to the plugin list.".to_string()), + selected_description: Some("Return to the plugin list.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::PluginsLoaded { + cwd: cwd.clone(), + result: Ok(plugins_response.clone()), + }); + })], + ..Default::default() + }); + } + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + footer_hint: Some(plugin_detail_hint_line()), + items, + ..Default::default() + } + } + + pub(super) fn plugin_detail_error_popup_params( + &self, + err: &str, + plugins_response: Option<&PluginListResponse>, + ) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from("Failed to load plugin details.".dim())); + + let mut items = vec![SelectionItem { + name: "Plugin detail unavailable".to_string(), + description: Some(err.to_string()), + is_disabled: true, + ..Default::default() + }]; + if let Some(plugins_response) = plugins_response.cloned() { + let cwd = self.config.cwd.to_path_buf(); + items.push(SelectionItem { + name: "Back to plugins".to_string(), + description: Some("Return to the plugin list.".to_string()), + selected_description: Some("Return to the plugin list.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::PluginsLoaded { + cwd: cwd.clone(), + result: Ok(plugins_response.clone()), + }); + })], + ..Default::default() + }); + } + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + footer_hint: Some(plugin_detail_hint_line()), + items, + ..Default::default() + } + } + + pub(super) fn plugins_popup_params( + &self, + response: &PluginListResponse, + active_tab_id: Option, + initial_selected_idx: Option, + ) -> SelectionViewParams { + let marketplaces: Vec<&PluginMarketplaceEntry> = response.marketplaces.iter().collect(); + + let total: usize = marketplaces + .iter() + .map(|marketplace| marketplace.plugins.len()) + .sum(); + let installed = marketplaces + .iter() + .flat_map(|marketplace| marketplace.plugins.iter()) + .filter(|plugin| plugin.installed) + .count(); + + let all_entries = plugin_entries_for_marketplaces(marketplaces.iter().copied()); + let name_column_width = all_entries + .iter() + .map(|(_, _, display_name)| { + PLUGIN_ROW_PREFIX_WIDTH + UnicodeWidthStr::width(display_name.as_str()) + }) + .chain([UnicodeWidthStr::width("Add marketplace")]) + .max(); + let installed_entries = all_entries + .iter() + .filter(|(_, plugin, _)| plugin.installed) + .cloned() + .collect(); + + let mut tabs = Vec::new(); + let mut tab_footer_hints = Vec::new(); + tabs.push(SelectionTab { + id: ALL_PLUGINS_TAB_ID.to_string(), + label: "All Plugins".to_string(), + header: plugins_header( + "Browse plugins from available marketplaces.".to_string(), + format!("Installed {installed} of {total} available plugins."), + ), + items: self.plugin_selection_items( + all_entries, + /*include_marketplace_names*/ true, + "No marketplace plugins available", + "No plugins are available in the discovered marketplaces.", + ), + }); + + tabs.push(SelectionTab { + id: INSTALLED_PLUGINS_TAB_ID.to_string(), + label: format!("Installed ({installed})"), + header: plugins_header( + "Installed plugins.".to_string(), + format!("Showing {installed} installed plugins."), + ), + items: self.plugin_selection_items( + installed_entries, + /*include_marketplace_names*/ true, + "No installed plugins", + "No installed plugins.", + ), + }); + + let curated_marketplace = marketplaces + .iter() + .find(|marketplace| is_openai_curated_marketplace_name(&marketplace.name)) + .copied(); + let curated_entries = curated_marketplace + .map(|marketplace| plugin_entries_for_marketplaces([marketplace])) + .unwrap_or_default(); + let curated_total = curated_entries.len(); + let curated_installed = curated_entries + .iter() + .filter(|(_, plugin, _)| plugin.installed) + .count(); + tabs.push(SelectionTab { + id: OPENAI_CURATED_TAB_ID.to_string(), + label: "OpenAI Curated".to_string(), + header: plugins_header( + "OpenAI Curated marketplace.".to_string(), + format!("Installed {curated_installed} of {curated_total} OpenAI Curated plugins."), + ), + items: self.plugin_selection_items( + curated_entries, + /*include_marketplace_names*/ false, + "No OpenAI Curated plugins available", + "No OpenAI Curated plugins available.", + ), + }); + + let mut additional_marketplaces: Vec<&PluginMarketplaceEntry> = marketplaces + .iter() + .copied() + .filter(|marketplace| !is_openai_curated_marketplace_name(&marketplace.name)) + .collect(); + additional_marketplaces.sort_by(|left, right| { + marketplace_display_name(left) + .to_ascii_lowercase() + .cmp(&marketplace_display_name(right).to_ascii_lowercase()) + .then_with(|| marketplace_display_name(left).cmp(&marketplace_display_name(right))) + .then_with(|| left.name.cmp(&right.name)) + }); + + let labels = disambiguate_duplicate_tab_labels( + additional_marketplaces + .iter() + .map(|marketplace| marketplace_display_name(marketplace)) + .collect(), + ); + for (marketplace, label) in additional_marketplaces.into_iter().zip(labels) { + let entries = plugin_entries_for_marketplaces([marketplace]); + let marketplace_total = entries.len(); + let marketplace_installed = entries + .iter() + .filter(|(_, plugin, _)| plugin.installed) + .count(); + let tab_id = marketplace_tab_id(marketplace); + let can_remove_marketplace = + marketplace_is_user_configured(&self.config, &marketplace.name); + let can_upgrade_marketplace = marketplace.path.is_some() + && marketplace_is_user_configured_git(&self.config, &marketplace.name); + if can_remove_marketplace || can_upgrade_marketplace { + tab_footer_hints.push(( + tab_id.clone(), + plugins_popup_hint_line( + /*can_remove_marketplace*/ can_remove_marketplace, + /*can_upgrade_marketplace*/ can_upgrade_marketplace, + ), + )); + } + let header = if self.newly_installed_marketplace_tab_id.as_deref() == Some(&tab_id) { + plugins_header( + format!("{label} installed successfully."), + "Select the plugins you want to use and press Enter to install or view details." + .to_string(), + ) + } else { + plugins_header( + format!("{label}."), + format!( + "Installed {marketplace_installed} of {marketplace_total} {label} plugins." + ), + ) + }; + tabs.push(SelectionTab { + id: tab_id, + label: label.clone(), + header, + items: self.plugin_selection_items( + entries, + /*include_marketplace_names*/ false, + "No plugins available in this marketplace", + "No plugins available in this marketplace.", + ), + }); + } + + tabs.push(self.marketplace_add_tab()); + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(()), + footer_hint: Some(plugins_popup_hint_line( + /*can_remove_marketplace*/ false, /*can_upgrade_marketplace*/ false, + )), + tab_footer_hints, + tabs, + initial_tab_id: active_tab_id, + is_searchable: true, + search_placeholder: Some("Type to search plugins".to_string()), + col_width_mode: ColumnWidthMode::AutoAllRows, + row_display: SelectionRowDisplay::SingleLine, + name_column_width, + initial_selected_idx, + ..Default::default() + } + } + + fn marketplace_add_tab(&self) -> SelectionTab { + SelectionTab { + id: ADD_MARKETPLACE_TAB_ID.to_string(), + label: "Add Marketplace".to_string(), + header: plugins_header( + "Add a marketplace from a Git repo or local root.".to_string(), + "Enter a source to make its plugins available in this menu.".to_string(), + ), + items: vec![SelectionItem { + name: "Add marketplace".to_string(), + description: Some( + "Enter owner/repo, a Git URL, or a local marketplace path.".to_string(), + ), + selected_description: Some( + "Press Enter to enter a marketplace source.".to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenMarketplaceAddPrompt); + })], + ..Default::default() + }], + } + } + + pub(super) fn plugin_detail_popup_params( + &self, + plugins_response: &PluginListResponse, + plugin: &PluginDetail, + ) -> SelectionViewParams { + let marketplace_label = plugin.marketplace_name.clone(); + let display_name = plugin_display_name(&plugin.summary); + let detail_status_label = + if plugin.summary.availability == PluginAvailability::DisabledByAdmin { + "Disabled by admin" + } else if plugin.summary.installed { + if plugin.summary.enabled { + "Installed" + } else { + "Disabled" + } + } else { + match plugin.summary.install_policy { + PluginInstallPolicy::NotAvailable => "Not installable", + PluginInstallPolicy::Available => "Can be installed", + PluginInstallPolicy::InstalledByDefault => "Available by default", + } + }; + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from( + format!("{display_name} · {detail_status_label} · {marketplace_label}").bold(), + )); + if !plugin.summary.installed { + header.push(PluginDisclosureLine { + line: Line::from(vec![ + "Data shared with this app is subject to the app's ".into(), + "terms of service".bold(), + " and ".into(), + "privacy policy".bold(), + ". ".into(), + "Learn more".cyan().underlined(), + ".".into(), + ]), + }); + } + if let Some(description) = plugin_detail_description(plugin) { + header.push(Line::from(description.dim())); + } + + let cwd = self.config.cwd.to_path_buf(); + let plugins_response = plugins_response.clone(); + let mut items = vec![SelectionItem { + name: "Back to plugins".to_string(), + description: Some("Return to the plugin list.".to_string()), + selected_description: Some("Return to the plugin list.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::PluginsLoaded { + cwd: cwd.clone(), + result: Ok(plugins_response.clone()), + }); + })], + ..Default::default() + }]; + + if plugin.summary.installed { + if let Some(plugin_id) = plugin_uninstall_id(&plugin.summary) { + let uninstall_cwd = self.config.cwd.to_path_buf(); + let plugin_display_name = display_name; + items.push(SelectionItem { + name: "Uninstall plugin".to_string(), + description: Some("Remove this plugin now.".to_string()), + selected_description: Some("Remove this plugin now.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenPluginUninstallLoading { + plugin_display_name: plugin_display_name.clone(), + }); + tx.send(AppEvent::FetchPluginUninstall { + cwd: uninstall_cwd.clone(), + plugin_id: plugin_id.clone(), + plugin_display_name: plugin_display_name.clone(), + }); + })], + ..Default::default() + }); + } else { + items.push(SelectionItem { + name: "Uninstall plugin".to_string(), + description: Some( + "This remote plugin did not provide an uninstall identity.".to_string(), + ), + is_disabled: true, + ..Default::default() + }); + } + } else if plugin.summary.availability == PluginAvailability::DisabledByAdmin { + items.push(SelectionItem { + name: "Install plugin".to_string(), + description: Some("This plugin is disabled by your workspace admin.".to_string()), + is_disabled: true, + ..Default::default() + }); + } else if plugin.summary.install_policy == PluginInstallPolicy::NotAvailable { + items.push(SelectionItem { + name: "Install plugin".to_string(), + description: Some( + "This plugin is not installable from this marketplace.".to_string(), + ), + is_disabled: true, + ..Default::default() + }); + } else if let Some(location) = plugin_detail_location(plugin) { + let install_cwd = self.config.cwd.to_path_buf(); + let plugin_name = plugin_request_name(&plugin.summary); + let plugin_display_name = display_name; + items.push(SelectionItem { + name: "Install plugin".to_string(), + description: Some("Install this plugin now.".to_string()), + selected_description: Some("Install this plugin now.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenPluginInstallLoading { + plugin_display_name: plugin_display_name.clone(), + }); + tx.send(AppEvent::FetchPluginInstall { + cwd: install_cwd.clone(), + location: location.clone(), + plugin_name: plugin_name.clone(), + plugin_display_name: plugin_display_name.clone(), + }); + })], + ..Default::default() + }); + } else { + items.push(SelectionItem { + name: "Install plugin".to_string(), + description: Some("This plugin did not provide an install location.".to_string()), + is_disabled: true, + ..Default::default() + }); + } + + items.push(SelectionItem { + name: "Skills".to_string(), + description: Some(plugin_skill_summary(plugin)), + is_disabled: true, + ..Default::default() + }); + items.push(SelectionItem { + name: "Hooks".to_string(), + description: Some(plugin_hook_summary(plugin)), + is_disabled: true, + ..Default::default() + }); + items.push(SelectionItem { + name: "Apps".to_string(), + description: Some(plugin_app_summary(plugin)), + is_disabled: true, + ..Default::default() + }); + items.push(SelectionItem { + name: "MCP Servers".to_string(), + description: Some(plugin_mcp_summary(plugin)), + is_disabled: true, + ..Default::default() + }); + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + footer_hint: Some(plugin_detail_hint_line()), + items, + col_width_mode: ColumnWidthMode::AutoAllRows, + ..Default::default() + } + } + + fn plugin_selection_items<'a>( + &self, + mut plugin_entries: Vec<(&'a PluginMarketplaceEntry, &'a PluginSummary, String)>, + include_marketplace_names: bool, + empty_name: &str, + empty_description: &str, + ) -> Vec { + sort_plugin_entries(&mut plugin_entries); + let status_label_width = plugin_entries + .iter() + .map(|(_, plugin, _)| plugin_status_label(plugin).chars().count()) + .max() + .unwrap_or(0); + + let mut items: Vec = Vec::new(); + for (marketplace, plugin, display_name) in plugin_entries { + let marketplace_label = marketplace_display_name(marketplace); + let status_label = plugin_status_label(plugin); + let description = if include_marketplace_names { + plugin_brief_description(plugin, &marketplace_label, status_label_width) + } else { + plugin_brief_description_without_marketplace(plugin, status_label_width) + }; + let plugin_detail_request = plugin_detail_request_for_entry(marketplace, plugin); + let can_view_details = plugin_detail_request.is_some(); + let disabled_by_admin = plugin.availability == PluginAvailability::DisabledByAdmin; + let can_toggle_plugin = plugin.installed && !disabled_by_admin; + let selected_status_label = format!("{status_label: = + if let Some((location, plugin_name)) = plugin_detail_request { + vec![Box::new(move |tx| { + tx.send(AppEvent::OpenPluginDetailLoading { + plugin_display_name: plugin_display_name.clone(), + }); + let (marketplace_path, remote_marketplace_name) = + location.clone().into_request_params(); + tx.send(AppEvent::FetchPluginDetail { + cwd: cwd.clone(), + params: codex_app_server_protocol::PluginReadParams { + marketplace_path, + remote_marketplace_name, + plugin_name: plugin_name.clone(), + }, + }); + })] + } else { + Vec::new() + }; + let is_disabled = !can_view_details && !plugin.installed; + let disabled_reason = is_disabled.then(|| "plugin details are unavailable".to_string()); + + items.push(SelectionItem { + name: display_name, + toggle, + toggle_placeholder: (!can_toggle_plugin).then_some("[-] "), + description: Some(description), + selected_description: Some(selected_description), + search_value: Some(search_value), + actions, + is_disabled, + disabled_reason, + ..Default::default() + }); + } + + if items.is_empty() { + items.push(SelectionItem { + name: empty_name.to_string(), + description: Some(empty_description.to_string()), + is_disabled: true, + ..Default::default() + }); + } + items + } +} + +fn plugins_popup_hint_line( + can_remove_marketplace: bool, + can_upgrade_marketplace: bool, +) -> Line<'static> { + match (can_remove_marketplace, can_upgrade_marketplace) { + (true, true) => Line::from( + "ctrl + u upgrade · ctrl + r remove · space toggle · ←/→ tabs · enter details · esc close", + ), + (true, false) => { + Line::from("ctrl + r remove · space toggle · ←/→ tabs · enter details · esc close") + } + (false, true) => { + Line::from("ctrl + u upgrade · space toggle · ←/→ tabs · enter details · esc close") + } + (false, false) => Line::from( + "space enable/disable · ←/→ select marketplace · enter view details · esc close", + ), + } +} + +pub(super) fn plugin_detail_hint_line() -> Line<'static> { + Line::from("Press esc to close.") +} + +fn plugins_header(subtitle: String, count_line: String) -> Box { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from(subtitle.dim())); + header.push(Line::from(count_line.dim())); + Box::new(header) +} + +fn plugin_entries_for_marketplaces<'a>( + marketplaces: impl IntoIterator, +) -> Vec<(&'a PluginMarketplaceEntry, &'a PluginSummary, String)> { + marketplaces + .into_iter() + .flat_map(|marketplace| { + marketplace + .plugins + .iter() + .map(move |plugin| (marketplace, plugin, plugin_display_name(plugin))) + }) + .collect() +} + +fn sort_plugin_entries(entries: &mut [(&PluginMarketplaceEntry, &PluginSummary, String)]) { + entries.sort_by(|left, right| { + right + .1 + .installed + .cmp(&left.1.installed) + .then_with(|| { + left.2 + .to_ascii_lowercase() + .cmp(&right.2.to_ascii_lowercase()) + }) + .then_with(|| left.2.cmp(&right.2)) + .then_with(|| left.1.name.cmp(&right.1.name)) + .then_with(|| left.1.id.cmp(&right.1.id)) + }); +} + +pub(super) fn marketplace_tab_id(marketplace: &PluginMarketplaceEntry) -> String { + match marketplace.path.as_ref() { + Some(path) => marketplace_tab_id_from_path(path.as_path()), + None => format!("marketplace:{}", marketplace.name), + } +} + +pub(super) fn marketplace_tab_id_from_path(path: &Path) -> String { + format!("{MARKETPLACE_TAB_ID_PREFIX}{}", path.display()) +} + +pub(super) fn marketplace_tab_id_matching_saved_id( + saved_tab_id: &str, + marketplaces: &[PluginMarketplaceEntry], +) -> Option { + if let Some(tab_id) = marketplaces.iter().find_map(|marketplace| { + let tab_id = marketplace_tab_id(marketplace); + (tab_id == saved_tab_id).then_some(tab_id) + }) { + return Some(tab_id); + } + + let root = saved_tab_id.strip_prefix(MARKETPLACE_TAB_ID_PREFIX)?; + if root.is_empty() { + return None; + } + let root = Path::new(root); + marketplaces.iter().find_map(|marketplace| { + marketplace + .path + .as_ref() + .is_some_and(|path| path.as_path().starts_with(root)) + .then(|| marketplace_tab_id(marketplace)) + }) +} + +pub(super) fn merge_remote_marketplaces( + response: &mut PluginListResponse, + remote_marketplaces: Vec, +) { + let remote_names = remote_marketplaces + .iter() + .map(|marketplace| marketplace.name.clone()) + .collect::>(); + response.marketplaces.retain(|marketplace| { + marketplace.path.is_some() + || !remote_marketplace_is_remote_section(marketplace) + && !remote_names.contains(marketplace.name.as_str()) + }); + response.marketplaces.extend(remote_marketplaces); +} + +fn remote_marketplace_is_remote_section(marketplace: &PluginMarketplaceEntry) -> bool { + matches!( + marketplace.name.as_str(), + REMOTE_WORKSPACE_MARKETPLACE_NAME + | REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME + | REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME + | REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME + ) +} + +fn disambiguate_duplicate_tab_labels(labels: Vec) -> Vec { + let mut counts: Vec<(String, usize)> = Vec::new(); + for label in &labels { + if let Some((_, count)) = counts.iter_mut().find(|(existing, _)| existing == label) { + *count += 1; + } else { + counts.push((label.clone(), 1)); + } + } + + let mut seen: Vec<(String, usize)> = Vec::new(); + labels + .into_iter() + .map(|label| { + let total = counts + .iter() + .find(|(existing, _)| existing == &label) + .map(|(_, count)| *count) + .unwrap_or(1); + if total == 1 { + return label; + } + + let current = if let Some((_, seen_count)) = + seen.iter_mut().find(|(existing, _)| existing == &label) + { + *seen_count += 1; + *seen_count + } else { + seen.push((label.clone(), 1)); + 1 + }; + format!("{label} ({current}/{total})") + }) + .collect() +} + +pub(super) fn marketplace_display_name(marketplace: &PluginMarketplaceEntry) -> String { + marketplace + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()) + .map(str::trim) + .filter(|display_name| !display_name.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| marketplace.name.clone()) +} + +pub(super) fn marketplace_is_user_configured(config: &Config, marketplace_name: &str) -> bool { + let Some(user_config) = config.config_layer_stack.effective_user_config() else { + return false; + }; + user_config + .get("marketplaces") + .and_then(toml::Value::as_table) + .is_some_and(|marketplaces| marketplaces.contains_key(marketplace_name)) +} + +pub(super) fn marketplace_is_user_configured_git(config: &Config, marketplace_name: &str) -> bool { + config + .config_layer_stack + .get_active_user_layer() + .and_then(|user_layer| user_layer.config.get("marketplaces")) + .and_then(toml::Value::as_table) + .and_then(|marketplaces| marketplaces.get(marketplace_name)) + .and_then(toml::Value::as_table) + .and_then(|marketplace| marketplace.get("source_type")) + .and_then(toml::Value::as_str) + .is_some_and(|source_type| source_type == "git") +} + +fn plugin_display_name(plugin: &PluginSummary) -> String { + plugin + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()) + .map(str::trim) + .filter(|display_name| !display_name.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| plugin.name.clone()) +} + +fn plugin_brief_description( + plugin: &PluginSummary, + marketplace_label: &str, + status_label_width: usize, +) -> String { + let status_label = plugin_status_label(plugin); + let status_label = format!("{status_label: format!("{status_label} · {marketplace_label} · {description}"), + None => format!("{status_label} · {marketplace_label}"), + } +} + +fn plugin_brief_description_without_marketplace( + plugin: &PluginSummary, + status_label_width: usize, +) -> String { + let status_label = plugin_status_label(plugin); + let status_label = format!("{status_label: format!("{status_label} · {description}"), + None => status_label, + } +} + +fn plugin_status_label(plugin: &PluginSummary) -> &'static str { + if plugin.availability == PluginAvailability::DisabledByAdmin { + return "Disabled by admin"; + } + if plugin.installed { + if plugin.enabled { + "Installed" + } else { + "Disabled" + } + } else { + match plugin.install_policy { + PluginInstallPolicy::NotAvailable => "Not installable", + PluginInstallPolicy::Available => "Available", + PluginInstallPolicy::InstalledByDefault => "Available", + } + } +} + +fn plugin_location_for_marketplace( + marketplace: &PluginMarketplaceEntry, + plugin: &PluginSummary, +) -> Option { + if let Some(marketplace_path) = marketplace.path.clone() { + return Some(PluginLocation::Local { marketplace_path }); + } + plugin_remote_identity(plugin).map(|_| PluginLocation::Remote { + marketplace_name: marketplace.name.clone(), + }) +} + +fn plugin_detail_location(plugin: &PluginDetail) -> Option { + if let Some(marketplace_path) = plugin.marketplace_path.clone() { + return Some(PluginLocation::Local { marketplace_path }); + } + plugin_remote_identity(&plugin.summary).map(|_| PluginLocation::Remote { + marketplace_name: plugin.marketplace_name.clone(), + }) +} + +fn plugin_detail_request_for_entry( + marketplace: &PluginMarketplaceEntry, + plugin: &PluginSummary, +) -> Option<(PluginLocation, String)> { + plugin_location_for_marketplace(marketplace, plugin) + .map(|location| (location, plugin_request_name(plugin))) +} + +fn plugin_request_name(plugin: &PluginSummary) -> String { + if matches!(&plugin.source, PluginSource::Remote) + && let Some(remote_plugin_id) = plugin_remote_identity(plugin) + { + return remote_plugin_id; + } + plugin.name.clone() +} + +fn plugin_remote_identity(plugin: &PluginSummary) -> Option { + plugin + .share_context + .as_ref() + .map(|context| context.remote_plugin_id.clone()) + .or_else(|| plugin.remote_plugin_id.clone()) +} + +fn plugin_uninstall_id(plugin: &PluginSummary) -> Option { + if matches!(&plugin.source, PluginSource::Remote) { + return plugin_remote_identity(plugin); + } + Some(plugin.id.clone()) +} + +fn plugin_description(plugin: &PluginSummary) -> Option { + plugin + .interface + .as_ref() + .and_then(|interface| { + interface + .short_description + .as_deref() + .or(interface.long_description.as_deref()) + }) + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(str::to_string) +} + +fn plugin_detail_description(plugin: &PluginDetail) -> Option { + plugin + .description + .as_deref() + .or_else(|| { + plugin + .summary + .interface + .as_ref() + .and_then(|interface| interface.long_description.as_deref()) + }) + .or_else(|| { + plugin + .summary + .interface + .as_ref() + .and_then(|interface| interface.short_description.as_deref()) + }) + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(str::to_string) +} + +fn plugin_skill_summary(plugin: &PluginDetail) -> String { + if plugin.skills.is_empty() { + "No plugin skills.".to_string() + } else { + plugin + .skills + .iter() + .map(|skill| skill.name.as_str()) + .collect::>() + .join(", ") + } +} + +fn plugin_app_summary(plugin: &PluginDetail) -> String { + if plugin.apps.is_empty() { + "No plugin apps.".to_string() + } else { + plugin + .apps + .iter() + .map(|app| app.name.as_str()) + .collect::>() + .join(", ") + } +} + +fn plugin_hook_summary(plugin: &PluginDetail) -> String { + if plugin.hooks.is_empty() { + "No plugin hooks.".to_string() + } else { + let mut event_counts = Vec::<(codex_app_server_protocol::HookEventName, usize)>::new(); + for hook in &plugin.hooks { + if let Some((_, handler_count)) = event_counts + .iter_mut() + .find(|(event_name, _)| *event_name == hook.event_name) + { + *handler_count += 1; + } else { + event_counts.push((hook.event_name, 1)); + } + } + event_counts + .into_iter() + .map(|(event_name, handler_count)| format!("{event_name:?} ({handler_count})")) + .collect::>() + .join(", ") + } +} + +fn plugin_mcp_summary(plugin: &PluginDetail) -> String { + if plugin.mcp_servers.is_empty() { + "No plugin MCP servers.".to_string() + } else { + plugin.mcp_servers.join(", ") + } +} diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 5e091b48a3..8c259856fb 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -1,71 +1,42 @@ -use std::path::Path; use std::path::PathBuf; -use std::time::Duration; -use std::time::Instant; use super::ChatWidget; +use super::plugin_catalog::marketplace_display_name; +use super::plugin_catalog::marketplace_is_user_configured; +use super::plugin_catalog::marketplace_is_user_configured_git; +use super::plugin_catalog::marketplace_tab_id; +use super::plugin_catalog::marketplace_tab_id_from_path; +use super::plugin_catalog::marketplace_tab_id_matching_saved_id; +use super::plugin_catalog::merge_remote_marketplaces; +use super::plugin_catalog::plugin_detail_hint_line; use crate::app_event::AppEvent; use crate::app_event::PluginLocation; use crate::app_event::PluginRemoteSectionError; use crate::bottom_pane::ColumnWidthMode; -use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; -use crate::bottom_pane::SelectionRowDisplay; -use crate::bottom_pane::SelectionTab; -use crate::bottom_pane::SelectionToggle; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::custom_prompt_view::CustomPromptView; use crate::history_cell; use crate::key_hint; -use crate::legacy_core::config::Config; -use crate::motion::MotionMode; -use crate::motion::shimmer_text; -use crate::onboarding::mark_url_hyperlink; use crate::render::renderable::ColumnRenderable; -use crate::render::renderable::Renderable; -use crate::tui::FrameRequester; use codex_app_server_protocol::MarketplaceAddResponse; use codex_app_server_protocol::MarketplaceRemoveResponse; use codex_app_server_protocol::MarketplaceUpgradeResponse; -use codex_app_server_protocol::PluginAvailability; -use codex_app_server_protocol::PluginDetail; -use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginMarketplaceEntry; use codex_app_server_protocol::PluginReadResponse; -use codex_app_server_protocol::PluginSource; -use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::PluginUninstallResponse; -use codex_core_plugins::is_openai_curated_marketplace_name; -use codex_core_plugins::remote::REMOTE_WORKSPACE_MARKETPLACE_NAME; -use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME; -use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME; -use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME; use codex_features::Feature; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::prelude::Widget; use ratatui::style::Stylize; use ratatui::text::Line; -use ratatui::text::Span; -use ratatui::widgets::Paragraph; -use ratatui::widgets::WidgetRef; -use ratatui::widgets::Wrap; -use unicode_width::UnicodeWidthStr; -const PLUGINS_SELECTION_VIEW_ID: &str = "plugins-selection"; -const ALL_PLUGINS_TAB_ID: &str = "all-plugins"; -const INSTALLED_PLUGINS_TAB_ID: &str = "installed-plugins"; -const MARKETPLACE_TAB_ID_PREFIX: &str = "marketplace:"; -const OPENAI_CURATED_TAB_ID: &str = "marketplace:openai-curated"; -const ADD_MARKETPLACE_TAB_ID: &str = "add-marketplace"; -const PLUGIN_ROW_PREFIX_WIDTH: usize = 6; -const LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1); -const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100); +pub(super) const PLUGINS_SELECTION_VIEW_ID: &str = "plugins-selection"; +pub(super) const ALL_PLUGINS_TAB_ID: &str = "all-plugins"; +pub(super) const ADD_MARKETPLACE_TAB_ID: &str = "add-marketplace"; #[derive(Debug, Clone, Default)] pub(super) struct PluginListFetchState { @@ -79,92 +50,6 @@ pub(super) struct PluginInstallAuthFlowState { next_app_index: usize, } -struct DelayedLoadingHeader { - started_at: Instant, - frame_requester: FrameRequester, - animations_enabled: bool, - loading_text: String, - note: Option, -} - -impl DelayedLoadingHeader { - fn new( - frame_requester: FrameRequester, - animations_enabled: bool, - loading_text: String, - note: Option, - ) -> Self { - Self { - started_at: Instant::now(), - frame_requester, - animations_enabled, - loading_text, - note, - } - } -} - -impl Renderable for DelayedLoadingHeader { - fn render(&self, area: Rect, buf: &mut Buffer) { - if area.is_empty() { - return; - } - - let mut lines = Vec::with_capacity(3); - lines.push(Line::from("Plugins".bold())); - - let now = Instant::now(); - let elapsed = now.saturating_duration_since(self.started_at); - if elapsed < LOADING_ANIMATION_DELAY { - self.frame_requester - .schedule_frame_in(LOADING_ANIMATION_DELAY - elapsed); - lines.push(Line::from(self.loading_text.as_str().dim())); - } else if self.animations_enabled { - self.frame_requester - .schedule_frame_in(LOADING_ANIMATION_INTERVAL); - lines.push(Line::from(shimmer_text( - self.loading_text.as_str(), - MotionMode::Animated, - ))); - } else { - lines.push(Line::from(self.loading_text.as_str().dim())); - } - - if let Some(note) = &self.note { - lines.push(Line::from(note.as_str().dim())); - } - - Paragraph::new(lines).render_ref(area, buf); - } - - fn desired_height(&self, _width: u16) -> u16 { - 2 + u16::from(self.note.is_some()) - } -} - -const APPS_HELP_ARTICLE_URL: &str = "https://help.openai.com/en/articles/11487775-apps-in-chatgpt"; - -struct PluginDisclosureLine { - line: Line<'static>, -} - -impl Renderable for PluginDisclosureLine { - fn render(&self, area: Rect, buf: &mut Buffer) { - Paragraph::new(self.line.clone()) - .wrap(Wrap { trim: false }) - .render(area, buf); - mark_url_hyperlink(buf, area, APPS_HELP_ARTICLE_URL); - } - - fn desired_height(&self, width: u16) -> u16 { - Paragraph::new(self.line.clone()) - .wrap(Wrap { trim: false }) - .line_count(width) - .try_into() - .unwrap_or(u16::MAX) - } -} - #[derive(Debug, Clone, Default)] pub(super) enum PluginsCacheState { #[default] @@ -332,7 +217,7 @@ impl ChatWidget { } } - fn plugins_cache_for_current_cwd(&self) -> PluginsCacheState { + pub(super) fn plugins_cache_for_current_cwd(&self) -> PluginsCacheState { if self.plugins_fetch_state.cache_cwd.as_deref() == Some(self.config.cwd.as_path()) { self.plugins_cache.clone() } else { @@ -1095,1283 +980,4 @@ impl ChatWidget { self.plugins_popup_params(response, active_tab_id, selected_idx), ); } - - fn plugins_loading_popup_params(&self) -> SelectionViewParams { - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(DelayedLoadingHeader::new( - self.frame_requester.clone(), - self.config.animations, - "Loading available plugins...".to_string(), - Some("This updates when the marketplace list is ready.".to_string()), - )), - items: vec![SelectionItem { - name: "Loading plugins...".to_string(), - description: Some("This updates when the marketplace list is ready.".to_string()), - is_disabled: true, - ..Default::default() - }], - ..Default::default() - } - } - - fn marketplace_add_loading_popup_params(&self) -> SelectionViewParams { - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(DelayedLoadingHeader::new( - self.frame_requester.clone(), - self.config.animations, - "Adding marketplace...".to_string(), - /*note*/ None, - )), - items: vec![SelectionItem { - name: "Adding marketplace...".to_string(), - description: Some( - "This updates when marketplace installation completes.".to_string(), - ), - is_disabled: true, - ..Default::default() - }], - ..Default::default() - } - } - - fn marketplace_remove_confirmation_popup_params( - &self, - plugins_response: &PluginListResponse, - marketplace_name: String, - marketplace_display_name: String, - ) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from( - format!("Remove {marketplace_display_name} marketplace?").dim(), - )); - header.push(Line::from( - "This removes the configured marketplace from Codex.".dim(), - )); - - let cwd_for_remove = self.config.cwd.to_path_buf(); - let cwd_for_cancel = self.config.cwd.to_path_buf(); - let cwd_for_on_cancel = self.config.cwd.to_path_buf(); - let plugins_response_for_cancel = plugins_response.clone(); - let plugins_response_for_on_cancel = plugins_response.clone(); - - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), - footer_hint: Some(Line::from(vec![ - Span::from(key_hint::plain(KeyCode::Enter)), - " select".dim(), - " · ".into(), - "esc close".dim(), - ])), - items: vec![ - SelectionItem { - name: "Remove marketplace".to_string(), - description: Some( - "Remove this marketplace from the available plugin list.".to_string(), - ), - selected_description: Some( - "Remove this marketplace from the available plugin list.".to_string(), - ), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::OpenMarketplaceRemoveLoading { - marketplace_display_name: marketplace_display_name.clone(), - }); - tx.send(AppEvent::FetchMarketplaceRemove { - cwd: cwd_for_remove.clone(), - marketplace_name: marketplace_name.clone(), - marketplace_display_name: marketplace_display_name.clone(), - }); - })], - ..Default::default() - }, - SelectionItem { - name: "Back to plugins".to_string(), - description: Some("Keep this marketplace installed.".to_string()), - selected_description: Some("Keep this marketplace installed.".to_string()), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { - cwd: cwd_for_cancel.clone(), - result: Ok(plugins_response_for_cancel.clone()), - }); - })], - ..Default::default() - }, - ], - on_cancel: Some(Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { - cwd: cwd_for_on_cancel.clone(), - result: Ok(plugins_response_for_on_cancel.clone()), - }); - })), - ..Default::default() - } - } - - fn marketplace_remove_loading_popup_params( - &self, - marketplace_display_name: &str, - ) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from( - format!("Removing {marketplace_display_name}...").dim(), - )); - - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), - items: vec![SelectionItem { - name: "Removing marketplace...".to_string(), - description: Some("This updates when marketplace removal completes.".to_string()), - is_disabled: true, - ..Default::default() - }], - ..Default::default() - } - } - - fn marketplace_upgrade_loading_popup_params( - &self, - marketplace_name: Option<&str>, - ) -> SelectionViewParams { - let loading_text = marketplace_name - .map(|name| format!("Upgrading {name} marketplace...")) - .unwrap_or_else(|| "Upgrading marketplaces...".to_string()); - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(DelayedLoadingHeader::new( - self.frame_requester.clone(), - self.config.animations, - loading_text.clone(), - /*note*/ None, - )), - items: vec![SelectionItem { - name: loading_text, - description: Some("This updates when marketplace upgrade completes.".to_string()), - is_disabled: true, - ..Default::default() - }], - ..Default::default() - } - } - - fn plugin_detail_loading_popup_params(&self, plugin_display_name: &str) -> SelectionViewParams { - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(DelayedLoadingHeader::new( - self.frame_requester.clone(), - self.config.animations, - format!("Loading details for {plugin_display_name}..."), - /*note*/ None, - )), - items: vec![SelectionItem { - name: "Loading plugin details...".to_string(), - description: Some("This updates when plugin details load.".to_string()), - is_disabled: true, - ..Default::default() - }], - ..Default::default() - } - } - - fn plugin_install_loading_popup_params( - &self, - plugin_display_name: &str, - ) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from( - format!("Installing {plugin_display_name}...").dim(), - )); - - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), - items: vec![SelectionItem { - name: "Installing plugin...".to_string(), - description: Some("This updates when plugin installation completes.".to_string()), - is_disabled: true, - ..Default::default() - }], - ..Default::default() - } - } - - fn plugin_uninstall_loading_popup_params( - &self, - plugin_display_name: &str, - ) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from( - format!("Uninstalling {plugin_display_name}...").dim(), - )); - - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), - items: vec![SelectionItem { - name: "Uninstalling plugin...".to_string(), - description: Some("This updates when the plugin removal completes.".to_string()), - is_disabled: true, - ..Default::default() - }], - ..Default::default() - } - } - - fn plugins_error_popup_params(&self, err: &str) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from("Failed to load plugins.".dim())); - - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), - items: vec![SelectionItem { - name: "Plugin marketplace unavailable".to_string(), - description: Some(err.to_string()), - is_disabled: true, - ..Default::default() - }], - ..Default::default() - } - } - - fn marketplace_add_error_popup_params(&self) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from("Failed to add marketplace.".dim())); - - let mut items = vec![ - SelectionItem { - name: "Marketplace add failed".to_string(), - description: Some( - "Failed to add marketplace from the provided source.".to_string(), - ), - is_disabled: true, - ..Default::default() - }, - SelectionItem { - name: "Try again".to_string(), - description: Some("Enter a marketplace source.".to_string()), - selected_description: Some("Enter a marketplace source.".to_string()), - actions: vec![Box::new(|tx| { - tx.send(AppEvent::OpenMarketplaceAddPrompt); - })], - ..Default::default() - }, - ]; - - if let PluginsCacheState::Ready(plugins_response) = self.plugins_cache_for_current_cwd() { - let cwd = self.config.cwd.to_path_buf(); - items.push(SelectionItem { - name: "Back to plugins".to_string(), - description: Some("Return to the plugin list.".to_string()), - selected_description: Some("Return to the plugin list.".to_string()), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { - cwd: cwd.clone(), - result: Ok(plugins_response.clone()), - }); - })], - ..Default::default() - }); - } - - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), - footer_hint: Some(plugin_detail_hint_line()), - items, - ..Default::default() - } - } - - fn marketplace_remove_error_popup_params( - &self, - marketplace_name: &str, - marketplace_display_name: &str, - ) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from("Failed to remove marketplace.".dim())); - - let marketplace_name = marketplace_name.to_string(); - let marketplace_display_name = marketplace_display_name.to_string(); - let mut items = vec![ - SelectionItem { - name: "Marketplace removal failed".to_string(), - description: Some("Failed to remove the selected marketplace.".to_string()), - is_disabled: true, - ..Default::default() - }, - SelectionItem { - name: "Try again".to_string(), - description: Some("Review the confirmation prompt again.".to_string()), - selected_description: Some("Review the confirmation prompt again.".to_string()), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::OpenMarketplaceRemoveConfirm { - marketplace_name: marketplace_name.clone(), - marketplace_display_name: marketplace_display_name.clone(), - }); - })], - ..Default::default() - }, - ]; - - if let PluginsCacheState::Ready(plugins_response) = self.plugins_cache_for_current_cwd() { - let cwd = self.config.cwd.to_path_buf(); - items.push(SelectionItem { - name: "Back to plugins".to_string(), - description: Some("Return to the plugin list.".to_string()), - selected_description: Some("Return to the plugin list.".to_string()), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { - cwd: cwd.clone(), - result: Ok(plugins_response.clone()), - }); - })], - ..Default::default() - }); - } - - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), - footer_hint: Some(plugin_detail_hint_line()), - items, - ..Default::default() - } - } - - fn plugin_detail_error_popup_params( - &self, - err: &str, - plugins_response: Option<&PluginListResponse>, - ) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from("Failed to load plugin details.".dim())); - - let mut items = vec![SelectionItem { - name: "Plugin detail unavailable".to_string(), - description: Some(err.to_string()), - is_disabled: true, - ..Default::default() - }]; - if let Some(plugins_response) = plugins_response.cloned() { - let cwd = self.config.cwd.to_path_buf(); - items.push(SelectionItem { - name: "Back to plugins".to_string(), - description: Some("Return to the plugin list.".to_string()), - selected_description: Some("Return to the plugin list.".to_string()), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { - cwd: cwd.clone(), - result: Ok(plugins_response.clone()), - }); - })], - ..Default::default() - }); - } - - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), - footer_hint: Some(plugin_detail_hint_line()), - items, - ..Default::default() - } - } - - fn plugins_popup_params( - &self, - response: &PluginListResponse, - active_tab_id: Option, - initial_selected_idx: Option, - ) -> SelectionViewParams { - let marketplaces: Vec<&PluginMarketplaceEntry> = response.marketplaces.iter().collect(); - - let total: usize = marketplaces - .iter() - .map(|marketplace| marketplace.plugins.len()) - .sum(); - let installed = marketplaces - .iter() - .flat_map(|marketplace| marketplace.plugins.iter()) - .filter(|plugin| plugin.installed) - .count(); - - let all_entries = plugin_entries_for_marketplaces(marketplaces.iter().copied()); - let name_column_width = all_entries - .iter() - .map(|(_, _, display_name)| { - PLUGIN_ROW_PREFIX_WIDTH + UnicodeWidthStr::width(display_name.as_str()) - }) - .chain([UnicodeWidthStr::width("Add marketplace")]) - .max(); - let installed_entries = all_entries - .iter() - .filter(|(_, plugin, _)| plugin.installed) - .cloned() - .collect(); - - let mut tabs = Vec::new(); - let mut tab_footer_hints = Vec::new(); - tabs.push(SelectionTab { - id: ALL_PLUGINS_TAB_ID.to_string(), - label: "All Plugins".to_string(), - header: plugins_header( - "Browse plugins from available marketplaces.".to_string(), - format!("Installed {installed} of {total} available plugins."), - ), - items: self.plugin_selection_items( - all_entries, - /*include_marketplace_names*/ true, - "No marketplace plugins available", - "No plugins are available in the discovered marketplaces.", - ), - }); - - tabs.push(SelectionTab { - id: INSTALLED_PLUGINS_TAB_ID.to_string(), - label: format!("Installed ({installed})"), - header: plugins_header( - "Installed plugins.".to_string(), - format!("Showing {installed} installed plugins."), - ), - items: self.plugin_selection_items( - installed_entries, - /*include_marketplace_names*/ true, - "No installed plugins", - "No installed plugins.", - ), - }); - - let curated_marketplace = marketplaces - .iter() - .find(|marketplace| is_openai_curated_marketplace_name(&marketplace.name)) - .copied(); - let curated_entries = curated_marketplace - .map(|marketplace| plugin_entries_for_marketplaces([marketplace])) - .unwrap_or_default(); - let curated_total = curated_entries.len(); - let curated_installed = curated_entries - .iter() - .filter(|(_, plugin, _)| plugin.installed) - .count(); - tabs.push(SelectionTab { - id: OPENAI_CURATED_TAB_ID.to_string(), - label: "OpenAI Curated".to_string(), - header: plugins_header( - "OpenAI Curated marketplace.".to_string(), - format!("Installed {curated_installed} of {curated_total} OpenAI Curated plugins."), - ), - items: self.plugin_selection_items( - curated_entries, - /*include_marketplace_names*/ false, - "No OpenAI Curated plugins available", - "No OpenAI Curated plugins available.", - ), - }); - - let mut additional_marketplaces: Vec<&PluginMarketplaceEntry> = marketplaces - .iter() - .copied() - .filter(|marketplace| !is_openai_curated_marketplace_name(&marketplace.name)) - .collect(); - additional_marketplaces.sort_by(|left, right| { - marketplace_display_name(left) - .to_ascii_lowercase() - .cmp(&marketplace_display_name(right).to_ascii_lowercase()) - .then_with(|| marketplace_display_name(left).cmp(&marketplace_display_name(right))) - .then_with(|| left.name.cmp(&right.name)) - }); - - let labels = disambiguate_duplicate_tab_labels( - additional_marketplaces - .iter() - .map(|marketplace| marketplace_display_name(marketplace)) - .collect(), - ); - for (marketplace, label) in additional_marketplaces.into_iter().zip(labels) { - let entries = plugin_entries_for_marketplaces([marketplace]); - let marketplace_total = entries.len(); - let marketplace_installed = entries - .iter() - .filter(|(_, plugin, _)| plugin.installed) - .count(); - let tab_id = marketplace_tab_id(marketplace); - let can_remove_marketplace = - marketplace_is_user_configured(&self.config, &marketplace.name); - let can_upgrade_marketplace = marketplace.path.is_some() - && marketplace_is_user_configured_git(&self.config, &marketplace.name); - if can_remove_marketplace || can_upgrade_marketplace { - tab_footer_hints.push(( - tab_id.clone(), - plugins_popup_hint_line( - /*can_remove_marketplace*/ can_remove_marketplace, - /*can_upgrade_marketplace*/ can_upgrade_marketplace, - ), - )); - } - let header = if self.newly_installed_marketplace_tab_id.as_deref() == Some(&tab_id) { - plugins_header( - format!("{label} installed successfully."), - "Select the plugins you want to use and press Enter to install or view details." - .to_string(), - ) - } else { - plugins_header( - format!("{label}."), - format!( - "Installed {marketplace_installed} of {marketplace_total} {label} plugins." - ), - ) - }; - tabs.push(SelectionTab { - id: tab_id, - label: label.clone(), - header, - items: self.plugin_selection_items( - entries, - /*include_marketplace_names*/ false, - "No plugins available in this marketplace", - "No plugins available in this marketplace.", - ), - }); - } - - tabs.push(self.marketplace_add_tab()); - - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(()), - footer_hint: Some(plugins_popup_hint_line( - /*can_remove_marketplace*/ false, /*can_upgrade_marketplace*/ false, - )), - tab_footer_hints, - tabs, - initial_tab_id: active_tab_id, - is_searchable: true, - search_placeholder: Some("Type to search plugins".to_string()), - col_width_mode: ColumnWidthMode::AutoAllRows, - row_display: SelectionRowDisplay::SingleLine, - name_column_width, - initial_selected_idx, - ..Default::default() - } - } - - fn marketplace_add_tab(&self) -> SelectionTab { - SelectionTab { - id: ADD_MARKETPLACE_TAB_ID.to_string(), - label: "Add Marketplace".to_string(), - header: plugins_header( - "Add a marketplace from a Git repo or local root.".to_string(), - "Enter a source to make its plugins available in this menu.".to_string(), - ), - items: vec![SelectionItem { - name: "Add marketplace".to_string(), - description: Some( - "Enter owner/repo, a Git URL, or a local marketplace path.".to_string(), - ), - selected_description: Some( - "Press Enter to enter a marketplace source.".to_string(), - ), - actions: vec![Box::new(|tx| { - tx.send(AppEvent::OpenMarketplaceAddPrompt); - })], - ..Default::default() - }], - } - } - - fn plugin_detail_popup_params( - &self, - plugins_response: &PluginListResponse, - plugin: &PluginDetail, - ) -> SelectionViewParams { - let marketplace_label = plugin.marketplace_name.clone(); - let display_name = plugin_display_name(&plugin.summary); - let detail_status_label = - if plugin.summary.availability == PluginAvailability::DisabledByAdmin { - "Disabled by admin" - } else if plugin.summary.installed { - if plugin.summary.enabled { - "Installed" - } else { - "Disabled" - } - } else { - match plugin.summary.install_policy { - PluginInstallPolicy::NotAvailable => "Not installable", - PluginInstallPolicy::Available => "Can be installed", - PluginInstallPolicy::InstalledByDefault => "Available by default", - } - }; - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from( - format!("{display_name} · {detail_status_label} · {marketplace_label}").bold(), - )); - if !plugin.summary.installed { - header.push(PluginDisclosureLine { - line: Line::from(vec![ - "Data shared with this app is subject to the app's ".into(), - "terms of service".bold(), - " and ".into(), - "privacy policy".bold(), - ". ".into(), - "Learn more".cyan().underlined(), - ".".into(), - ]), - }); - } - if let Some(description) = plugin_detail_description(plugin) { - header.push(Line::from(description.dim())); - } - - let cwd = self.config.cwd.to_path_buf(); - let plugins_response = plugins_response.clone(); - let mut items = vec![SelectionItem { - name: "Back to plugins".to_string(), - description: Some("Return to the plugin list.".to_string()), - selected_description: Some("Return to the plugin list.".to_string()), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { - cwd: cwd.clone(), - result: Ok(plugins_response.clone()), - }); - })], - ..Default::default() - }]; - - if plugin.summary.installed { - if let Some(plugin_id) = plugin_uninstall_id(&plugin.summary) { - let uninstall_cwd = self.config.cwd.to_path_buf(); - let plugin_display_name = display_name; - items.push(SelectionItem { - name: "Uninstall plugin".to_string(), - description: Some("Remove this plugin now.".to_string()), - selected_description: Some("Remove this plugin now.".to_string()), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::OpenPluginUninstallLoading { - plugin_display_name: plugin_display_name.clone(), - }); - tx.send(AppEvent::FetchPluginUninstall { - cwd: uninstall_cwd.clone(), - plugin_id: plugin_id.clone(), - plugin_display_name: plugin_display_name.clone(), - }); - })], - ..Default::default() - }); - } else { - items.push(SelectionItem { - name: "Uninstall plugin".to_string(), - description: Some( - "This remote plugin did not provide an uninstall identity.".to_string(), - ), - is_disabled: true, - ..Default::default() - }); - } - } else if plugin.summary.availability == PluginAvailability::DisabledByAdmin { - items.push(SelectionItem { - name: "Install plugin".to_string(), - description: Some("This plugin is disabled by your workspace admin.".to_string()), - is_disabled: true, - ..Default::default() - }); - } else if plugin.summary.install_policy == PluginInstallPolicy::NotAvailable { - items.push(SelectionItem { - name: "Install plugin".to_string(), - description: Some( - "This plugin is not installable from this marketplace.".to_string(), - ), - is_disabled: true, - ..Default::default() - }); - } else if let Some(location) = plugin_detail_location(plugin) { - let install_cwd = self.config.cwd.to_path_buf(); - let plugin_name = plugin_request_name(&plugin.summary); - let plugin_display_name = display_name; - items.push(SelectionItem { - name: "Install plugin".to_string(), - description: Some("Install this plugin now.".to_string()), - selected_description: Some("Install this plugin now.".to_string()), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::OpenPluginInstallLoading { - plugin_display_name: plugin_display_name.clone(), - }); - tx.send(AppEvent::FetchPluginInstall { - cwd: install_cwd.clone(), - location: location.clone(), - plugin_name: plugin_name.clone(), - plugin_display_name: plugin_display_name.clone(), - }); - })], - ..Default::default() - }); - } else { - items.push(SelectionItem { - name: "Install plugin".to_string(), - description: Some("This plugin did not provide an install location.".to_string()), - is_disabled: true, - ..Default::default() - }); - } - - items.push(SelectionItem { - name: "Skills".to_string(), - description: Some(plugin_skill_summary(plugin)), - is_disabled: true, - ..Default::default() - }); - items.push(SelectionItem { - name: "Hooks".to_string(), - description: Some(plugin_hook_summary(plugin)), - is_disabled: true, - ..Default::default() - }); - items.push(SelectionItem { - name: "Apps".to_string(), - description: Some(plugin_app_summary(plugin)), - is_disabled: true, - ..Default::default() - }); - items.push(SelectionItem { - name: "MCP Servers".to_string(), - description: Some(plugin_mcp_summary(plugin)), - is_disabled: true, - ..Default::default() - }); - - SelectionViewParams { - view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), - footer_hint: Some(plugin_detail_hint_line()), - items, - col_width_mode: ColumnWidthMode::AutoAllRows, - ..Default::default() - } - } - - fn plugin_selection_items<'a>( - &self, - mut plugin_entries: Vec<(&'a PluginMarketplaceEntry, &'a PluginSummary, String)>, - include_marketplace_names: bool, - empty_name: &str, - empty_description: &str, - ) -> Vec { - sort_plugin_entries(&mut plugin_entries); - let status_label_width = plugin_entries - .iter() - .map(|(_, plugin, _)| plugin_status_label(plugin).chars().count()) - .max() - .unwrap_or(0); - - let mut items: Vec = Vec::new(); - for (marketplace, plugin, display_name) in plugin_entries { - let marketplace_label = marketplace_display_name(marketplace); - let status_label = plugin_status_label(plugin); - let description = if include_marketplace_names { - plugin_brief_description(plugin, &marketplace_label, status_label_width) - } else { - plugin_brief_description_without_marketplace(plugin, status_label_width) - }; - let plugin_detail_request = plugin_detail_request_for_entry(marketplace, plugin); - let can_view_details = plugin_detail_request.is_some(); - let disabled_by_admin = plugin.availability == PluginAvailability::DisabledByAdmin; - let can_toggle_plugin = plugin.installed && !disabled_by_admin; - let selected_status_label = format!("{status_label: = - if let Some((location, plugin_name)) = plugin_detail_request { - vec![Box::new(move |tx| { - tx.send(AppEvent::OpenPluginDetailLoading { - plugin_display_name: plugin_display_name.clone(), - }); - let (marketplace_path, remote_marketplace_name) = - location.clone().into_request_params(); - tx.send(AppEvent::FetchPluginDetail { - cwd: cwd.clone(), - params: codex_app_server_protocol::PluginReadParams { - marketplace_path, - remote_marketplace_name, - plugin_name: plugin_name.clone(), - }, - }); - })] - } else { - Vec::new() - }; - let is_disabled = !can_view_details && !plugin.installed; - let disabled_reason = is_disabled.then(|| "plugin details are unavailable".to_string()); - - items.push(SelectionItem { - name: display_name, - toggle, - toggle_placeholder: (!can_toggle_plugin).then_some("[-] "), - description: Some(description), - selected_description: Some(selected_description), - search_value: Some(search_value), - actions, - is_disabled, - disabled_reason, - ..Default::default() - }); - } - - if items.is_empty() { - items.push(SelectionItem { - name: empty_name.to_string(), - description: Some(empty_description.to_string()), - is_disabled: true, - ..Default::default() - }); - } - items - } -} - -fn plugins_popup_hint_line( - can_remove_marketplace: bool, - can_upgrade_marketplace: bool, -) -> Line<'static> { - match (can_remove_marketplace, can_upgrade_marketplace) { - (true, true) => Line::from( - "ctrl + u upgrade · ctrl + r remove · space toggle · ←/→ tabs · enter details · esc close", - ), - (true, false) => { - Line::from("ctrl + r remove · space toggle · ←/→ tabs · enter details · esc close") - } - (false, true) => { - Line::from("ctrl + u upgrade · space toggle · ←/→ tabs · enter details · esc close") - } - (false, false) => Line::from( - "space enable/disable · ←/→ select marketplace · enter view details · esc close", - ), - } -} - -fn plugin_detail_hint_line() -> Line<'static> { - Line::from("Press esc to close.") -} - -fn plugins_header(subtitle: String, count_line: String) -> Box { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from(subtitle.dim())); - header.push(Line::from(count_line.dim())); - Box::new(header) -} - -fn plugin_entries_for_marketplaces<'a>( - marketplaces: impl IntoIterator, -) -> Vec<(&'a PluginMarketplaceEntry, &'a PluginSummary, String)> { - marketplaces - .into_iter() - .flat_map(|marketplace| { - marketplace - .plugins - .iter() - .map(move |plugin| (marketplace, plugin, plugin_display_name(plugin))) - }) - .collect() -} - -fn sort_plugin_entries(entries: &mut [(&PluginMarketplaceEntry, &PluginSummary, String)]) { - entries.sort_by(|left, right| { - right - .1 - .installed - .cmp(&left.1.installed) - .then_with(|| { - left.2 - .to_ascii_lowercase() - .cmp(&right.2.to_ascii_lowercase()) - }) - .then_with(|| left.2.cmp(&right.2)) - .then_with(|| left.1.name.cmp(&right.1.name)) - .then_with(|| left.1.id.cmp(&right.1.id)) - }); -} - -fn marketplace_tab_id(marketplace: &PluginMarketplaceEntry) -> String { - match marketplace.path.as_ref() { - Some(path) => marketplace_tab_id_from_path(path.as_path()), - None => format!("marketplace:{}", marketplace.name), - } -} - -fn marketplace_tab_id_from_path(path: &Path) -> String { - format!("{MARKETPLACE_TAB_ID_PREFIX}{}", path.display()) -} - -fn marketplace_tab_id_matching_saved_id( - saved_tab_id: &str, - marketplaces: &[PluginMarketplaceEntry], -) -> Option { - if let Some(tab_id) = marketplaces.iter().find_map(|marketplace| { - let tab_id = marketplace_tab_id(marketplace); - (tab_id == saved_tab_id).then_some(tab_id) - }) { - return Some(tab_id); - } - - let root = saved_tab_id.strip_prefix(MARKETPLACE_TAB_ID_PREFIX)?; - if root.is_empty() { - return None; - } - let root = Path::new(root); - marketplaces.iter().find_map(|marketplace| { - marketplace - .path - .as_ref() - .is_some_and(|path| path.as_path().starts_with(root)) - .then(|| marketplace_tab_id(marketplace)) - }) -} - -fn merge_remote_marketplaces( - response: &mut PluginListResponse, - remote_marketplaces: Vec, -) { - let remote_names = remote_marketplaces - .iter() - .map(|marketplace| marketplace.name.clone()) - .collect::>(); - response.marketplaces.retain(|marketplace| { - marketplace.path.is_some() - || !remote_marketplace_is_remote_section(marketplace) - && !remote_names.contains(marketplace.name.as_str()) - }); - response.marketplaces.extend(remote_marketplaces); -} - -fn remote_marketplace_is_remote_section(marketplace: &PluginMarketplaceEntry) -> bool { - matches!( - marketplace.name.as_str(), - REMOTE_WORKSPACE_MARKETPLACE_NAME - | REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME - | REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME - | REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME - ) -} - -fn disambiguate_duplicate_tab_labels(labels: Vec) -> Vec { - let mut counts: Vec<(String, usize)> = Vec::new(); - for label in &labels { - if let Some((_, count)) = counts.iter_mut().find(|(existing, _)| existing == label) { - *count += 1; - } else { - counts.push((label.clone(), 1)); - } - } - - let mut seen: Vec<(String, usize)> = Vec::new(); - labels - .into_iter() - .map(|label| { - let total = counts - .iter() - .find(|(existing, _)| existing == &label) - .map(|(_, count)| *count) - .unwrap_or(1); - if total == 1 { - return label; - } - - let current = if let Some((_, seen_count)) = - seen.iter_mut().find(|(existing, _)| existing == &label) - { - *seen_count += 1; - *seen_count - } else { - seen.push((label.clone(), 1)); - 1 - }; - format!("{label} ({current}/{total})") - }) - .collect() -} - -fn marketplace_display_name(marketplace: &PluginMarketplaceEntry) -> String { - marketplace - .interface - .as_ref() - .and_then(|interface| interface.display_name.as_deref()) - .map(str::trim) - .filter(|display_name| !display_name.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| marketplace.name.clone()) -} - -fn marketplace_is_user_configured(config: &Config, marketplace_name: &str) -> bool { - let Some(user_config) = config.config_layer_stack.effective_user_config() else { - return false; - }; - user_config - .get("marketplaces") - .and_then(toml::Value::as_table) - .is_some_and(|marketplaces| marketplaces.contains_key(marketplace_name)) -} - -fn marketplace_is_user_configured_git(config: &Config, marketplace_name: &str) -> bool { - config - .config_layer_stack - .get_active_user_layer() - .and_then(|user_layer| user_layer.config.get("marketplaces")) - .and_then(toml::Value::as_table) - .and_then(|marketplaces| marketplaces.get(marketplace_name)) - .and_then(toml::Value::as_table) - .and_then(|marketplace| marketplace.get("source_type")) - .and_then(toml::Value::as_str) - .is_some_and(|source_type| source_type == "git") -} - -fn plugin_display_name(plugin: &PluginSummary) -> String { - plugin - .interface - .as_ref() - .and_then(|interface| interface.display_name.as_deref()) - .map(str::trim) - .filter(|display_name| !display_name.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| plugin.name.clone()) -} - -fn plugin_brief_description( - plugin: &PluginSummary, - marketplace_label: &str, - status_label_width: usize, -) -> String { - let status_label = plugin_status_label(plugin); - let status_label = format!("{status_label: format!("{status_label} · {marketplace_label} · {description}"), - None => format!("{status_label} · {marketplace_label}"), - } -} - -fn plugin_brief_description_without_marketplace( - plugin: &PluginSummary, - status_label_width: usize, -) -> String { - let status_label = plugin_status_label(plugin); - let status_label = format!("{status_label: format!("{status_label} · {description}"), - None => status_label, - } -} - -fn plugin_status_label(plugin: &PluginSummary) -> &'static str { - if plugin.availability == PluginAvailability::DisabledByAdmin { - return "Disabled by admin"; - } - if plugin.installed { - if plugin.enabled { - "Installed" - } else { - "Disabled" - } - } else { - match plugin.install_policy { - PluginInstallPolicy::NotAvailable => "Not installable", - PluginInstallPolicy::Available => "Available", - PluginInstallPolicy::InstalledByDefault => "Available", - } - } -} - -fn plugin_location_for_marketplace( - marketplace: &PluginMarketplaceEntry, - plugin: &PluginSummary, -) -> Option { - if let Some(marketplace_path) = marketplace.path.clone() { - return Some(PluginLocation::Local { marketplace_path }); - } - plugin_remote_identity(plugin).map(|_| PluginLocation::Remote { - marketplace_name: marketplace.name.clone(), - }) -} - -fn plugin_detail_location(plugin: &PluginDetail) -> Option { - if let Some(marketplace_path) = plugin.marketplace_path.clone() { - return Some(PluginLocation::Local { marketplace_path }); - } - plugin_remote_identity(&plugin.summary).map(|_| PluginLocation::Remote { - marketplace_name: plugin.marketplace_name.clone(), - }) -} - -fn plugin_detail_request_for_entry( - marketplace: &PluginMarketplaceEntry, - plugin: &PluginSummary, -) -> Option<(PluginLocation, String)> { - plugin_location_for_marketplace(marketplace, plugin) - .map(|location| (location, plugin_request_name(plugin))) -} - -fn plugin_request_name(plugin: &PluginSummary) -> String { - if matches!(&plugin.source, PluginSource::Remote) - && let Some(remote_plugin_id) = plugin_remote_identity(plugin) - { - return remote_plugin_id; - } - plugin.name.clone() -} - -fn plugin_remote_identity(plugin: &PluginSummary) -> Option { - plugin - .share_context - .as_ref() - .map(|context| context.remote_plugin_id.clone()) - .or_else(|| plugin.remote_plugin_id.clone()) -} - -fn plugin_uninstall_id(plugin: &PluginSummary) -> Option { - if matches!(&plugin.source, PluginSource::Remote) { - return plugin_remote_identity(plugin); - } - Some(plugin.id.clone()) -} - -fn plugin_description(plugin: &PluginSummary) -> Option { - plugin - .interface - .as_ref() - .and_then(|interface| { - interface - .short_description - .as_deref() - .or(interface.long_description.as_deref()) - }) - .map(str::trim) - .filter(|description| !description.is_empty()) - .map(str::to_string) -} - -fn plugin_detail_description(plugin: &PluginDetail) -> Option { - plugin - .description - .as_deref() - .or_else(|| { - plugin - .summary - .interface - .as_ref() - .and_then(|interface| interface.long_description.as_deref()) - }) - .or_else(|| { - plugin - .summary - .interface - .as_ref() - .and_then(|interface| interface.short_description.as_deref()) - }) - .map(str::trim) - .filter(|description| !description.is_empty()) - .map(str::to_string) -} - -fn plugin_skill_summary(plugin: &PluginDetail) -> String { - if plugin.skills.is_empty() { - "No plugin skills.".to_string() - } else { - plugin - .skills - .iter() - .map(|skill| skill.name.as_str()) - .collect::>() - .join(", ") - } -} - -fn plugin_app_summary(plugin: &PluginDetail) -> String { - if plugin.apps.is_empty() { - "No plugin apps.".to_string() - } else { - plugin - .apps - .iter() - .map(|app| app.name.as_str()) - .collect::>() - .join(", ") - } -} - -fn plugin_hook_summary(plugin: &PluginDetail) -> String { - if plugin.hooks.is_empty() { - "No plugin hooks.".to_string() - } else { - let mut event_counts = Vec::<(codex_app_server_protocol::HookEventName, usize)>::new(); - for hook in &plugin.hooks { - if let Some((_, handler_count)) = event_counts - .iter_mut() - .find(|(event_name, _)| *event_name == hook.event_name) - { - *handler_count += 1; - } else { - event_counts.push((hook.event_name, 1)); - } - } - event_counts - .into_iter() - .map(|(event_name, handler_count)| format!("{event_name:?} ({handler_count})")) - .collect::>() - .join(", ") - } -} - -fn plugin_mcp_summary(plugin: &PluginDetail) -> String { - if plugin.mcp_servers.is_empty() { - "No plugin MCP servers.".to_string() - } else { - plugin.mcp_servers.join(", ") - } } From cca39d51ba5e159bf71eae319e09915bc9ecce01 Mon Sep 17 00:00:00 2001 From: Konstantine Kahadze Date: Wed, 17 Jun 2026 12:16:18 -0700 Subject: [PATCH 004/112] [codex] Use compact OpenAI docs search queries (#28389) ## Summary Updates the bundled OpenAI Docs skill to use compact, title-like search queries. This performs better in Codex. ## Validation - OpenAI Docs skill validation passed - `git diff --check` --- codex-rs/skills/src/assets/samples/openai-docs/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md b/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md index 30526bd9d8..df395fb442 100644 --- a/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md +++ b/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md @@ -135,7 +135,7 @@ If MCP tools fail or no OpenAI docs resources are available: 5. Leave historical docs, examples, eval baselines, fixtures, provider comparisons, provider registries, pricing tables, alias defaults, low-cost fallback paths, and ambiguous older model usage unchanged unless the user explicitly asks to upgrade them. 6. Keep SDK, tooling, IDE, plugin, shell, auth, and provider-environment migrations out of a model-and-prompt upgrade unless the user explicitly asks for them. 7. If an upgrade needs API-surface changes, schema rewiring, tool-handler changes, or implementation work beyond a literal model-string replacement and prompt edits, report it as blocked or confirmation-needed. -8. For general docs lookup, search docs with a precise query, fetch the best page and exact section needed, and answer with concise citations. +8. For general docs lookup, start with a compact, title-like search query of 2-6 essential terms. Do not turn the full user question into a keyword list. Fetch the best page and exact section needed, and answer with concise citations. ## Reference map From 5867b529ae91afad02de74a0bc1a2162e3721688 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Wed, 17 Jun 2026 12:36:16 -0700 Subject: [PATCH 005/112] unified-exec: preserve PathUri through exec-server (#28681) ## Why It should be possible for app-server to handle "foreign" OS paths in unified_exec working directories, allowing e.g. a Linux app-server to run processes on e.g. a Windows exec-server. ## What Convert the core unified_exec cwd values to use `PathUri`. Adds fallible path conversion in several places to try to minimize the scope of this change. The only time this change suppresses errors from converting `PathUri` to an `AbsolutePathBuf` is when the turn is configured with no sandboxing at all to allow us to make progress testing without sandboxing. Future changes to apply_patch and sandboxing will clean up these error paths. A tool's cwd is resolved from joining a model-provided workdir to the environment's cwd. When using `AbsolutePathBuf::join()`, an absolute-path workdir would overwrite the environment's cwd and we would resolve permissions/sandboxing against the model-provided path. This change extends `PathUri::join()` to also treat an absolute rhs as an override of the base/lhs. This also removes some coverage from the remove_env_windows tests until a follow-up converts foreign paths in command exec events correctly. ## Breaking Changes When using `AbsolutePathBuf::join()` for workdir resolution, we ended up resolving tilde-prefixed paths against the app-server's `$HOME`, e.g. `~/foo/bar` becomes `/home/anp/foo/bar`. It's difficult to do this with `PathUri` joining, so after offline discussion this PR no longer implements it. A quick check of some power users' rollouts suggests that models don't actually generate home-prefixed absolute working directories for their spawns, so this shouldn't have any real blast radius. --- codex-rs/app-server/src/command_exec.rs | 4 + .../request_processors/thread_processor.rs | 24 ++- codex-rs/apply-patch/src/invocation.rs | 11 +- codex-rs/apply-patch/src/lib.rs | 4 +- codex-rs/core/src/apply_patch.rs | 14 +- codex-rs/core/src/codex_thread.rs | 3 +- codex-rs/core/src/exec.rs | 11 +- codex-rs/core/src/sandboxing/mod.rs | 6 +- codex-rs/core/src/tasks/user_shell.rs | 4 +- codex-rs/core/src/tools/events.rs | 11 +- .../core/src/tools/handlers/apply_patch.rs | 9 +- .../handlers/unified_exec/exec_command.rs | 107 ++++++++---- codex-rs/core/src/tools/orchestrator.rs | 6 +- .../core/src/tools/runtimes/apply_patch.rs | 31 +++- .../src/tools/runtimes/apply_patch_tests.rs | 5 +- codex-rs/core/src/tools/runtimes/mod_tests.rs | 4 +- .../tools/runtimes/shell/unix_escalation.rs | 29 ++- .../core/src/tools/runtimes/unified_exec.rs | 122 +++++++++---- codex-rs/core/src/tools/sandboxing.rs | 2 +- .../core/src/unified_exec/async_watcher.rs | 8 +- codex-rs/core/src/unified_exec/errors.rs | 3 + codex-rs/core/src/unified_exec/mod.rs | 8 +- codex-rs/core/src/unified_exec/mod_tests.rs | 9 +- .../core/src/unified_exec/process_manager.rs | 33 +++- .../src/unified_exec/process_manager_tests.rs | 12 +- .../remote_env_windows_test.rs | 19 +- codex-rs/exec-server/src/fs_sandbox.rs | 2 + codex-rs/exec/tests/suite/sandbox.rs | 7 +- codex-rs/sandboxing/src/manager.rs | 165 +++++++++++++----- codex-rs/sandboxing/src/manager_tests.rs | 13 +- .../utils/path-uri/src/api_path_string.rs | 4 + codex-rs/utils/path-uri/src/lib.rs | 63 ++++++- codex-rs/utils/path-uri/src/tests.rs | 87 ++++++++- 33 files changed, 634 insertions(+), 206 deletions(-) diff --git a/codex-rs/app-server/src/command_exec.rs b/codex-rs/app-server/src/command_exec.rs index 2f62f099e9..f66bd95794 100644 --- a/codex-rs/app-server/src/command_exec.rs +++ b/codex-rs/app-server/src/command_exec.rs @@ -237,6 +237,10 @@ impl CommandExecManager { arg0, .. } = exec_request; + // TODO(anp): Keep PathUri through the local command launch boundary. + let cwd = cwd + .to_abs_path() + .map_err(|err| invalid_request(format!("invalid command cwd: {err}")))?; let stream_stdin = tty || stream_stdin; let stream_stdout_stderr = tty || stream_stdout_stderr; diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 907b05a03b..91005d6f71 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1781,16 +1781,22 @@ impl ThreadRequestProcessor { .list_background_terminals() .await .into_iter() - .map(|terminal| ThreadBackgroundTerminal { - item_id: terminal.item_id, - process_id: terminal.process_id, - command: terminal.command, - cwd: terminal.cwd, - os_pid: None, - cpu_percent: None, - rss_kb: None, + .map(|terminal| { + // TODO(anp): Migrate ThreadBackgroundTerminal to PathUri. + let cwd = terminal.cwd.to_abs_path().map_err(|err| { + internal_error(format!("background terminal has invalid cwd: {err}")) + })?; + Ok(ThreadBackgroundTerminal { + item_id: terminal.item_id, + process_id: terminal.process_id, + command: terminal.command, + cwd, + os_pid: None, + cpu_percent: None, + rss_kb: None, + }) }) - .collect::>(); + .collect::, JSONRPCErrorError>>()?; let (data, next_cursor) = paginate_background_terminals(&terminals, cursor, limit)?; diff --git a/codex-rs/apply-patch/src/invocation.rs b/codex-rs/apply-patch/src/invocation.rs index 8701750fbe..0e0261c3b5 100644 --- a/codex-rs/apply-patch/src/invocation.rs +++ b/codex-rs/apply-patch/src/invocation.rs @@ -230,7 +230,7 @@ pub async fn verify_apply_patch_args( MaybeApplyPatchVerified::Body(ApplyPatchAction { changes, patch, - cwd: effective_cwd, + cwd: effective_cwd.into(), }) } @@ -811,7 +811,9 @@ PATCH"#, }, )]), patch: argv[1].clone(), - cwd: AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(), + cwd: AbsolutePathBuf::from_absolute_path(session_dir.path()) + .unwrap() + .into(), }) ); } @@ -851,7 +853,10 @@ PATCH"#, other => panic!("expected verified body, got {other:?}"), }; - assert_eq!(action.cwd.as_path(), worktree_dir.as_path()); + assert_eq!( + action.cwd.to_abs_path().unwrap().as_path(), + worktree_dir.as_path() + ); let source_path = worktree_dir.join(source_name); let change = action diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index 386f1e5346..ff11c43242 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -142,7 +142,7 @@ pub struct ApplyPatchAction { pub patch: String, /// The working directory that was used to resolve relative paths in the patch. - pub cwd: AbsolutePathBuf, + pub cwd: PathUri, } impl ApplyPatchAction { @@ -174,7 +174,7 @@ impl ApplyPatchAction { #[expect(clippy::expect_used)] Self { changes, - cwd: path.parent().expect("path should have parent"), + cwd: path.parent().expect("path should have parent").into(), patch, } } diff --git a/codex-rs/core/src/apply_patch.rs b/codex-rs/core/src/apply_patch.rs index 38dc45ec1f..208db44d9c 100644 --- a/codex-rs/core/src/apply_patch.rs +++ b/codex-rs/core/src/apply_patch.rs @@ -35,12 +35,24 @@ pub(crate) async fn apply_patch( file_system_sandbox_policy: &FileSystemSandboxPolicy, action: ApplyPatchAction, ) -> InternalApplyPatchInvocation { + // TODO(anp): Migrate patch safety checks to PathUri. + let cwd = match action.cwd.to_abs_path() { + Ok(cwd) => cwd, + Err(err) => { + return InternalApplyPatchInvocation::Output(Err(FunctionCallError::RespondToModel( + format!( + "patch cwd `{}` is not native to the Codex host: {err}", + action.cwd + ), + ))); + } + }; match assess_patch_safety( &action, turn_context.approval_policy.value(), &turn_context.permission_profile(), file_system_sandbox_policy, - &action.cwd, + &cwd, turn_context.windows_sandbox_level, ) { SafetyCheck::AutoApprove { diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 451b497827..99e0391d3b 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -41,6 +41,7 @@ use codex_thread_store::ThreadMetadataPatch; use codex_thread_store::ThreadStoreError; use codex_thread_store::ThreadStoreResult; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use rmcp::model::ReadResourceRequestParams; use std::collections::BTreeMap; use std::collections::HashMap; @@ -164,7 +165,7 @@ pub struct BackgroundTerminalInfo { pub item_id: String, pub process_id: String, pub command: String, - pub cwd: AbsolutePathBuf, + pub cwd: PathUri, } /// Conduit for the bidirectional stream of messages that compose a thread diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 2b390ddba2..4983e0a806 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -381,7 +381,7 @@ pub fn build_exec_request( }) .map(|request| { let windows_sandbox_workspace_roots = if windows_sandbox_workspace_roots.is_empty() { - vec![request.sandbox_policy_cwd.clone()] + vec![sandbox_cwd.clone()] } else { windows_sandbox_workspace_roots.to_vec() }; @@ -440,6 +440,15 @@ pub(crate) async fn execute_exec_request( arg0, } = exec_request; + // TODO(anp): Keep PathUri through the local process launch boundary. + let cwd = cwd + .to_abs_path() + .map_err(|err| CodexErr::InvalidRequest(format!("invalid exec cwd: {err}")))?; + // TODO(anp): Keep PathUri through the Windows sandbox launch boundary. + let windows_sandbox_policy_cwd = windows_sandbox_policy_cwd + .to_abs_path() + .map_err(|err| CodexErr::InvalidRequest(format!("invalid sandbox cwd: {err}")))?; + let params = ExecParams { command, cwd, diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 009cd71da8..fa069afd4a 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -25,6 +25,7 @@ use codex_sandboxing::SandboxExecRequest; use codex_sandboxing::SandboxType; use codex_sandboxing::WindowsSandboxFilesystemOverrides; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use std::collections::HashMap; #[derive(Debug)] @@ -42,14 +43,14 @@ pub(crate) struct ExecServerEnvConfig { #[derive(Debug)] pub struct ExecRequest { pub command: Vec, - pub cwd: AbsolutePathBuf, + pub cwd: PathUri, pub env: HashMap, pub(crate) exec_server_env_config: Option, pub network: Option, pub expiration: ExecExpiration, pub capture_policy: ExecCapturePolicy, pub sandbox: SandboxType, - pub windows_sandbox_policy_cwd: AbsolutePathBuf, + pub windows_sandbox_policy_cwd: PathUri, pub windows_sandbox_workspace_roots: Vec, pub windows_sandbox_level: WindowsSandboxLevel, pub windows_sandbox_private_desktop: bool, @@ -76,6 +77,7 @@ impl ExecRequest { permission_profile: PermissionProfile, arg0: Option, ) -> Self { + let cwd = PathUri::from_abs_path(&cwd); let windows_sandbox_policy_cwd = cwd.clone(); let (file_system_sandbox_policy, network_sandbox_policy) = permission_profile.to_runtime_permissions(); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 37835c36a1..057718b9f7 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -199,7 +199,7 @@ pub(crate) async fn execute_user_shell_command( let permission_profile = PermissionProfile::Disabled; let exec_env = ExecRequest { command: exec_command.clone(), - cwd: cwd.clone(), + cwd: cwd.clone().into(), env: exec_env_map, exec_server_env_config: None, // `/shell` is the explicit full-access escape hatch, so it must not @@ -210,7 +210,7 @@ pub(crate) async fn execute_user_shell_command( expiration: USER_SHELL_TIMEOUT_MS.into(), capture_policy: ExecCapturePolicy::ShellTool, sandbox: SandboxType::None, - windows_sandbox_policy_cwd: cwd.clone(), + windows_sandbox_policy_cwd: cwd.clone().into(), windows_sandbox_workspace_roots: turn_context.config.effective_workspace_roots(), windows_sandbox_level: turn_context.windows_sandbox_level, windows_sandbox_private_desktop: turn_context diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index c30cee84ca..bb385f37b5 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -21,6 +21,7 @@ use codex_protocol::protocol::PatchApplyStatus; use codex_protocol::protocol::TurnDiffEvent; use codex_shell_command::parse_command::parse_command; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use std::collections::HashMap; use std::path::PathBuf; use std::time::Duration; @@ -133,7 +134,7 @@ pub(crate) enum ToolEmitter { }, UnifiedExec { command: Vec, - cwd: AbsolutePathBuf, + cwd: PathUri, source: ExecCommandSource, parsed_cmd: Vec, process_id: Option, @@ -165,7 +166,7 @@ impl ToolEmitter { pub fn unified_exec( command: &[String], - cwd: AbsolutePathBuf, + cwd: PathUri, source: ExecCommandSource, process_id: Option, ) -> Self { @@ -320,11 +321,15 @@ impl ToolEmitter { }, stage, ) => { + // TODO(anp): Migrate exec command protocol events to PathUri. + let Ok(cwd) = cwd.to_abs_path() else { + return; + }; emit_exec_stage( ctx, ExecCommandInput::new( command, - cwd, + &cwd, parsed_cmd, *source, /*interaction_input*/ None, diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 9004eb9922..9a00fac94d 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -205,16 +205,19 @@ fn format_update_chunks_for_progress(chunks: &[codex_apply_patch::UpdateFileChun fn file_paths_for_action(action: &ApplyPatchAction) -> Vec { let mut keys = Vec::new(); - let cwd = &action.cwd; + // TODO(anp): Migrate permission path accounting to PathUri. + let Ok(cwd) = action.cwd.to_abs_path() else { + return keys; + }; for (path, change) in action.changes() { - if let Some(key) = to_abs_path(cwd, path) { + if let Some(key) = to_abs_path(&cwd, path) { keys.push(key); } if let ApplyPatchFileChange::Update { move_path, .. } = change && let Some(dest) = move_path - && let Some(key) = to_abs_path(cwd, dest) + && let Some(key) = to_abs_path(&cwd, dest) { keys.push(key); } diff --git a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index b5bfe1eb52..db13c6f0f9 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -28,9 +28,13 @@ use crate::unified_exec::generate_chunk_id; use codex_features::Feature; use codex_otel::SessionTelemetry; use codex_otel::TOOL_CALL_UNIFIED_EXEC_METRIC; +use codex_sandboxing::SandboxManager; +use codex_sandboxing::SandboxType; +use codex_sandboxing::SandboxablePreference; use codex_tools::ToolName; use codex_tools::ToolSpec; use codex_utils_output_truncation::approx_token_count; +use codex_utils_path_uri::PathConvention; use super::super::shell_spec::CommandToolOptions; use super::super::shell_spec::create_exec_command_tool_with_environment_id; @@ -129,33 +133,68 @@ impl ExecCommandHandler { "unified exec is unavailable in this session".to_string(), )); }; - // TODO(anp): Resolve tool paths using the selected environment's native path convention - // so unified exec can support relative paths in foreign environments. - let native_environment_cwd = turn_environment.cwd().to_abs_path().map_err(|err| { - FunctionCallError::RespondToModel(format!( - "environment cwd `{}` is not native to the Codex host: {err}", - turn_environment.cwd() - )) - })?; + let native_environment_cwd = turn_environment.cwd().clone(); let cwd = environment_args .workdir .as_deref() .filter(|workdir| !workdir.is_empty()) .map_or_else( - || native_environment_cwd.clone(), + || Ok(native_environment_cwd.clone()), |workdir| native_environment_cwd.join(workdir), - ); + ) + .map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?; let environment = Arc::clone(&turn_environment.environment); let fs = environment.get_filesystem(); - let args: ExecCommandArgs = parse_arguments_with_base_path(&arguments, &cwd)?; + + // A foreign cwd cannot seed the AbsolutePathBufGuard used to resolve relative paths in the + // permissions config below. Consult the configured platform-sandbox requirement before + // deciding whether parsing may continue without that base path. + let sandbox = SandboxManager::new().select_initial( + &turn.file_system_sandbox_policy(), + turn.network_sandbox_policy(), + SandboxablePreference::Auto, + turn.windows_sandbox_level, + turn.network.is_some(), + ); + // `to_abs_path()` alone cannot identify foreign drive paths: `file:///C:/repo` is + // representable as `/C:/repo` on POSIX. Require the inferred convention to match too. + let cwd_uses_native_convention = + cwd.infer_path_convention() == Some(PathConvention::native()); + // TODO(anp): Remove this parsing split once sandboxing supports foreign paths. + let native_cwd = match cwd.to_abs_path() { + Ok(cwd) if cwd_uses_native_convention => Some(cwd), + _ if sandbox == SandboxType::None => None, + Err(err) => return Err(FunctionCallError::RespondToModel(err.to_string())), + Ok(_) => { + return Err(FunctionCallError::RespondToModel(format!( + "path URI `{cwd}` does not use the host's native {} path convention", + PathConvention::native() + ))); + } + }; + let args: ExecCommandArgs = match native_cwd.as_ref() { + Some(native_cwd) => { + // The base path only resolves paths nested in the permissions config types. + parse_arguments_with_base_path(&arguments, native_cwd)? + } + None => { + // Parsing without a base only skips relative-path resolution inside the + // permissions config. That is safe only for a truly unsandboxed attempt; + // sandboxed attempts fall through and return the conversion error below. + parse_arguments(&arguments)? + } + }; let hook_command = args.cmd.clone(); - maybe_emit_implicit_skill_invocation( - session.as_ref(), - context.turn.as_ref(), - &hook_command, - &cwd, - ) - .await; + // TODO(anp) wire PathUri through implicit skills instead of skipping on foreign paths + if let Some(native_cwd) = native_cwd.as_ref() { + maybe_emit_implicit_skill_invocation( + session.as_ref(), + context.turn.as_ref(), + &hook_command, + native_cwd, + ) + .await; + } let process_id = manager.allocate_process_id().await; let shell_mode = shell_mode_for_environment(&turn.unified_exec_shell_mode, environment.as_ref()); @@ -191,10 +230,12 @@ impl ExecCommandHandler { let exec_permission_approvals_enabled = session.features().enabled(Feature::ExecPermissionApprovals); let requested_additional_permissions = additional_permissions.clone(); + // TODO(anp): Make permission matching operate on PathUri for remote environments. + let permission_cwd = native_cwd.as_ref().unwrap_or(&turn.config.cwd); let effective_additional_permissions = apply_granted_turn_permissions( context.session.as_ref(), &turn_environment.environment_id, - cwd.as_path(), + permission_cwd.as_path(), sandbox_permissions, additional_permissions, ) @@ -234,7 +275,7 @@ impl ExecCommandHandler { effective_additional_permissions.sandbox_permissions, effective_additional_permissions.additional_permissions, effective_additional_permissions.permissions_preapproved, - &cwd, + permission_cwd, ) }, |permissions| Ok(Some(permissions)), @@ -246,18 +287,20 @@ impl ExecCommandHandler { } }; - if let Some(output) = intercept_apply_patch( - &command, - &cwd, - fs.as_ref(), - turn_environment.clone(), - context.session.clone(), - context.turn.clone(), - Some(&tracker), - &context.call_id, - "exec_command", - ) - .await? + // TODO(anp) intercept apply_patch properly when cwd is a foreign path + if let Some(native_cwd) = native_cwd.as_ref() + && let Some(output) = intercept_apply_patch( + &command, + native_cwd, + fs.as_ref(), + turn_environment.clone(), + context.session.clone(), + context.turn.clone(), + Some(&tracker), + &context.call_id, + "exec_command", + ) + .await? { manager.release_process_id(process_id).await; return Ok(boxed_tool_output(ExecCommandToolOutput { diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 14c6ad74dc..803b8d6a9f 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -239,8 +239,10 @@ impl ToolOrchestrator { // Platform-specific flag gating is handled by SandboxManager::select_initial. let use_legacy_landlock = turn_ctx.config.features.use_legacy_landlock(); #[allow(deprecated)] - let sandbox_cwd = tool.sandbox_cwd(req).unwrap_or(&turn_ctx.cwd); - let sandbox_policy_cwd = PathUri::from_abs_path(sandbox_cwd); + let sandbox_policy_cwd = tool + .sandbox_cwd(req) + .cloned() + .unwrap_or_else(|| PathUri::from_abs_path(&turn_ctx.cwd)); let workspace_roots = turn_ctx.config.effective_workspace_roots(); let initial_attempt = SandboxAttempt { sandbox: initial_sandbox, diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 9d23fcab3c..9611429a7d 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -78,13 +78,15 @@ impl ApplyPatchRuntime { fn build_guardian_review_request( req: &ApplyPatchRequest, call_id: &str, - ) -> GuardianApprovalRequest { - GuardianApprovalRequest::ApplyPatch { + ) -> std::io::Result { + // TODO(anp): Remove this conversion once the guardian API supports PathUri. + let cwd = req.action.cwd.to_abs_path()?; + Ok(GuardianApprovalRequest::ApplyPatch { id: call_id.to_string(), - cwd: req.action.cwd.clone(), + cwd, files: req.file_paths.clone(), patch: req.action.patch.clone(), - } + }) } fn file_system_sandbox_context_for_attempt( @@ -149,7 +151,16 @@ impl Approvable for ApplyPatchRuntime { let guardian_review_id = ctx.guardian_review_id.clone(); Box::pin(async move { if let Some(review_id) = guardian_review_id { - let action = ApplyPatchRuntime::build_guardian_review_request(req, ctx.call_id); + let action = match ApplyPatchRuntime::build_guardian_review_request( + req, + ctx.call_id, + ) { + Ok(action) => action, + Err(err) => { + tracing::error!(cwd = %req.action.cwd, %err, "guardian apply_patch cwd is not host-native"); + return ReviewDecision::Abort; + } + }; return review_approval_request(session, turn, review_id, action, retry_reason) .await; } @@ -219,7 +230,7 @@ impl Approvable for ApplyPatchRuntime { } impl ToolRuntime for ApplyPatchRuntime { - fn sandbox_cwd<'a>(&self, req: &'a ApplyPatchRequest) -> Option<&'a AbsolutePathBuf> { + fn sandbox_cwd<'a>(&self, req: &'a ApplyPatchRequest) -> Option<&'a PathUri> { Some(&req.action.cwd) } @@ -234,9 +245,15 @@ impl ToolRuntime for ApplyPatchRunti let sandbox = Self::file_system_sandbox_context_for_attempt(req, attempt); let mut stdout = Vec::new(); let mut stderr = Vec::new(); + // TODO(anp): Teach apply_patch to operate on PathUri directly. + let cwd = req + .action + .cwd + .to_abs_path() + .map_err(|err| ToolError::Rejected(err.to_string()))?; let result = codex_apply_patch::apply_patch( &req.action.patch, - &req.action.cwd, + &cwd, &mut stdout, &mut stderr, fs.as_ref(), diff --git a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs index 51ca0b98c8..0dc1ed9a67 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -54,7 +54,7 @@ async fn guardian_review_request_includes_patch_context() { .join("guardian-apply-patch-test.txt") .abs(); let action = ApplyPatchAction::new_add_for_test(&path, "hello".to_string()); - let expected_cwd = action.cwd.clone(); + let expected_cwd = action.cwd.to_abs_path().expect("native patch cwd"); let expected_patch = action.patch.clone(); let request = ApplyPatchRequest { turn_environment: test_turn_environment(codex_exec_server::LOCAL_ENVIRONMENT_ID), @@ -74,7 +74,8 @@ async fn guardian_review_request_includes_patch_context() { permissions_preapproved: false, }; - let guardian_request = ApplyPatchRuntime::build_guardian_review_request(&request, "call-1"); + let guardian_request = ApplyPatchRuntime::build_guardian_review_request(&request, "call-1") + .expect("native guardian request cwd"); assert_eq!( guardian_request, diff --git a/codex-rs/core/src/tools/runtimes/mod_tests.rs b/codex-rs/core/src/tools/runtimes/mod_tests.rs index 2b879e75ca..542589ad89 100644 --- a/codex-rs/core/src/tools/runtimes/mod_tests.rs +++ b/codex-rs/core/src/tools/runtimes/mod_tests.rs @@ -129,10 +129,10 @@ async fn explicit_escalation_prepares_exec_without_managed_network() -> anyhow:: ) .expect("prepare exec request"); - assert_eq!(exec_request.cwd, command_cwd); + assert_eq!(exec_request.cwd, PathUri::from_abs_path(&command_cwd)); assert_eq!( exec_request.windows_sandbox_policy_cwd, - native_sandbox_policy_cwd + PathUri::from_abs_path(&native_sandbox_policy_cwd) ); assert_eq!(exec_request.network, None); for key in PROXY_ENV_KEYS { diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 70390cb3c2..b8e417f856 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -172,6 +172,14 @@ pub(super) async fn try_run_zsh_fork( let exec_policy = Arc::new(RwLock::new( ctx.session.services.exec_policy.current().as_ref().clone(), )); + // TODO(anp): Keep PathUri through the shell escalation boundary. + let sandbox_cwd = sandbox_cwd + .to_abs_path() + .map_err(|err| ToolError::Rejected(err.to_string()))?; + // TODO(anp): Keep PathUri through the shell sandbox policy boundary. + let sandbox_policy_cwd = sandbox_policy_cwd + .to_abs_path() + .map_err(|err| ToolError::Rejected(err.to_string()))?; let command_executor = CoreShellCommandExecutor { command, cwd: sandbox_cwd, @@ -274,9 +282,19 @@ pub(crate) async fn prepare_unified_exec_zsh_fork( let exec_policy = Arc::new(RwLock::new( ctx.session.services.exec_policy.current().as_ref().clone(), )); + // TODO(anp): Keep PathUri through the zsh-fork executor boundary. + let cwd = exec_request + .cwd + .to_abs_path() + .map_err(|err| ToolError::Rejected(err.to_string()))?; + // TODO(anp): Keep PathUri through the zsh-fork sandbox policy boundary. + let sandbox_policy_cwd = exec_request + .windows_sandbox_policy_cwd + .to_abs_path() + .map_err(|err| ToolError::Rejected(err.to_string()))?; let command_executor = CoreShellCommandExecutor { command: exec_request.command.clone(), - cwd: exec_request.cwd.clone(), + cwd, permission_profile: exec_request.permission_profile.clone(), file_system_sandbox_policy: exec_request.file_system_sandbox_policy.clone(), network_sandbox_policy: exec_request.network_sandbox_policy, @@ -285,7 +303,7 @@ pub(crate) async fn prepare_unified_exec_zsh_fork( network: exec_request.network.clone(), windows_sandbox_level: exec_request.windows_sandbox_level, arg0: exec_request.arg0.clone(), - sandbox_policy_cwd: exec_request.windows_sandbox_policy_cwd.clone(), + sandbox_policy_cwd, windows_sandbox_workspace_roots: exec_request.windows_sandbox_workspace_roots.clone(), codex_linux_sandbox_exe: ctx.turn.config.codex_linux_sandbox_exe.clone(), use_legacy_landlock: ctx.turn.config.features.use_legacy_landlock(), @@ -857,14 +875,14 @@ impl CoreShellCommandExecutor { let result = crate::sandboxing::execute_exec_request_with_after_spawn( crate::sandboxing::ExecRequest { command: self.command.clone(), - cwd: self.cwd.clone(), + cwd: self.cwd.clone().into(), env: exec_env, exec_server_env_config: None, network: self.network.clone(), expiration: ExecExpiration::Cancellation(cancel_rx), capture_policy: ExecCapturePolicy::ShellTool, sandbox: self.sandbox, - windows_sandbox_policy_cwd: self.sandbox_policy_cwd.clone(), + windows_sandbox_policy_cwd: self.sandbox_policy_cwd.clone().into(), windows_sandbox_workspace_roots: self.windows_sandbox_workspace_roots.clone(), windows_sandbox_level: self.windows_sandbox_level, windows_sandbox_private_desktop: false, @@ -1010,7 +1028,8 @@ impl CoreShellCommandExecutor { Ok(PreparedExec { command: exec_request.command, - cwd: exec_request.cwd.to_path_buf(), + // TODO(anp): Keep PathUri through the execve-wrapper boundary. + cwd: exec_request.cwd.to_abs_path()?.to_path_buf(), env: exec_request.env, arg0: exec_request.arg0, }) diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 0b141faabc..80f0990b5c 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -21,7 +21,6 @@ use crate::tools::network_approval::NetworkApprovalSpec; use crate::tools::runtimes::RuntimePathPrepends; #[cfg(unix)] use crate::tools::runtimes::apply_zsh_fork_path_prepend; -use crate::tools::runtimes::build_sandbox_command; use crate::tools::runtimes::disable_powershell_profile_for_elevated_windows_sandbox; use crate::tools::runtimes::exec_env_for_sandbox_permissions; use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; @@ -47,13 +46,15 @@ use codex_protocol::error::CodexErr; use codex_protocol::error::SandboxErr; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::protocol::ReviewDecision; +use codex_sandboxing::SandboxCommand; use codex_sandboxing::SandboxablePreference; use codex_shell_command::powershell::prefix_powershell_script_with_utf8; use codex_tools::UnifiedExecShellMode; -use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use futures::future::BoxFuture; use std::collections::HashMap; use tokio_util::sync::CancellationToken; +use tracing::error; /// Request payload used by the unified-exec runtime after approvals and /// sandbox preferences have been resolved for the current turn. @@ -63,8 +64,8 @@ pub struct UnifiedExecRequest { pub shell_type: ShellType, pub hook_command: String, pub process_id: i32, - pub cwd: AbsolutePathBuf, - pub sandbox_cwd: AbsolutePathBuf, + pub cwd: PathUri, + pub sandbox_cwd: PathUri, pub turn_environment: TurnEnvironment, pub env: HashMap, pub exec_server_env_config: Option, @@ -85,7 +86,7 @@ pub struct UnifiedExecRequest { pub struct UnifiedExecApprovalKey { pub environment_id: String, pub command: Vec, - pub cwd: AbsolutePathBuf, + pub cwd: PathUri, pub tty: bool, pub sandbox_permissions: SandboxPermissions, pub additional_permissions: Option, @@ -111,6 +112,24 @@ fn unified_exec_options( } } +fn build_unified_exec_sandbox_command( + command: &[String], + cwd: &PathUri, + env: &HashMap, + additional_permissions: Option, +) -> Result { + let (program, args) = command + .split_first() + .ok_or_else(|| ToolError::Rejected("command args are empty".to_string()))?; + Ok(SandboxCommand { + program: program.clone().into(), + args: args.to_vec(), + cwd: cwd.clone(), + env: env.clone(), + additional_permissions, + }) +} + impl<'a> UnifiedExecRuntime<'a> { /// Creates a runtime bound to the shared unified-exec process manager. pub fn new(manager: &'a UnifiedExecProcessManager, shell_mode: UnifiedExecShellMode) -> Self { @@ -155,12 +174,20 @@ impl Approvable for UnifiedExecRuntime<'_> { let turn = ctx.turn; let call_id = ctx.call_id.to_string(); let command = req.command.clone(); - let cwd = req.cwd.clone(); let environment_id = Some(req.turn_environment.environment_id.clone()); let retry_reason = ctx.retry_reason.clone(); let reason = retry_reason.clone().or_else(|| req.justification.clone()); let guardian_review_id = ctx.guardian_review_id.clone(); Box::pin(async move { + let native_cwd = match req.cwd.to_abs_path() { + Ok(c) => c, + Err(e) => { + // TODO(anp) make sandboxing work for foreign OSes, in the meantime this should + // be impossible for single-OS app-servers + error!(cwd = %req.cwd, ?e, "got non-native path in start_approval_async"); + return ReviewDecision::Abort; + } + }; if let Some(review_id) = guardian_review_id { return review_approval_request( session, @@ -169,7 +196,7 @@ impl Approvable for UnifiedExecRuntime<'_> { GuardianApprovalRequest::ExecCommand { id: call_id, command, - cwd: cwd.clone(), + cwd: native_cwd.clone(), sandbox_permissions: req.sandbox_permissions, additional_permissions: req.additional_permissions.clone(), justification: req.justification.clone(), @@ -188,7 +215,7 @@ impl Approvable for UnifiedExecRuntime<'_> { /*approval_id*/ None, environment_id, command, - cwd.clone(), + native_cwd, reason, ctx.network_approval_context.clone(), req.exec_approval_requirement @@ -226,7 +253,7 @@ impl Approvable for UnifiedExecRuntime<'_> { } impl<'a> ToolRuntime for UnifiedExecRuntime<'a> { - fn sandbox_cwd<'b>(&self, req: &'b UnifiedExecRequest) -> Option<&'b AbsolutePathBuf> { + fn sandbox_cwd<'b>(&self, req: &'b UnifiedExecRequest) -> Option<&'b PathUri> { Some(&req.sandbox_cwd) } @@ -249,7 +276,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt call_id: ctx.call_id.clone(), tool_name: flat_tool_name(&ctx.tool_name).into_owned(), command: req.command.clone(), - cwd: req.cwd.clone(), + cwd: req.cwd.to_abs_path().ok()?, sandbox_permissions: req.sandbox_permissions, additional_permissions: req.additional_permissions.clone(), justification: req.justification.clone(), @@ -272,7 +299,17 @@ impl<'a> ToolRuntime for UnifiedExecRunt .shell .as_ref() .unwrap_or(session_shell.as_ref()); - let shell_snapshot_location = req.turn_environment.shell_snapshot(&req.cwd); + let environment_is_remote = req.turn_environment.environment.is_remote(); + let shell_snapshot_location = if environment_is_remote { + None + } else { + // TODO(anp): Make shell snapshot lookup accept PathUri. + let native_cwd = req + .cwd + .to_abs_path() + .map_err(|err| ToolError::Rejected(err.to_string()))?; + req.turn_environment.shell_snapshot(&native_cwd) + }; let (file_system_sandbox_policy, _) = attempt.permissions.to_runtime_permissions(); let launch_sandbox_permissions = sandbox_permissions_preserving_denied_reads( req.sandbox_permissions, @@ -286,7 +323,6 @@ impl<'a> ToolRuntime for UnifiedExecRunt if let Some(network) = managed_network { network.apply_to_env(&mut env); } - let environment_is_remote = req.turn_environment.environment.is_remote(); let explicit_env_overrides = req.explicit_env_overrides.clone(); #[cfg(unix)] let runtime_path_prepends = { @@ -333,14 +369,18 @@ impl<'a> ToolRuntime for UnifiedExecRunt }; if let UnifiedExecShellMode::ZshFork(zsh_fork_config) = &self.shell_mode { - let command = - build_sandbox_command(&command, &req.cwd, &env, req.additional_permissions.clone()) - .map_err(|error| match error { - ToolError::Rejected(_) => { - ToolError::Rejected("missing command line for PTY".to_string()) - } - error @ ToolError::Codex(_) => error, - })?; + let command = build_unified_exec_sandbox_command( + &command, + &req.cwd, + &env, + req.additional_permissions.clone(), + ) + .map_err(|error| match error { + ToolError::Rejected(_) => { + ToolError::Rejected("missing command line for PTY".to_string()) + } + error @ ToolError::Codex(_) => error, + })?; let options = unified_exec_options(attempt.network_denial_cancellation_token.clone()); let mut exec_env = attempt .env_for(command, options, managed_network) @@ -389,14 +429,18 @@ impl<'a> ToolRuntime for UnifiedExecRunt } } } - let command = - build_sandbox_command(&command, &req.cwd, &env, req.additional_permissions.clone()) - .map_err(|error| match error { - ToolError::Rejected(_) => { - ToolError::Rejected("missing command line for PTY".to_string()) - } - error @ ToolError::Codex(_) => error, - })?; + let command = build_unified_exec_sandbox_command( + &command, + &req.cwd, + &env, + req.additional_permissions.clone(), + ) + .map_err(|error| match error { + ToolError::Rejected(_) => { + ToolError::Rejected("missing command line for PTY".to_string()) + } + error @ ToolError::Codex(_) => error, + })?; let options = unified_exec_options(attempt.network_denial_cancellation_token.clone()); let mut exec_env = attempt .env_for(command, options, managed_network) @@ -431,16 +475,17 @@ mod tests { use codex_exec_server::Environment; use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_tools::ZshForkConfig; + use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use std::sync::Arc; use std::time::Duration; use tempfile::tempdir; - fn test_turn_environment(cwd: AbsolutePathBuf) -> TurnEnvironment { + fn test_turn_environment(cwd: PathUri) -> TurnEnvironment { TurnEnvironment::new( LOCAL_ENVIRONMENT_ID.to_string(), Arc::new(Environment::default_for_tests()), - PathUri::from_abs_path(&cwd), + cwd, /*shell*/ None, ) } @@ -501,9 +546,9 @@ mod tests { shell_type: ShellType::Sh, hook_command: "pwd".to_string(), process_id: 1000, - cwd, - sandbox_cwd: sandbox_cwd.clone(), - turn_environment: test_turn_environment(sandbox_cwd.clone()), + cwd: cwd.into(), + sandbox_cwd: sandbox_cwd.clone().into(), + turn_environment: test_turn_environment(sandbox_cwd.clone().into()), env: HashMap::new(), exec_server_env_config: None, explicit_env_overrides: HashMap::new(), @@ -520,7 +565,10 @@ mod tests { }, }; - assert_eq!(runtime.sandbox_cwd(&request), Some(&sandbox_cwd)); + assert_eq!( + runtime.sandbox_cwd(&request), + Some(&PathUri::from_abs_path(&sandbox_cwd)) + ); } #[tokio::test] @@ -600,9 +648,9 @@ mod tests { shell_type: ShellType::Zsh, hook_command: "echo hi".to_string(), process_id: 1000, - cwd: cwd.clone(), - sandbox_cwd: cwd.clone(), - turn_environment: test_turn_environment(cwd), + cwd: cwd.clone().into(), + sandbox_cwd: cwd.clone().into(), + turn_environment: test_turn_environment(cwd.into()), env: HashMap::new(), exec_server_env_config: None, explicit_env_overrides: HashMap::new(), diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 499cdfbdae..cc7a7913dd 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -394,7 +394,7 @@ pub(crate) trait ToolRuntime: Approvable + Sandboxable { None } - fn sandbox_cwd<'a>(&self, _req: &'a Req) -> Option<&'a AbsolutePathBuf> { + fn sandbox_cwd<'a>(&self, _req: &'a Req) -> Option<&'a PathUri> { None } diff --git a/codex-rs/core/src/unified_exec/async_watcher.rs b/codex-rs/core/src/unified_exec/async_watcher.rs index 33dbf843dc..38010754e9 100644 --- a/codex-rs/core/src/unified_exec/async_watcher.rs +++ b/codex-rs/core/src/unified_exec/async_watcher.rs @@ -22,7 +22,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecCommandOutputDeltaEvent; use codex_protocol::protocol::ExecCommandSource; use codex_protocol::protocol::ExecOutputStream; -use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; pub(crate) const TRAILING_OUTPUT_GRACE: Duration = Duration::from_millis(100); @@ -110,7 +110,7 @@ pub(crate) fn spawn_exit_watcher( turn_ref: Arc, call_id: String, command: Vec, - cwd: AbsolutePathBuf, + cwd: PathUri, process_id: i32, transcript: Arc>, started_at: Instant, @@ -197,7 +197,7 @@ pub(crate) async fn emit_exec_end_for_unified_exec( turn_ref: Arc, call_id: String, command: Vec, - cwd: AbsolutePathBuf, + cwd: PathUri, process_id: Option, transcript: Arc>, fallback_output: String, @@ -242,7 +242,7 @@ pub(crate) async fn emit_failed_exec_end_for_unified_exec( turn_ref: Arc, call_id: String, command: Vec, - cwd: AbsolutePathBuf, + cwd: PathUri, process_id: Option, transcript: Arc>, fallback_output: String, diff --git a/codex-rs/core/src/unified_exec/errors.rs b/codex-rs/core/src/unified_exec/errors.rs index 0576e7d7cf..b4e2882c9b 100644 --- a/codex-rs/core/src/unified_exec/errors.rs +++ b/codex-rs/core/src/unified_exec/errors.rs @@ -1,4 +1,5 @@ use codex_protocol::exec_output::ExecToolCallOutput; +use codex_utils_path_uri::PathUri; use thiserror::Error; #[derive(Debug, Error)] @@ -23,6 +24,8 @@ pub(crate) enum UnifiedExecError { message: String, output: ExecToolCallOutput, }, + #[error("{path} is not valid on {}", std::env::consts::OS)] + ForeignPath { path: PathUri }, } impl UnifiedExecError { diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 23dd3bf1b5..34d88c8149 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -30,8 +30,8 @@ use std::sync::Weak; use codex_network_proxy::NetworkProxy; use codex_protocol::models::AdditionalPermissionProfile; use codex_tools::UnifiedExecShellMode; -use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_path_uri::PathUri; use rand::Rng; use rand::rng; use tokio::sync::Mutex; @@ -96,8 +96,8 @@ pub(crate) struct ExecCommandRequest { pub process_id: i32, pub yield_time_ms: u64, pub max_output_tokens: Option, - pub cwd: AbsolutePathBuf, - pub sandbox_cwd: AbsolutePathBuf, + pub cwd: PathUri, + pub sandbox_cwd: PathUri, pub turn_environment: TurnEnvironment, pub shell_mode: UnifiedExecShellMode, pub network: Option, @@ -156,7 +156,7 @@ struct ProcessEntry { process: Arc, call_id: String, process_id: i32, - cwd: AbsolutePathBuf, + cwd: PathUri, initial_exec_command_active: Arc, hook_command: String, tty: bool, diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index dce521f6f4..0d4e21d93c 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -20,6 +20,7 @@ use codex_exec_server::StartedExecProcess; use codex_exec_server::WriteResponse; use codex_exec_server::WriteStatus; use codex_sandboxing::SandboxType; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::approx_token_count; use core_test_support::get_remote_test_env; @@ -128,7 +129,7 @@ async fn exec_command_with_tty( process: Arc::clone(&process), call_id: context.call_id.clone(), process_id, - cwd: cwd.clone(), + cwd: cwd.clone().into(), initial_exec_command_active: Arc::new(std::sync::atomic::AtomicBool::new(true)), hook_command: cmd.to_string(), tty, @@ -370,7 +371,7 @@ async fn unified_exec_persists_across_requests() -> anyhow::Result<()> { item_id: "call".to_string(), process_id: process_id.to_string(), command: "bash -i".to_string(), - cwd, + cwd: cwd.into(), }] ); @@ -674,7 +675,7 @@ async fn terminating_initial_exec_command_rechecks_initial_response_state() -> a process, call_id: "call".to_string(), process_id, - cwd, + cwd: cwd.into(), initial_exec_command_active: Arc::new(std::sync::atomic::AtomicBool::new(true)), hook_command: "sleep 60".to_string(), tty: true, @@ -747,7 +748,7 @@ async fn terminating_during_stdin_poll_returns_exited_response() -> anyhow::Resu process: Arc::clone(&process), call_id: "call".to_string(), process_id, - cwd, + cwd: cwd.into(), initial_exec_command_active: Arc::new(std::sync::atomic::AtomicBool::new(false)), hook_command: "sleep 60".to_string(), tty: true, diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index e683df963c..0aac491823 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -55,7 +55,6 @@ use codex_protocol::error::CodexErr; use codex_protocol::error::SandboxErr; use codex_protocol::protocol::ExecCommandSource; use codex_tools::ToolName; -use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_output_truncation::approx_token_count; use codex_utils_path_uri::PathUri; @@ -157,7 +156,7 @@ fn exec_server_params_for_request( codex_exec_server::ExecParams { process_id: exec_server_process_id(process_id).into(), argv: request.command.clone(), - cwd: PathUri::from_abs_path(&request.cwd), + cwd: request.cwd.clone(), env_policy, env, tty, @@ -294,7 +293,7 @@ async fn emit_failed_initial_exec_end_if_unstored( process_started_alive: bool, context: &UnifiedExecContext, request: &ExecCommandRequest, - cwd: AbsolutePathBuf, + cwd: PathUri, transcript: Arc>, fallback_output: String, message: String, @@ -841,7 +840,7 @@ impl UnifiedExecProcessManager { context: &UnifiedExecContext, command: &[String], hook_command: String, - cwd: AbsolutePathBuf, + cwd: PathUri, started_at: Instant, process_id: i32, tty: bool, @@ -899,6 +898,14 @@ impl UnifiedExecProcessManager { #[cfg(target_os = "windows")] if request.sandbox == codex_sandboxing::SandboxType::WindowsRestrictedToken { + // TODO(anp): Keep PathUri through the Windows sandbox launch boundary. + let native_cwd = + request + .cwd + .to_abs_path() + .map_err(|_| UnifiedExecError::ForeignPath { + path: request.cwd.clone(), + })?; let codex_home = crate::config::find_codex_home().map_err(|err| { UnifiedExecError::create_process(format!( "windows sandbox: failed to resolve codex_home: {err}" @@ -933,7 +940,7 @@ impl UnifiedExecProcessManager { request.windows_sandbox_workspace_roots.as_slice(), codex_home.as_ref(), request.command.clone(), - request.cwd.as_path(), + native_cwd.as_path(), request.env.clone(), request.network.is_some(), None, @@ -955,7 +962,7 @@ impl UnifiedExecProcessManager { request.windows_sandbox_workspace_roots.as_slice(), codex_home.as_ref(), request.command.clone(), - request.cwd.as_path(), + native_cwd.as_path(), request.env.clone(), None, &additional_deny_read_paths, @@ -991,6 +998,14 @@ impl UnifiedExecProcessManager { return UnifiedExecProcess::from_exec_server_started(started, request.sandbox).await; } + // TODO(anp): Keep PathUri through the local PTY/process launch boundary. + let native_cwd = request + .cwd + .to_abs_path() + .map_err(|_| UnifiedExecError::ForeignPath { + path: request.cwd.clone(), + })?; + let (program, args) = request .command .split_first() @@ -999,7 +1014,7 @@ impl UnifiedExecProcessManager { codex_utils_pty::pty::spawn_process_with_inherited_fds( program, args, - request.cwd.as_path(), + native_cwd.as_path(), &request.env, &request.arg0, codex_utils_pty::TerminalSize::default(), @@ -1010,7 +1025,7 @@ impl UnifiedExecProcessManager { codex_utils_pty::pipe::spawn_process_no_stdin_with_inherited_fds( program, args, - request.cwd.as_path(), + native_cwd.as_path(), &request.env, &request.arg0, &inherited_fds, @@ -1026,7 +1041,7 @@ impl UnifiedExecProcessManager { pub(super) async fn open_session_with_sandbox( &self, request: &ExecCommandRequest, - cwd: AbsolutePathBuf, + cwd: PathUri, context: &UnifiedExecContext, ) -> Result<(UnifiedExecProcess, Option), UnifiedExecError> { let local_policy_env = create_env( diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index 49758ec799..c399b9c988 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -78,7 +78,7 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { let permission_profile = codex_protocol::models::PermissionProfile::Disabled; let request = ExecRequest { command: vec!["bash".to_string(), "-lc".to_string(), "true".to_string()], - cwd: cwd.clone(), + cwd: cwd.clone().into(), env: HashMap::from([ ("HOME".to_string(), "/client-home".to_string()), ("PATH".to_string(), "/sandbox-path".to_string()), @@ -101,7 +101,7 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { expiration: crate::exec::ExecExpiration::DefaultTimeout, capture_policy: crate::exec::ExecCapturePolicy::ShellTool, sandbox: codex_sandboxing::SandboxType::None, - windows_sandbox_policy_cwd: cwd.clone(), + windows_sandbox_policy_cwd: cwd.clone().into(), windows_sandbox_workspace_roots: vec![cwd], windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, @@ -116,7 +116,7 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { exec_server_params_for_request(/*process_id*/ 123, &request, /*tty*/ true); assert_eq!(params.process_id.as_str(), "123"); - assert_eq!(params.cwd, PathUri::from_abs_path(&request.cwd)); + assert_eq!(params.cwd, request.cwd); assert!(params.env_policy.is_some()); assert_eq!( params.env, @@ -200,9 +200,9 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() { yield_time_ms: 1000, max_output_tokens: None, #[allow(deprecated)] - cwd: turn.cwd.clone(), + cwd: turn.cwd.clone().into(), #[allow(deprecated)] - sandbox_cwd: turn.cwd.clone(), + sandbox_cwd: turn.cwd.clone().into(), turn_environment: turn .environments .primary() @@ -229,7 +229,7 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() { &context, &request, #[allow(deprecated)] - turn.cwd.clone(), + turn.cwd.clone().into(), transcript, "PRE_DENIAL_MARKER".to_string(), "Network access denied".to_string(), diff --git a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs index c092c7dcc4..593dd7ea05 100644 --- a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs +++ b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs @@ -20,7 +20,6 @@ use codex_features::Feature; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::ExecCommandStatus; use codex_protocol::protocol::Op; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::protocol::TurnEnvironmentSelections; @@ -58,6 +57,9 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { let arguments = serde_json::to_string(&json!({ "cmd": COMMAND, "login": false, + // An absolute foreign workdir should replace the selected environment cwd and + // reach exec-server without conversion to the host path convention. + "workdir": r"C:\windows", "yield_time_ms": 10_000, }))?; let response_mock = mount_sse_sequence( @@ -125,6 +127,10 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { }) .await?; + // TODO(anp): Re-enable these event assertions once exec command events retain a + // PathUri cwd. Today the host-native event conversion drops begin/end events for a + // foreign cwd. + /* let mut begin = None; let mut end = None; let mut turn_complete = false; @@ -152,13 +158,16 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { "unexpected command: {:?}", begin.command ); - assert_eq!( - &begin.command[1..], - ["-NoProfile", "-Command", COMMAND] - ); + assert_eq!(&begin.command[1..], ["-NoProfile", "-Command", COMMAND]); let end = end.context("exec_command should emit an end event")?; assert_eq!((end.exit_code, end.status), (0, ExecCommandStatus::Completed)); + */ + + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; let request = response_mock .last_request() diff --git a/codex-rs/exec-server/src/fs_sandbox.rs b/codex-rs/exec-server/src/fs_sandbox.rs index 9e9dbcf6b4..ea420c9ea2 100644 --- a/codex-rs/exec-server/src/fs_sandbox.rs +++ b/codex-rs/exec-server/src/fs_sandbox.rs @@ -341,6 +341,8 @@ fn spawn_command( #[cfg(not(unix))] let _ = arg0; command.args(args); + // TODO(anp): Keep PathUri through the filesystem helper launch boundary. + let cwd = cwd.to_abs_path().map_err(io_error)?; command.current_dir(cwd.as_path()); command.env_clear(); command.envs(env); diff --git a/codex-rs/exec/tests/suite/sandbox.rs b/codex-rs/exec/tests/suite/sandbox.rs index 4b8cecc515..3171a445a1 100644 --- a/codex-rs/exec/tests/suite/sandbox.rs +++ b/codex-rs/exec/tests/suite/sandbox.rs @@ -60,7 +60,12 @@ async fn spawn_command_under_sandbox( child.arg0(arg0); } child.args(args); - child.current_dir(exec_request.cwd); + // TODO(anp): Keep PathUri through the macOS sandbox process launch boundary. + let native_cwd = exec_request + .cwd + .to_abs_path() + .map_err(|err| io::Error::other(err.to_string()))?; + child.current_dir(native_cwd); child.env_clear(); child.envs(exec_request.env); diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index 6bfbaa3459..25032a8050 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -109,8 +109,8 @@ pub struct SandboxCommand { #[derive(Debug)] pub struct SandboxExecRequest { pub command: Vec, - pub cwd: AbsolutePathBuf, - pub sandbox_policy_cwd: AbsolutePathBuf, + pub cwd: PathUri, + pub sandbox_policy_cwd: PathUri, pub env: HashMap, pub network: Option, pub sandbox: SandboxType, @@ -150,6 +150,52 @@ pub struct SandboxDirectSpawnTransformRequest<'a> { pub workspace_roots: &'a [AbsolutePathBuf], } +// TODO(anp): Revisit this preparation type once this module's PathUri migration is complete. +struct PendingSandboxedExecRequest { + native_command_cwd: AbsolutePathBuf, + native_sandbox_policy_cwd: AbsolutePathBuf, + effective_permission_profile: PermissionProfile, + effective_file_system_policy: FileSystemSandboxPolicy, + effective_network_policy: NetworkSandboxPolicy, +} + +impl PendingSandboxedExecRequest { + fn new( + command_cwd: &PathUri, + sandbox_policy_cwd: &PathUri, + effective_permission_profile: PermissionProfile, + managed_mitm_ca_trust_bundle_path: Option<&AbsolutePathBuf>, + ) -> Result { + // TODO(anp): Move PathUri conversion into the platform sandbox implementations. + let native_command_cwd = command_cwd.to_abs_path().map_err(|source| { + SandboxTransformError::InvalidCommandCwd { + cwd: command_cwd.clone(), + source, + } + })?; + let native_sandbox_policy_cwd = sandbox_policy_cwd.to_abs_path().map_err(|source| { + SandboxTransformError::InvalidSandboxPolicyCwd { + cwd: sandbox_policy_cwd.clone(), + source, + } + })?; + let effective_permission_profile = with_managed_mitm_ca_readable_root( + effective_permission_profile, + managed_mitm_ca_trust_bundle_path, + native_sandbox_policy_cwd.as_path(), + ); + let (effective_file_system_policy, effective_network_policy) = + effective_permission_profile.to_runtime_permissions(); + Ok(Self { + native_command_cwd, + native_sandbox_policy_cwd, + effective_permission_profile, + effective_file_system_policy, + effective_network_policy, + }) + } +} + #[derive(Debug)] pub enum SandboxTransformError { InvalidCommandCwd { @@ -266,47 +312,37 @@ impl SandboxManager { windows_sandbox_level, windows_sandbox_private_desktop, } = request; - let native_command_cwd = command.cwd.to_abs_path().map_err(|source| { - SandboxTransformError::InvalidCommandCwd { - cwd: command.cwd.clone(), - source, - } - })?; - let native_sandbox_policy_cwd = sandbox_policy_cwd.to_abs_path().map_err(|source| { - SandboxTransformError::InvalidSandboxPolicyCwd { - cwd: sandbox_policy_cwd.clone(), - source, - } - })?; let additional_permissions = command.additional_permissions.take(); let managed_mitm_ca_trust_bundle_path = network.and_then(NetworkProxy::managed_mitm_ca_trust_bundle_path); - let effective_permission_profile = + let base_effective_permission_profile = effective_permission_profile(permissions, additional_permissions.as_ref()); - let effective_permission_profile = with_managed_mitm_ca_readable_root( - effective_permission_profile, + let pending_sandboxed_request = PendingSandboxedExecRequest::new( + &command.cwd, + sandbox_policy_cwd, + base_effective_permission_profile.clone(), managed_mitm_ca_trust_bundle_path.as_ref(), - native_sandbox_policy_cwd.as_path(), ); - let (effective_file_system_policy, effective_network_policy) = - effective_permission_profile.to_runtime_permissions(); + let (base_file_system_policy, base_network_policy) = + base_effective_permission_profile.to_runtime_permissions(); let mut argv = Vec::with_capacity(1 + command.args.len()); argv.push(command.program); argv.extend(command.args.into_iter().map(OsString::from)); - let (argv, arg0_override) = match sandbox { - SandboxType::None => (os_argv_to_strings(argv), None), + let (argv, arg0_override, pending_sandboxed_request) = match sandbox { + SandboxType::None => (os_argv_to_strings(argv), None, None), #[cfg(target_os = "macos")] SandboxType::MacosSeatbelt => { use crate::seatbelt::CreateSeatbeltCommandArgsParams; use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; use crate::seatbelt::create_seatbelt_command_args; + let pending = pending_sandboxed_request?; let mut args = create_seatbelt_command_args(CreateSeatbeltCommandArgsParams { command: os_argv_to_strings(argv), - file_system_sandbox_policy: &effective_file_system_policy, - network_sandbox_policy: effective_network_policy, - sandbox_policy_cwd: native_sandbox_policy_cwd.as_path(), + file_system_sandbox_policy: &pending.effective_file_system_policy, + network_sandbox_policy: pending.effective_network_policy, + sandbox_policy_cwd: pending.native_sandbox_policy_cwd.as_path(), enforce_managed_network, network, extra_allow_unix_sockets: &[], @@ -314,52 +350,84 @@ impl SandboxManager { let mut full_command = Vec::with_capacity(1 + args.len()); full_command.push(MACOS_PATH_TO_SEATBELT_EXECUTABLE.to_string()); full_command.append(&mut args); - (full_command, None) + (full_command, None, Some(pending)) } #[cfg(not(target_os = "macos"))] SandboxType::MacosSeatbelt => return Err(SandboxTransformError::SeatbeltUnavailable), SandboxType::LinuxSeccomp => { + let pending = pending_sandboxed_request?; let exe = codex_linux_sandbox_exe .ok_or(SandboxTransformError::MissingLinuxSandboxExecutable)?; let allow_proxy_network = allow_network_for_proxy(enforce_managed_network); #[cfg(target_os = "linux")] ensure_linux_bubblewrap_is_supported( - &effective_file_system_policy, + &pending.effective_file_system_policy, use_legacy_landlock, allow_proxy_network, is_wsl1(), )?; let mut args = create_linux_sandbox_command_args_for_permission_profile( os_argv_to_strings(argv), - native_command_cwd.as_path(), - &effective_permission_profile, - native_sandbox_policy_cwd.as_path(), + pending.native_command_cwd.as_path(), + &pending.effective_permission_profile, + pending.native_sandbox_policy_cwd.as_path(), use_legacy_landlock, allow_proxy_network, ); let mut full_command = Vec::with_capacity(1 + args.len()); full_command.push(os_string_to_command_component(exe.as_os_str().to_owned())); full_command.append(&mut args); - (full_command, Some(linux_sandbox_arg0_override(exe))) + ( + full_command, + Some(linux_sandbox_arg0_override(exe)), + Some(pending), + ) } #[cfg(target_os = "windows")] - SandboxType::WindowsRestrictedToken => (os_argv_to_strings(argv), None), + SandboxType::WindowsRestrictedToken => ( + os_argv_to_strings(argv), + None, + Some(pending_sandboxed_request?), + ), #[cfg(not(target_os = "windows"))] - SandboxType::WindowsRestrictedToken => (os_argv_to_strings(argv), None), + SandboxType::WindowsRestrictedToken => ( + os_argv_to_strings(argv), + None, + Some(pending_sandboxed_request?), + ), }; + // Unsandboxed exec-server requests may have foreign cwd values that cannot be prepared + // locally, but their effective permissions must still be preserved. In that case, carry + // forward the base profile and its derived runtime policies. + let (permission_profile, file_system_sandbox_policy, network_sandbox_policy) = + pending_sandboxed_request.map_or( + ( + base_effective_permission_profile, + base_file_system_policy, + base_network_policy, + ), + |pending| { + ( + pending.effective_permission_profile, + pending.effective_file_system_policy, + pending.effective_network_policy, + ) + }, + ); + Ok(SandboxExecRequest { command: argv, - cwd: native_command_cwd, - sandbox_policy_cwd: native_sandbox_policy_cwd, + cwd: command.cwd, + sandbox_policy_cwd: sandbox_policy_cwd.clone(), env: command.env, network: network.cloned(), sandbox, windows_sandbox_level, windows_sandbox_private_desktop, - permission_profile: effective_permission_profile, - file_system_sandbox_policy: effective_file_system_policy, - network_sandbox_policy: effective_network_policy, + permission_profile, + file_system_sandbox_policy, + network_sandbox_policy, arg0: arg0_override, }) } @@ -406,6 +474,21 @@ fn wrap_windows_sandbox_exec_request_for_direct_spawn( workspace_roots: &[AbsolutePathBuf], codex_home: &Path, ) -> Result<(), SandboxTransformError> { + // TODO(anp): Keep PathUri through the Windows sandbox wrapper boundary. + let native_cwd = + request + .cwd + .to_abs_path() + .map_err(|source| SandboxTransformError::InvalidCommandCwd { + cwd: request.cwd.clone(), + source, + })?; + let native_sandbox_policy_cwd = request.sandbox_policy_cwd.to_abs_path().map_err(|source| { + SandboxTransformError::InvalidSandboxPolicyCwd { + cwd: request.sandbox_policy_cwd.clone(), + source, + } + })?; let Some(program) = request.command.first_mut() else { return Err(SandboxTransformError::WindowsSandboxPreparation( "sandbox command was empty".to_string(), @@ -423,14 +506,14 @@ fn wrap_windows_sandbox_exec_request_for_direct_spawn( resolve_windows_elevated_filesystem_overrides( request.sandbox, &request.permission_profile, - &request.sandbox_policy_cwd, + &native_sandbox_policy_cwd, use_elevated, ) } else { resolve_windows_restricted_token_filesystem_overrides( request.sandbox, &request.permission_profile, - &request.sandbox_policy_cwd, + &native_sandbox_policy_cwd, request.windows_sandbox_level, ) } @@ -454,7 +537,7 @@ fn wrap_windows_sandbox_exec_request_for_direct_spawn( let mut wrapper_args = codex_windows_sandbox::create_windows_sandbox_command_args_for_permission_profile( inner_command, - &request.cwd, + &native_cwd, workspace_roots, &request.env, &request.permission_profile, diff --git a/codex-rs/sandboxing/src/manager_tests.rs b/codex-rs/sandboxing/src/manager_tests.rs index 8f736b994f..344787f141 100644 --- a/codex-rs/sandboxing/src/manager_tests.rs +++ b/codex-rs/sandboxing/src/manager_tests.rs @@ -74,10 +74,13 @@ fn restricted_file_system_uses_platform_sandbox_without_managed_network() { } #[test] -fn transform_preserves_unrestricted_file_system_policy_for_restricted_network() { +fn unsandboxed_transform_preserves_foreign_cwd_and_unrestricted_file_system_policy() { let manager = SandboxManager::new(); - let cwd = AbsolutePathBuf::current_dir().expect("current dir"); - let cwd_uri = PathUri::from_abs_path(&cwd); + let cwd_uri = if cfg!(windows) { + PathUri::parse("file:///workspace/remote").expect("POSIX path URI") + } else { + PathUri::parse("file:///C:/workspace/remote").expect("Windows path URI") + }; let permissions = PermissionProfile::from_runtime_permissions( &FileSystemSandboxPolicy::unrestricted(), NetworkSandboxPolicy::Restricted, @@ -103,8 +106,8 @@ fn transform_preserves_unrestricted_file_system_policy_for_restricted_network() }) .expect("transform"); - assert_eq!(exec_request.cwd, cwd); - assert_eq!(exec_request.sandbox_policy_cwd, cwd); + assert_eq!(exec_request.cwd, cwd_uri); + assert_eq!(exec_request.sandbox_policy_cwd, cwd_uri); assert_eq!( exec_request.file_system_sandbox_policy, FileSystemSandboxPolicy::unrestricted() diff --git a/codex-rs/utils/path-uri/src/api_path_string.rs b/codex-rs/utils/path-uri/src/api_path_string.rs index f9ae8690c8..0881c64d7c 100644 --- a/codex-rs/utils/path-uri/src/api_path_string.rs +++ b/codex-rs/utils/path-uri/src/api_path_string.rs @@ -33,6 +33,10 @@ use ts_rs::TS; pub struct LegacyAppPathString(String); impl LegacyAppPathString { + pub(super) fn from_str(path: &str) -> Self { + Self(path.to_string()) + } + /// Renders an absolute path using the current host's path convention. pub fn from_abs_path(path: &AbsolutePathBuf) -> Self { Self(path.to_string_lossy().into_owned()) diff --git a/codex-rs/utils/path-uri/src/lib.rs b/codex-rs/utils/path-uri/src/lib.rs index 18142c7b19..a7f3c83369 100644 --- a/codex-rs/utils/path-uri/src/lib.rs +++ b/codex-rs/utils/path-uri/src/lib.rs @@ -211,18 +211,20 @@ impl PathUri { Some(Self(url)) } - /// Lexically joins a relative URI path onto this URI. + /// Lexically resolves native absolute or relative path text against this URI. /// + /// Path text is interpreted using the POSIX or Windows convention inferred + /// from the base URI. An absolute path replaces the base URI's path, while a + /// relative path is appended lexically. Windows root-relative paths retain + /// the base drive or UNC share, while drive-relative paths are rejected. /// Empty and `.` segments are ignored, while `..` removes one segment - /// without escaping the URI root. Literal `%`, `?`, and `#` characters are - /// percent-encoded as filename text. Paths containing a null character are - /// rejected because they cannot be safely converted to native paths. + /// without escaping the POSIX root, Windows drive, or UNC share. Literal + /// `%`, `?`, and `#` characters are percent-encoded as filename text. Paths + /// containing a null character are rejected because they cannot be safely + /// converted to native paths. /// Opaque fallback URIs created by [`Self::from_abs_path`] reject non-empty /// joins. pub fn join(&self, path: &str) -> Result { - if path.starts_with('/') { - return Err(PathUriParseError::JoinPathMustBeRelative(path.to_string())); - } if path.contains('\0') { return Err(PathUriParseError::InvalidFileUriPath { path: path.to_string(), @@ -231,6 +233,24 @@ impl PathUri { if path.is_empty() { return Ok(self.clone()); } + let convention = + self.infer_path_convention() + .ok_or_else(|| PathUriParseError::InvalidFileUriPath { + path: self.to_string(), + })?; + // An absolute native path is already fully resolved, so replace the base URI's main path + // instead of appending it. + if let Ok(absolute) = LegacyAppPathString::from_str(path).to_path_uri(convention) { + return Ok(absolute); + } + let path_bytes = path.as_bytes(); + if convention == PathConvention::Windows + && matches!(path_bytes, [drive, b':', ..] if drive.is_ascii_alphabetic()) + { + return Err(PathUriParseError::InvalidFileUriPath { + path: path.to_string(), + }); + } if decode_bad_path_uri(&self.0).is_some() { return Err(PathUriParseError::InvalidFileUriPath { path: self.to_string(), @@ -238,19 +258,40 @@ impl PathUri { } let mut url = self.0.clone(); + let anchor_depth = usize::from(convention == PathConvention::Windows); + let mut depth = url + .path_segments() + .map(|segments| segments.filter(|segment| !segment.is_empty()).count()) + .unwrap_or_default(); + let windows_root_relative = convention == PathConvention::Windows + && matches!(path_bytes, [b'\\' | b'/', rest @ ..] if !matches!(rest, [b'\\' | b'/', ..])); { let Ok(mut segments) = url.path_segments_mut() else { unreachable!("validated file URLs support hierarchical path segments"); }; segments.pop_if_empty(); + if windows_root_relative { + while depth > anchor_depth { + segments.pop(); + depth -= 1; + } + } + let path = match convention { + PathConvention::Posix => path.to_string(), + PathConvention::Windows => path.replace('\\', "/"), + }; for component in path.split('/') { match component { "" | "." => {} ".." => { - segments.pop(); + if depth > anchor_depth { + segments.pop(); + depth -= 1; + } } component => { segments.push(component); + depth += 1; } } } @@ -350,6 +391,12 @@ impl TryFrom for PathUri { } } +impl From for PathUri { + fn from(p: AbsolutePathBuf) -> Self { + Self::from_abs_path(&p) + } +} + impl<'de> Deserialize<'de> for PathUri { fn deserialize(deserializer: D) -> Result where diff --git a/codex-rs/utils/path-uri/src/tests.rs b/codex-rs/utils/path-uri/src/tests.rs index 1992cc9c75..6e7dc7294d 100644 --- a/codex-rs/utils/path-uri/src/tests.rs +++ b/codex-rs/utils/path-uri/src/tests.rs @@ -522,13 +522,72 @@ fn join_normalizes_relative_uri_segments() { } #[test] -fn join_rejects_absolute_and_null_paths() { +fn join_replaces_posix_absolute_path() { let base = PathUri::parse("file:///workspace").expect("valid base URI"); - assert!(matches!( + assert_eq!( base.join("/src"), - Err(PathUriParseError::JoinPathMustBeRelative(path)) if path == "/src" - )); + Ok(PathUri::parse("file:///src").expect("valid absolute URI")) + ); +} + +#[test] +fn join_replaces_windows_absolute_path() { + let base = PathUri::parse("file:///C:/workspace/src").expect("valid base URI"); + + assert_eq!( + base.join(r"D:\tmp\test.rs"), + Ok(PathUri::parse("file:///D:/tmp/test.rs").expect("valid absolute URI")) + ); +} + +#[test] +fn join_windows_root_relative_path_preserves_drive_or_share() { + for (base, path, expected) in [ + ("file:///C:/base/dir", r"\Windows", "file:///C:/Windows"), + ( + "file://server/share/base/dir", + r"\Windows", + "file://server/share/Windows", + ), + ] { + let base = PathUri::parse(base).expect("valid base URI"); + let expected = PathUri::parse(expected).expect("valid expected URI"); + assert_eq!(base.join(path), Ok(expected), "joining {path}"); + } +} + +#[test] +fn join_rejects_windows_drive_relative_path() { + let base = PathUri::parse("file:///C:/base").expect("valid base URI"); + + assert_eq!( + base.join(r"D:tmp"), + Err(PathUriParseError::InvalidFileUriPath { + path: r"D:tmp".to_string(), + }) + ); +} + +#[test] +fn join_parent_segments_preserve_windows_drive_or_share_anchor() { + for (base, expected) in [ + ("file:///C:/base/dir", "file:///C:/Windows"), + ( + "file://server/share/base/dir", + "file://server/share/Windows", + ), + ] { + let base = PathUri::parse(base).expect("valid base URI"); + let expected = PathUri::parse(expected).expect("valid expected URI"); + assert_eq!(base.join(r"..\..\..\Windows"), Ok(expected)); + } +} + +#[test] +fn join_rejects_null_paths() { + let base = PathUri::parse("file:///workspace").expect("valid base URI"); + assert_eq!( base.join("src\0file"), Err(PathUriParseError::InvalidFileUriPath { @@ -537,6 +596,26 @@ fn join_rejects_absolute_and_null_paths() { ); } +#[test] +fn join_uses_the_base_uri_path_convention() { + for (base, path, expected) in [ + ( + "file:///workspace/src", + "../tests/test.rs", + "file:///workspace/tests/test.rs", + ), + ( + "file:///C:/workspace/src", + r"..\tests\test.rs", + "file:///C:/workspace/tests/test.rs", + ), + ] { + let base = PathUri::parse(base).expect("valid base URI"); + let expected = PathUri::parse(expected).expect("valid expected URI"); + assert_eq!(base.join(path), Ok(expected), "joining {path}"); + } +} + #[test] fn to_url_returns_the_validated_url() { let uri = PathUri::parse("file://localhost/workspace/a%20file.rs").expect("valid file URI"); From 3959ab0ffcd4e48620d5a380ccad66f00176a6e4 Mon Sep 17 00:00:00 2001 From: charlesgong-openai Date: Wed, 17 Jun 2026 13:16:34 -0700 Subject: [PATCH 006/112] [codex] Track plugin install and import telemetry failures (#28731) ## Summary - Track plugin install failures through the unified `codex_plugin_install_failed` event for local installs, remote install preflight failures, bundle failures, and remote catalog/backend failures. - Send classified `error_type` values in plugin install failure analytics instead of raw error strings. - Stop sending raw external-agent import errors in analytics while preserving raw failure details in app-facing import notifications/history. - Keep raw plugin/migration diagnostics in `tracing::warn!` logs. - Keep remote failure plugin names as the existing local placeholder (`unknown`) and remove the extra telemetry plugin-name override. - Change `ExternalAgentConfigImportParams.source` from a generated enum to `string | null`, with legacy `claudeCode` / `claudeCowork` inputs normalized to existing analytics values. ## Testing --- .../analytics/src/analytics_client_tests.rs | 221 ++++++++++++++ codex-rs/analytics/src/client.rs | 30 ++ codex-rs/analytics/src/events.rs | 50 ++++ codex-rs/analytics/src/facts.rs | 24 ++ codex-rs/analytics/src/lib.rs | 2 + codex-rs/analytics/src/reducer.rs | 75 +++++ .../schema/json/ClientRequest.json | 9 +- .../codex_app_server_protocol.schemas.json | 9 +- .../codex_app_server_protocol.v2.schemas.json | 9 +- .../v2/ExternalAgentConfigDetectParams.json | 2 +- .../v2/ExternalAgentConfigImportParams.json | 7 + .../v2/ExternalAgentConfigDetectParams.ts | 2 +- .../v2/ExternalAgentConfigImportParams.ts | 6 +- .../src/protocol/v2/config.rs | 5 +- .../src/protocol/v2/tests.rs | 1 + codex-rs/app-server/README.md | 2 +- .../src/config/external_agent_config.rs | 27 +- .../src/config/external_agent_config_tests.rs | 48 ++-- codex-rs/app-server/src/message_processor.rs | 3 +- .../external_agent_config_processor.rs | 91 +++++- .../src/request_processors/plugins.rs | 162 ++++++++++- .../tests/suite/v2/external_agent_config.rs | 272 ++++++++++++++++-- .../tests/suite/v2/plugin_install.rs | 187 +++++++++++- codex-rs/core-plugins/src/manager.rs | 162 ++++++++++- codex-rs/tui/src/app_server_session.rs | 5 +- 25 files changed, 1314 insertions(+), 97 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d7be7f1df9..d6792587f2 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -9,7 +9,11 @@ use crate::events::CodexCommandExecutionEventParams; use crate::events::CodexCommandExecutionEventRequest; use crate::events::CodexCompactionEventRequest; use crate::events::CodexHookRunEventRequest; +use crate::events::CodexOnboardingExternalAgentImportFailureEventRequest; +use crate::events::CodexOnboardingExternalAgentImportFailureMetadata; use crate::events::CodexPluginEventRequest; +use crate::events::CodexPluginInstallFailedEventRequest; +use crate::events::CodexPluginInstallFailedMetadata; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexReviewEventParams; use crate::events::CodexReviewEventRequest; @@ -51,10 +55,13 @@ use crate::facts::CompactionStatus; use crate::facts::CompactionStrategy; use crate::facts::CompactionTrigger; use crate::facts::CustomAnalyticsFact; +use crate::facts::ExternalAgentConfigImportCompletedInput; +use crate::facts::ExternalAgentConfigImportFailureInput; use crate::facts::HookRunFact; use crate::facts::HookRunInput; use crate::facts::InputError; use crate::facts::InvocationType; +use crate::facts::PluginInstallFailedInput; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; @@ -3054,6 +3061,36 @@ fn plugin_management_event_serializes_expected_shape() { ); } +#[test] +fn plugin_install_failed_event_serializes_expected_shape() { + let event = TrackEventRequest::PluginInstallFailed(CodexPluginInstallFailedEventRequest { + event_type: "codex_plugin_install_failed", + event_params: CodexPluginInstallFailedMetadata { + plugin: codex_plugin_metadata(sample_plugin_metadata()), + error_type: "store_io".to_string(), + }, + }); + + let payload = serde_json::to_value(&event).expect("serialize plugin install failed event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_plugin_install_failed", + "event_params": { + "plugin_id": "sample@test", + "plugin_name": "sample", + "marketplace_name": "test", + "has_skills": true, + "mcp_server_count": 2, + "connector_ids": ["calendar", "drive"], + "product_client_id": originator().value, + "error_type": "store_io" + } + }) + ); +} + #[test] fn plugin_management_event_can_use_remote_plugin_id_override() { let mut plugin = sample_plugin_metadata(); @@ -3421,6 +3458,190 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[tokio::test] +async fn reducer_ingests_plugin_install_failed_fact() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::PluginInstallFailed( + PluginInstallFailedInput { + plugin: sample_plugin_metadata(), + error_type: "invalid_plugin".to_string(), + }, + )), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!( + payload, + json!([{ + "event_type": "codex_plugin_install_failed", + "event_params": { + "plugin_id": "sample@test", + "plugin_name": "sample", + "marketplace_name": "test", + "has_skills": true, + "mcp_server_count": 2, + "connector_ids": ["calendar", "drive"], + "product_client_id": originator().value, + "error_type": "invalid_plugin" + } + }]) + ); +} + +#[tokio::test] +async fn reducer_ingests_plugin_install_failed_fact_without_detail() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + let mut plugin = PluginTelemetryMetadata::from_plugin_id( + &PluginId::parse("unknown@openai-curated-remote").expect("valid plugin id"), + ); + plugin.remote_plugin_id = Some("plugins~Plugin_00000000000000000000000000000000".to_string()); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::PluginInstallFailed( + PluginInstallFailedInput { + plugin, + error_type: "remote_catalog_unexpected_status".to_string(), + }, + )), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!( + payload, + json!([{ + "event_type": "codex_plugin_install_failed", + "event_params": { + "plugin_id": "plugins~Plugin_00000000000000000000000000000000", + "plugin_name": "unknown", + "marketplace_name": "openai-curated-remote", + "has_skills": null, + "mcp_server_count": null, + "connector_ids": null, + "product_client_id": originator().value, + "error_type": "remote_catalog_unexpected_status" + } + }]) + ); +} + +#[tokio::test] +async fn reducer_ingests_external_agent_config_import_completed_fact() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::ExternalAgentConfigImportCompleted( + ExternalAgentConfigImportCompletedInput { + import_id: "import-1".to_string(), + source: "app_server".to_string(), + item_type: "PLUGINS".to_string(), + success_count: 2, + failed_count: 1, + }, + )), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!( + payload, + json!([{ + "event_type": "codex_onboarding_external_agent_import_complete", + "event_params": { + "import_id": "import-1", + "source": "app_server", + "type": "PLUGINS", + "success_count": 2, + "failed_count": 1, + "product_client_id": originator().value, + } + }]) + ); +} + +#[test] +fn external_agent_config_import_failure_event_serializes_expected_shape() { + let event = TrackEventRequest::ExternalAgentConfigImportFailure( + CodexOnboardingExternalAgentImportFailureEventRequest { + event_type: "codex_onboarding_external_agent_import_failure", + event_params: CodexOnboardingExternalAgentImportFailureMetadata { + import_id: "import-1".to_string(), + source: "app_server".to_string(), + item_type: "SESSIONS".to_string(), + failure_stage: "session_missing".to_string(), + error_type: "session_missing".to_string(), + product_client_id: Some(originator().value), + }, + }, + ); + + let payload = serde_json::to_value(&event).expect("serialize import failure event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_onboarding_external_agent_import_failure", + "event_params": { + "import_id": "import-1", + "source": "app_server", + "type": "SESSIONS", + "failure_stage": "session_missing", + "error_type": "session_missing", + "product_client_id": originator().value, + } + }) + ); +} + +#[tokio::test] +async fn reducer_ingests_external_agent_config_import_failure_fact() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::ExternalAgentConfigImportFailure( + ExternalAgentConfigImportFailureInput { + import_id: "import-1".to_string(), + source: "app_server".to_string(), + item_type: "SESSIONS".to_string(), + failure_stage: "session_missing".to_string(), + error_type: "session_missing".to_string(), + }, + )), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!( + payload, + json!([{ + "event_type": "codex_onboarding_external_agent_import_failure", + "event_params": { + "import_id": "import-1", + "source": "app_server", + "type": "SESSIONS", + "failure_stage": "session_missing", + "error_type": "session_missing", + "product_client_id": originator().value, + } + }]) + ); +} + #[test] fn turn_event_serializes_expected_shape() { let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e88c0114a0..24ff523c9c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -11,8 +11,11 @@ use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CodexGoalEvent; use crate::facts::CustomAnalyticsFact; +use crate::facts::ExternalAgentConfigImportCompletedInput; +use crate::facts::ExternalAgentConfigImportFailureInput; use crate::facts::HookRunFact; use crate::facts::HookRunInput; +use crate::facts::PluginInstallFailedInput; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::SkillInvocation; @@ -343,6 +346,33 @@ impl AnalyticsEventsClient { )); } + pub fn track_plugin_install_failed(&self, plugin: PluginTelemetryMetadata, error_type: String) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::PluginInstallFailed(PluginInstallFailedInput { + plugin, + error_type, + }), + )); + } + + pub fn track_external_agent_config_import_completed( + &self, + input: ExternalAgentConfigImportCompletedInput, + ) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::ExternalAgentConfigImportCompleted(input), + )); + } + + pub fn track_external_agent_config_import_failure( + &self, + input: ExternalAgentConfigImportFailureInput, + ) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::ExternalAgentConfigImportFailure(input), + )); + } + pub fn track_plugin_uninstalled(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index eed972852a..29d896156b 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -83,6 +83,9 @@ pub(crate) enum TrackEventRequest { PluginUninstalled(CodexPluginEventRequest), PluginEnabled(CodexPluginEventRequest), PluginDisabled(CodexPluginEventRequest), + PluginInstallFailed(CodexPluginInstallFailedEventRequest), + ExternalAgentConfigImportCompleted(CodexOnboardingExternalAgentImportCompleteEventRequest), + ExternalAgentConfigImportFailure(CodexOnboardingExternalAgentImportFailureEventRequest), } impl TrackEventRequest { @@ -954,6 +957,53 @@ pub(crate) struct CodexPluginEventRequest { pub(crate) event_params: CodexPluginMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexPluginInstallFailedMetadata { + #[serde(flatten)] + pub(crate) plugin: CodexPluginMetadata, + pub(crate) error_type: String, +} + +#[derive(Serialize)] +pub(crate) struct CodexPluginInstallFailedEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexPluginInstallFailedMetadata, +} + +#[derive(Serialize)] +pub(crate) struct CodexOnboardingExternalAgentImportCompleteMetadata { + pub(crate) import_id: String, + pub(crate) source: String, + #[serde(rename = "type")] + pub(crate) item_type: String, + pub(crate) success_count: usize, + pub(crate) failed_count: usize, + pub(crate) product_client_id: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexOnboardingExternalAgentImportCompleteEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexOnboardingExternalAgentImportCompleteMetadata, +} + +#[derive(Serialize)] +pub(crate) struct CodexOnboardingExternalAgentImportFailureMetadata { + pub(crate) import_id: String, + pub(crate) source: String, + #[serde(rename = "type")] + pub(crate) item_type: String, + pub(crate) failure_stage: String, + pub(crate) error_type: String, + pub(crate) product_client_id: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexOnboardingExternalAgentImportFailureEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexOnboardingExternalAgentImportFailureMetadata, +} + #[derive(Serialize)] pub(crate) struct CodexPluginUsedEventRequest { pub(crate) event_type: &'static str, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index ab278e223c..a8d1d6d6b0 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -504,6 +504,9 @@ pub(crate) enum CustomAnalyticsFact { HookRun(HookRunInput), PluginUsed(PluginUsedInput), PluginStateChanged(PluginStateChangedInput), + PluginInstallFailed(PluginInstallFailedInput), + ExternalAgentConfigImportCompleted(ExternalAgentConfigImportCompletedInput), + ExternalAgentConfigImportFailure(ExternalAgentConfigImportFailureInput), } pub(crate) struct SkillInvokedInput { @@ -542,6 +545,27 @@ pub(crate) struct PluginStateChangedInput { pub state: PluginState, } +pub(crate) struct PluginInstallFailedInput { + pub plugin: PluginTelemetryMetadata, + pub error_type: String, +} + +pub struct ExternalAgentConfigImportCompletedInput { + pub import_id: String, + pub source: String, + pub item_type: String, + pub success_count: usize, + pub failed_count: usize, +} + +pub struct ExternalAgentConfigImportFailureInput { + pub import_id: String, + pub source: String, + pub item_type: String, + pub failure_stage: String, + pub error_type: String, +} + #[derive(Clone, Copy)] pub(crate) enum PluginState { Installed, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 687a3a7631..58228f5c5c 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -36,6 +36,8 @@ pub use facts::CompactionReason; pub use facts::CompactionStatus; pub use facts::CompactionStrategy; pub use facts::CompactionTrigger; +pub use facts::ExternalAgentConfigImportCompletedInput; +pub use facts::ExternalAgentConfigImportFailureInput; pub use facts::GoalEventKind; pub use facts::HookRunFact; pub use facts::InputError; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 89d6e57dc5..b647984dff 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -21,7 +21,13 @@ use crate::events::CodexImageGenerationEventParams; use crate::events::CodexImageGenerationEventRequest; use crate::events::CodexMcpToolCallEventParams; use crate::events::CodexMcpToolCallEventRequest; +use crate::events::CodexOnboardingExternalAgentImportCompleteEventRequest; +use crate::events::CodexOnboardingExternalAgentImportCompleteMetadata; +use crate::events::CodexOnboardingExternalAgentImportFailureEventRequest; +use crate::events::CodexOnboardingExternalAgentImportFailureMetadata; use crate::events::CodexPluginEventRequest; +use crate::events::CodexPluginInstallFailedEventRequest; +use crate::events::CodexPluginInstallFailedMetadata; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexReviewEventParams; use crate::events::CodexReviewEventRequest; @@ -66,7 +72,10 @@ use crate::facts::AppUsedInput; use crate::facts::CodexCompactionEvent; use crate::facts::CodexGoalEvent; use crate::facts::CustomAnalyticsFact; +use crate::facts::ExternalAgentConfigImportCompletedInput; +use crate::facts::ExternalAgentConfigImportFailureInput; use crate::facts::HookRunInput; +use crate::facts::PluginInstallFailedInput; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; @@ -511,6 +520,15 @@ impl AnalyticsReducer { CustomAnalyticsFact::PluginStateChanged(input) => { self.ingest_plugin_state_changed(input, out); } + CustomAnalyticsFact::PluginInstallFailed(input) => { + self.ingest_plugin_install_failed(input, out); + } + CustomAnalyticsFact::ExternalAgentConfigImportCompleted(input) => { + self.ingest_external_agent_config_import_completed(input, out); + } + CustomAnalyticsFact::ExternalAgentConfigImportFailure(input) => { + self.ingest_external_agent_config_import_failure(input, out); + } }, } } @@ -775,6 +793,63 @@ impl AnalyticsReducer { }); } + fn ingest_plugin_install_failed( + &mut self, + input: PluginInstallFailedInput, + out: &mut Vec, + ) { + let PluginInstallFailedInput { plugin, error_type } = input; + out.push(TrackEventRequest::PluginInstallFailed( + CodexPluginInstallFailedEventRequest { + event_type: "codex_plugin_install_failed", + event_params: CodexPluginInstallFailedMetadata { + plugin: codex_plugin_metadata(plugin), + error_type, + }, + }, + )); + } + + fn ingest_external_agent_config_import_completed( + &mut self, + input: ExternalAgentConfigImportCompletedInput, + out: &mut Vec, + ) { + out.push(TrackEventRequest::ExternalAgentConfigImportCompleted( + CodexOnboardingExternalAgentImportCompleteEventRequest { + event_type: "codex_onboarding_external_agent_import_complete", + event_params: CodexOnboardingExternalAgentImportCompleteMetadata { + import_id: input.import_id, + source: input.source, + item_type: input.item_type, + success_count: input.success_count, + failed_count: input.failed_count, + product_client_id: Some(originator().value), + }, + }, + )); + } + + fn ingest_external_agent_config_import_failure( + &mut self, + input: ExternalAgentConfigImportFailureInput, + out: &mut Vec, + ) { + out.push(TrackEventRequest::ExternalAgentConfigImportFailure( + CodexOnboardingExternalAgentImportFailureEventRequest { + event_type: "codex_onboarding_external_agent_import_failure", + event_params: CodexOnboardingExternalAgentImportFailureMetadata { + import_id: input.import_id, + source: input.source, + item_type: input.item_type, + failure_stage: input.failure_stage, + error_type: input.error_type, + product_client_id: Some(originator().value), + }, + }, + )); + } + async fn ingest_response( &mut self, connection_id: u64, diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 4f04698e37..509670f61b 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -800,7 +800,7 @@ ] }, "includeHome": { - "description": "If true, include detection under the user's home (~/.claude, ~/.codex, etc.).", + "description": "If true, include detection under the user's home directory.", "type": "boolean" } }, @@ -813,6 +813,13 @@ "$ref": "#/definitions/ExternalAgentConfigMigrationItem" }, "type": "array" + }, + "source": { + "description": "Source product that produced the migration items. Missing means unspecified.", + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 5cbf96f24c..99a8a24e39 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -9119,7 +9119,7 @@ ] }, "includeHome": { - "description": "If true, include detection under the user's home (~/.claude, ~/.codex, etc.).", + "description": "If true, include detection under the user's home directory.", "type": "boolean" } }, @@ -9282,6 +9282,13 @@ "$ref": "#/definitions/v2/ExternalAgentConfigMigrationItem" }, "type": "array" + }, + "source": { + "description": "Source product that produced the migration items. Missing means unspecified.", + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index e5a922ea78..b0399a2232 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -5404,7 +5404,7 @@ ] }, "includeHome": { - "description": "If true, include detection under the user's home (~/.claude, ~/.codex, etc.).", + "description": "If true, include detection under the user's home directory.", "type": "boolean" } }, @@ -5567,6 +5567,13 @@ "$ref": "#/definitions/ExternalAgentConfigMigrationItem" }, "type": "array" + }, + "source": { + "description": "Source product that produced the migration items. Missing means unspecified.", + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json index 20ddd6e48a..01fda3b071 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json @@ -12,7 +12,7 @@ ] }, "includeHome": { - "description": "If true, include detection under the user's home (~/.claude, ~/.codex, etc.).", + "description": "If true, include detection under the user's home directory.", "type": "boolean" } }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json index b26e9d187a..bc432b873a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json @@ -184,6 +184,13 @@ "$ref": "#/definitions/ExternalAgentConfigMigrationItem" }, "type": "array" + }, + "source": { + "description": "Source product that produced the migration items. Missing means unspecified.", + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectParams.ts index 163d961925..48b55e9b9a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectParams.ts @@ -4,7 +4,7 @@ export type ExternalAgentConfigDetectParams = { /** - * If true, include detection under the user's home (~/.claude, ~/.codex, etc.). + * If true, include detection under the user's home directory. */ includeHome?: boolean, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportParams.ts index 7bc5d9d91f..be7f7ffe30 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportParams.ts @@ -3,4 +3,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ExternalAgentConfigMigrationItem } from "./ExternalAgentConfigMigrationItem"; -export type ExternalAgentConfigImportParams = { migrationItems: Array, }; +export type ExternalAgentConfigImportParams = { migrationItems: Array, +/** + * Source product that produced the migration items. Missing means unspecified. + */ +source?: string | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 4f299d0227..b37f10dce7 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -651,7 +651,7 @@ pub struct ExternalAgentConfigDetectResponse { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct ExternalAgentConfigDetectParams { - /// If true, include detection under the user's home (~/.claude, ~/.codex, etc.). + /// If true, include detection under the user's home directory. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub include_home: bool, /// Zero or more working directories to include for repo-scoped detection. @@ -664,6 +664,9 @@ pub struct ExternalAgentConfigDetectParams { #[ts(export_to = "v2/")] pub struct ExternalAgentConfigImportParams { pub migration_items: Vec, + /// Source product that produced the migration items. Missing means unspecified. + #[ts(optional = nullable)] + pub source: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 39bc52717a..b973b8b4ae 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -376,6 +376,7 @@ fn external_agent_config_import_params_accept_legacy_plugin_details() { ..Default::default() }), }], + source: None, } ); } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index ab001cc682..d342313917 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -232,7 +232,7 @@ Example with notification opt-out: - `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id. - `config/read` — fetch the effective config on disk after resolving config layering, including opaque `desktop` values stored in `config.toml`. - `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home), and plugin/session migration items may additionally include structured `details` grouping plugin ids or session metadata. -- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. Returns an `importId` used to correlate the completion notification. When a request includes migration items, the server emits `externalAgentConfig/import/completed` once after the full import finishes with type-level `itemResults` containing each migrated type's success count, error count, successes, and raw errors (immediately after the response when everything completed synchronously, or after background imports finish). +- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. Callers may pass `source` to identify the product that initiated the import; omitted or `null` means unspecified. The response acknowledges the synchronous import phase with an `importId`. Expected migration failures are reported as per-item failures rather than JSON-RPC errors, so the server still returns that `importId` and emits `externalAgentConfig/import/completed` with the same ID once all synchronous and background work finishes. The completion notification contains type-level `itemTypeResults` with successes and failures, including raw failure messages for the client to report separately. - `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface. - `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits. - `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), remote-control policy (`allowRemoteControl`; `false` force-disables remote control while `true` or `null` preserves existing behavior), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. diff --git a/codex-rs/app-server/src/config/external_agent_config.rs b/codex-rs/app-server/src/config/external_agent_config.rs index 79171e1717..ce10d6e81c 100644 --- a/codex-rs/app-server/src/config/external_agent_config.rs +++ b/codex-rs/app-server/src/config/external_agent_config.rs @@ -237,7 +237,7 @@ impl ExternalAgentConfigService { pub(crate) async fn import( &self, migration_items: Vec, - ) -> io::Result { + ) -> ExternalAgentConfigImportOutcome { let mut outcome = ExternalAgentConfigImportOutcome::default(); for migration_item in migration_items { let item_type = migration_item.item_type; @@ -413,17 +413,28 @@ impl ExternalAgentConfigService { })(), ExternalAgentConfigMigrationItemType::Sessions => Ok(()), }; - if let Err(err) = import_result { - if item_type == ExternalAgentConfigMigrationItemType::Plugins { - outcome.item_results.push(item_result); - continue; - } - return Err(err); + if let Err(err) = import_result + && item_type != ExternalAgentConfigMigrationItemType::Plugins + { + let message = err.to_string(); + let error_type = if message.contains("invalid existing config.toml") { + "invalid_existing_config" + } else { + "external_agent_config_import_error" + }; + item_result.record_error(ExternalAgentConfigImportRawError { + item_type, + error_type: Some(error_type.to_string()), + failure_stage: "import_request_failed".to_string(), + message, + cwd: item_result.cwd.clone(), + source: None, + }); } outcome.item_results.push(item_result); } - Ok(outcome) + outcome } async fn detect_migrations( diff --git a/codex-rs/app-server/src/config/external_agent_config_tests.rs b/codex-rs/app-server/src/config/external_agent_config_tests.rs index d6fa5a927e..447741d14a 100644 --- a/codex-rs/app-server/src/config/external_agent_config_tests.rs +++ b/codex-rs/app-server/src/config/external_agent_config_tests.rs @@ -561,8 +561,7 @@ async fn import_repo_migrates_mcp_hooks_commands_and_subagents() { details: None, }, ]) - .await - .expect("import"); + .await; let config: TomlValue = toml::from_str( &fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"), @@ -724,8 +723,7 @@ url = "https://example.com/mixed-transport" cwd: Some(repo_root.clone()), details: None, }]) - .await - .expect("import"); + .await; assert_eq!( fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"), @@ -838,8 +836,7 @@ async fn import_home_migrates_supported_config_fields_skills_and_agents_md() { details: None, }, ]) - .await - .expect("import"); + .await; assert_eq!( fs::read_to_string(codex_home.join("AGENTS.md")).expect("read agents"), @@ -894,8 +891,7 @@ async fn import_home_config_uses_local_settings_over_project_settings() { cwd: None, details: None, }]) - .await - .expect("import"); + .await; let config: TomlValue = toml::from_str(&fs::read_to_string(codex_home.join("config.toml")).expect("read config")) @@ -939,8 +935,7 @@ async fn import_home_config_ignores_invalid_local_settings() { cwd: None, details: None, }]) - .await - .expect("import"); + .await; assert_eq!( fs::read_to_string(codex_home.join("config.toml")).expect("read config"), @@ -965,8 +960,7 @@ async fn import_home_skips_empty_config_migration() { cwd: None, details: None, }]) - .await - .expect("import"); + .await; assert_eq!( outcome.item_results, @@ -1043,8 +1037,7 @@ async fn import_local_plugins_returns_completed_status() { ..Default::default() }), }]) - .await - .expect("import"); + .await; assert_eq!( outcome.pending_plugin_imports, @@ -1104,8 +1097,7 @@ async fn import_git_plugins_returns_pending_async_status() { ..Default::default() }), }]) - .await - .expect("import"); + .await; assert_eq!( outcome.pending_plugin_imports, @@ -1235,8 +1227,7 @@ async fn import_repo_agents_md_rewrites_terms_and_skips_non_empty_targets() { details: None, }, ]) - .await - .expect("import"); + .await; assert_eq!( outcome.item_results, @@ -1302,8 +1293,7 @@ async fn import_repo_agents_md_overwrites_empty_targets() { cwd: Some(repo_root.clone()), details: None, }]) - .await - .expect("import"); + .await; assert_eq!( outcome.item_results, @@ -1403,8 +1393,7 @@ async fn import_repo_hooks_preserves_disabled_codex_hooks_feature() { cwd: Some(repo_root.clone()), details: None, }]) - .await - .expect("import"); + .await; assert_eq!( outcome.item_results, @@ -1481,8 +1470,7 @@ async fn import_repo_mcp_uses_home_settings_toggles_when_repo_settings_missing() cwd: Some(repo_root.clone()), details: None, }]) - .await - .expect("import"); + .await; assert_eq!( outcome.item_results, @@ -1559,8 +1547,7 @@ async fn import_repo_mcp_uses_local_settings_toggles_over_project_settings() { cwd: Some(repo_root.clone()), details: None, }]) - .await - .expect("import"); + .await; let config: TomlValue = toml::from_str( &fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"), @@ -1607,8 +1594,7 @@ async fn import_repo_mcp_ignores_invalid_home_settings_when_repo_settings_missin cwd: Some(repo_root.clone()), details: None, }]) - .await - .expect("import"); + .await; let config: TomlValue = toml::from_str( &fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"), @@ -1649,8 +1635,7 @@ async fn import_repo_uses_non_empty_external_agent_agents_source() { cwd: Some(repo_root.clone()), details: None, }]) - .await - .expect("import"); + .await; assert_eq!( fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"), @@ -1683,8 +1668,7 @@ async fn import_continues_after_failed_migration_item() { details: None, }, ]) - .await - .expect("import continues"); + .await; assert_eq!( fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 985160985d..3544592b49 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -515,7 +515,7 @@ impl MessageProcessor { outgoing.clone(), config_manager.clone(), thread_manager.clone(), - analytics_events_client, + analytics_events_client.clone(), ); let external_agent_config_processor = ExternalAgentConfigRequestProcessor::new(ExternalAgentConfigRequestProcessorArgs { @@ -525,6 +525,7 @@ impl MessageProcessor { config_manager: config_manager.clone(), config_processor: config_processor.clone(), state_db, + analytics_events_client, arg0_paths, codex_home: config.codex_home.to_path_buf(), }); diff --git a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs index b3cdb0e213..69bb4218ce 100644 --- a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs +++ b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs @@ -15,6 +15,9 @@ use crate::config_manager::ConfigManager; use crate::error_code::internal_error; use crate::outgoing_message::ConnectionRequestId; use crate::outgoing_message::OutgoingMessageSender; +use codex_analytics::AnalyticsEventsClient; +use codex_analytics::ExternalAgentConfigImportCompletedInput; +use codex_analytics::ExternalAgentConfigImportFailureInput; use codex_app_server_protocol::CommandMigration; use codex_app_server_protocol::ExternalAgentConfigDetectParams; use codex_app_server_protocol::ExternalAgentConfigDetectResponse; @@ -57,6 +60,7 @@ pub(crate) struct ExternalAgentConfigRequestProcessor { thread_manager: Arc, config_processor: ConfigRequestProcessor, state_db: Option, + analytics_events_client: AnalyticsEventsClient, } pub(crate) struct ExternalAgentConfigRequestProcessorArgs { @@ -66,6 +70,7 @@ pub(crate) struct ExternalAgentConfigRequestProcessorArgs { pub(crate) config_manager: ConfigManager, pub(crate) config_processor: ConfigRequestProcessor, pub(crate) state_db: Option, + pub(crate) analytics_events_client: AnalyticsEventsClient, pub(crate) arg0_paths: Arg0DispatchPaths, pub(crate) codex_home: PathBuf, } @@ -79,6 +84,7 @@ impl ExternalAgentConfigRequestProcessor { config_manager, config_processor, state_db, + analytics_events_client, arg0_paths, codex_home, } = args; @@ -96,6 +102,7 @@ impl ExternalAgentConfigRequestProcessor { thread_manager, config_processor, state_db, + analytics_events_client, } } @@ -199,6 +206,7 @@ impl ExternalAgentConfigRequestProcessor { params: ExternalAgentConfigImportParams, ) -> Result<(), JSONRPCErrorError> { let import_id = Uuid::new_v4().to_string(); + let analytics_source = params.source.clone().unwrap_or_default(); let needs_runtime_refresh = migration_items_need_runtime_refresh(¶ms.migration_items); let has_migration_items = !params.migration_items.is_empty(); let has_plugin_imports = params.migration_items.iter().any(|item| { @@ -209,7 +217,7 @@ impl ExternalAgentConfigRequestProcessor { }); let (pending_session_imports, session_validation_result) = self.validate_pending_session_imports(¶ms); - let import_outcome = self.import_external_agent_config(params).await?; + let import_outcome = self.import_external_agent_config(params).await; if needs_runtime_refresh { self.config_processor.handle_config_mutation().await; } @@ -242,7 +250,9 @@ impl ExternalAgentConfigRequestProcessor { send_completed_import_notification( &self.outgoing, self.state_db.as_ref(), + &self.analytics_events_client, import_id, + analytics_source, &completed_item_results, ) .await; @@ -253,6 +263,7 @@ impl ExternalAgentConfigRequestProcessor { let plugin_processor = self.clone(); let outgoing = Arc::clone(&self.outgoing); let state_db = self.state_db.clone(); + let analytics_events_client = self.analytics_events_client.clone(); let thread_manager = Arc::clone(&self.thread_manager); let session_import_result = (!pending_session_imports.is_empty()).then(|| { CoreImportItemResult::new( @@ -324,7 +335,9 @@ impl ExternalAgentConfigRequestProcessor { send_completed_import_notification( &outgoing, state_db.as_ref(), + &analytics_events_client, import_id, + analytics_source, &completed_item_results, ) .await; @@ -421,7 +434,7 @@ impl ExternalAgentConfigRequestProcessor { async fn import_external_agent_config( &self, params: ExternalAgentConfigImportParams, - ) -> Result { + ) -> CoreImportOutcome { self.migration_service .import( params @@ -516,7 +529,6 @@ impl ExternalAgentConfigRequestProcessor { .collect(), ) .await - .map_err(|err| internal_error(err.to_string())) } async fn complete_pending_plugin_import( @@ -551,10 +563,14 @@ async fn send_import_progress( async fn send_completed_import_notification( outgoing: &OutgoingMessageSender, state_db: Option<&StateDbHandle>, + analytics_events_client: &AnalyticsEventsClient, import_id: String, + analytics_source: String, item_results: &[CoreImportItemResult], ) { let notification = completed_notification(import_id, item_results); + log_completed_import_failures(¬ification); + track_completed_import_notification(analytics_events_client, &analytics_source, ¬ification); if let Some(state_db) = state_db && let Err(err) = record_completed_import_notification(state_db, ¬ification).await { @@ -571,6 +587,75 @@ async fn send_completed_import_notification( .await; } +fn log_completed_import_failures(notification: &ExternalAgentConfigImportCompletedNotification) { + for type_result in ¬ification.item_type_results { + for failure in &type_result.failures { + let error_type = import_failure_error_type(failure); + tracing::warn!( + import_id = %notification.import_id, + item_type = ?failure.item_type, + error_type = %error_type, + failure_stage = %failure.failure_stage, + cwd = ?failure.cwd, + source = ?failure.source, + error = %failure.message, + "external agent config migration item failed" + ); + } + } +} + +fn track_completed_import_notification( + analytics_events_client: &AnalyticsEventsClient, + analytics_source: &str, + notification: &ExternalAgentConfigImportCompletedNotification, +) { + for type_result in ¬ification.item_type_results { + let item_type = analytics_migration_item_type(type_result.item_type).to_string(); + analytics_events_client.track_external_agent_config_import_completed( + ExternalAgentConfigImportCompletedInput { + import_id: notification.import_id.clone(), + source: analytics_source.to_string(), + item_type: item_type.clone(), + success_count: type_result.successes.len(), + failed_count: type_result.failures.len(), + }, + ); + for failure in &type_result.failures { + analytics_events_client.track_external_agent_config_import_failure( + ExternalAgentConfigImportFailureInput { + import_id: notification.import_id.clone(), + source: analytics_source.to_string(), + item_type: item_type.clone(), + failure_stage: failure.failure_stage.clone(), + error_type: import_failure_error_type(failure), + }, + ); + } + } +} + +fn import_failure_error_type(failure: &ProtocolImportFailure) -> String { + failure + .error_type + .clone() + .unwrap_or_else(|| failure.failure_stage.clone()) +} + +fn analytics_migration_item_type(item_type: ExternalAgentConfigMigrationItemType) -> &'static str { + match item_type { + ExternalAgentConfigMigrationItemType::AgentsMd => "AGENTS_MD", + ExternalAgentConfigMigrationItemType::Config => "CONFIG", + ExternalAgentConfigMigrationItemType::Skills => "SKILLS", + ExternalAgentConfigMigrationItemType::Plugins => "PLUGINS", + ExternalAgentConfigMigrationItemType::McpServerConfig => "MCP_SERVER_CONFIG", + ExternalAgentConfigMigrationItemType::Subagents => "SUBAGENTS", + ExternalAgentConfigMigrationItemType::Hooks => "HOOKS", + ExternalAgentConfigMigrationItemType::Commands => "COMMANDS", + ExternalAgentConfigMigrationItemType::Sessions => "SESSIONS", + } +} + async fn record_completed_import_notification( state_db: &StateDbHandle, notification: &ExternalAgentConfigImportCompletedNotification, diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index d2cd08b63a..e9bf044766 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -18,9 +18,12 @@ use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETP use codex_core_plugins::remote::RemoteAppTemplateUnavailableReason; use codex_core_plugins::remote::is_valid_remote_plugin_id; use codex_core_plugins::remote::validate_remote_plugin_id; +use codex_core_plugins::remote_bundle::RemotePluginBundleInstallError; use codex_mcp::McpOAuthLoginSupport; use codex_mcp::oauth_login_support; use codex_mcp::should_retry_without_scopes; +use codex_plugin::PluginId; +use codex_plugin::PluginTelemetryMetadata; use codex_rmcp_client::perform_oauth_login_silent; #[derive(Clone)] @@ -1437,15 +1440,24 @@ impl PluginRequestProcessor { } let plugins_manager = self.thread_manager.plugins_manager(); + let marketplace_display = marketplace_path.display().to_string(); + let plugin_name_for_log = plugin_name.clone(); let request = PluginInstallRequest { plugin_name, marketplace_path, }; - let result = plugins_manager - .install_plugin(request) - .await - .map_err(Self::plugin_install_error)?; + let result = match plugins_manager.install_plugin(request).await { + Ok(result) => result, + Err(err) => { + warn!( + marketplace = %marketplace_display, + plugin_name = %plugin_name_for_log, + "failed to install plugin: {err}" + ); + return Err(Self::plugin_install_error(err)); + } + }; let config = match self.load_latest_config(config_cwd).await { Ok(config) => config, Err(err) => { @@ -1512,11 +1524,20 @@ impl PluginRequestProcessor { ) .await .map_err(|err| { + let error_type = remote_plugin_catalog_error_type(&err); + self.track_plugin_install_failed_for_remote_plugin( + &remote_plugin_id, + &remote_marketplace_name, + error_type, + err.to_string(), + ); remote_plugin_catalog_error_to_jsonrpc( err, "read remote plugin details before install", ) })?; + let actual_remote_marketplace_name = remote_detail.marketplace_name.clone(); + let remote_plugin_name = remote_detail.summary.name.clone(); if remote_detail.summary.availability == PluginAvailability::DisabledByAdmin { return Err(invalid_request(format!( "remote plugin {remote_plugin_id} is disabled by admin" @@ -1527,31 +1548,48 @@ impl PluginRequestProcessor { "remote plugin {remote_plugin_id} is not available for install" ))); } - let actual_remote_marketplace_name = remote_detail.marketplace_name.clone(); // Direct install writes the same cache tree that installed-plugin sync // prunes before the backend installed snapshot can include this plugin. let _remote_plugin_cache_mutation = codex_core_plugins::remote::mark_remote_plugin_cache_mutation_in_flight( config.codex_home.as_path(), &actual_remote_marketplace_name, - &remote_detail.summary.name, + &remote_plugin_name, ); let validated_bundle = codex_core_plugins::remote_bundle::validate_remote_plugin_bundle( &remote_plugin_id, &actual_remote_marketplace_name, - &remote_detail.summary.name, + &remote_plugin_name, remote_detail.release_version.as_deref(), remote_detail.bundle_download_url.as_deref(), remote_detail.app_manifest.clone(), ) - .map_err(remote_plugin_bundle_install_error_to_jsonrpc)?; + .map_err(|err| { + let error_type = remote_plugin_bundle_install_error_type(&err); + self.track_plugin_install_failed_for_remote_plugin( + &remote_plugin_id, + &actual_remote_marketplace_name, + error_type, + err.to_string(), + ); + remote_plugin_bundle_install_error_to_jsonrpc(err) + })?; let result = codex_core_plugins::remote_bundle::download_and_install_remote_plugin_bundle( config.codex_home.to_path_buf(), validated_bundle, ) .await - .map_err(remote_plugin_bundle_install_error_to_jsonrpc)?; + .map_err(|err| { + let error_type = remote_plugin_bundle_install_error_type(&err); + self.track_plugin_install_failed_for_remote_plugin( + &remote_plugin_id, + &actual_remote_marketplace_name, + error_type, + err.to_string(), + ); + remote_plugin_bundle_install_error_to_jsonrpc(err) + })?; // Cache first so a backend install cannot succeed when local materialization fails. // If this backend call fails, the cache entry is harmless because remote installed state @@ -1563,7 +1601,16 @@ impl PluginRequestProcessor { &remote_plugin_id, ) .await - .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "install remote plugin"))?; + .map_err(|err| { + let error_type = remote_plugin_catalog_error_type(&err); + self.track_plugin_install_failed_for_remote_plugin( + &remote_plugin_id, + &actual_remote_marketplace_name, + error_type, + err.to_string(), + ); + remote_plugin_catalog_error_to_jsonrpc(err, "install remote plugin") + })?; self.thread_manager .plugins_manager() @@ -1646,6 +1693,32 @@ impl PluginRequestProcessor { }) } + fn track_plugin_install_failed_for_remote_plugin( + &self, + remote_plugin_id: &str, + marketplace_name: &str, + error_type: &'static str, + error_message: String, + ) { + tracing::warn!( + remote_plugin_id = %remote_plugin_id, + marketplace_name = %marketplace_name, + error_type = %error_type, + error = %error_message, + "remote plugin install failed" + ); + // The remote id is reported separately; this local name only satisfies + // PluginId validation before remote details are available. + let Ok(plugin_id) = PluginId::new("unknown".to_string(), marketplace_name.to_string()) + else { + return; + }; + let mut plugin = PluginTelemetryMetadata::from_plugin_id(&plugin_id); + plugin.remote_plugin_id = Some(remote_plugin_id.to_string()); + self.analytics_events_client + .track_plugin_install_failed(plugin, error_type.to_string()); + } + async fn plugin_apps_needing_auth_for_install( &self, config: &Config, @@ -2145,6 +2218,75 @@ fn remote_plugin_detail_to_info( } } +fn remote_plugin_catalog_error_type(err: &RemotePluginCatalogError) -> &'static str { + match err { + RemotePluginCatalogError::AuthRequired => "remote_catalog_auth_required", + RemotePluginCatalogError::UnsupportedAuthMode => "remote_catalog_unsupported_auth_mode", + RemotePluginCatalogError::AuthToken(_) => "remote_catalog_auth_token", + RemotePluginCatalogError::Request { .. } => "remote_catalog_request", + RemotePluginCatalogError::UnexpectedStatus { .. } => "remote_catalog_unexpected_status", + RemotePluginCatalogError::Decode { .. } => "remote_catalog_decode", + RemotePluginCatalogError::InvalidBaseUrl(_) => "remote_catalog_invalid_base_url", + RemotePluginCatalogError::InvalidBaseUrlPath => "remote_catalog_invalid_base_url_path", + RemotePluginCatalogError::UnknownMarketplace { .. } => "remote_catalog_unknown_marketplace", + RemotePluginCatalogError::UnexpectedPluginId { .. } => { + "remote_catalog_unexpected_plugin_id" + } + RemotePluginCatalogError::UnexpectedSkillName { .. } => { + "remote_catalog_unexpected_skill_name" + } + RemotePluginCatalogError::UnexpectedEnabledState { .. } => { + "remote_catalog_unexpected_enabled_state" + } + RemotePluginCatalogError::InvalidPluginPath { .. } => "remote_catalog_invalid_plugin_path", + RemotePluginCatalogError::PluginShareCheckoutNotAvailable { .. } => { + "remote_catalog_plugin_share_checkout_not_available" + } + RemotePluginCatalogError::Archive { .. } => "remote_catalog_archive", + RemotePluginCatalogError::ArchiveJoin(_) => "remote_catalog_archive_join", + RemotePluginCatalogError::ArchiveTooLarge { .. } => "remote_catalog_archive_too_large", + RemotePluginCatalogError::MissingUploadEtag => "remote_catalog_missing_upload_etag", + RemotePluginCatalogError::UnexpectedResponse(_) => "remote_catalog_unexpected_response", + RemotePluginCatalogError::CacheRemove(_) => "remote_catalog_cache_remove", + } +} + +fn remote_plugin_bundle_install_error_type(err: &RemotePluginBundleInstallError) -> &'static str { + match err { + RemotePluginBundleInstallError::MissingReleaseVersion { .. } => { + "remote_bundle_missing_release_version" + } + RemotePluginBundleInstallError::InvalidReleaseVersion { .. } => { + "remote_bundle_invalid_release_version" + } + RemotePluginBundleInstallError::MissingBundleDownloadUrl { .. } => { + "remote_bundle_missing_download_url" + } + RemotePluginBundleInstallError::InvalidBundleDownloadUrl { .. } => { + "remote_bundle_invalid_download_url" + } + RemotePluginBundleInstallError::UnsupportedBundleDownloadUrlScheme { .. } => { + "remote_bundle_unsupported_download_url_scheme" + } + RemotePluginBundleInstallError::InvalidPluginId { .. } => "remote_bundle_invalid_plugin_id", + RemotePluginBundleInstallError::DownloadRequest { .. } => "remote_bundle_download_request", + RemotePluginBundleInstallError::DownloadStatus { .. } => "remote_bundle_download_status", + RemotePluginBundleInstallError::DownloadBody { .. } => "remote_bundle_download_body", + RemotePluginBundleInstallError::DownloadTooLarge { .. } => { + "remote_bundle_download_too_large" + } + RemotePluginBundleInstallError::UnsupportedBundleDownloadFinalUrl { .. } => { + "remote_bundle_unsupported_download_final_url" + } + RemotePluginBundleInstallError::ExtractedBundleTooLarge { .. } => { + "remote_bundle_extracted_too_large" + } + RemotePluginBundleInstallError::Io { .. } => "remote_bundle_io", + RemotePluginBundleInstallError::InvalidBundle(_) => "remote_bundle_invalid_bundle", + RemotePluginBundleInstallError::Store(_) => "remote_bundle_store", + } +} + fn remote_plugin_catalog_error_to_jsonrpc( err: RemotePluginCatalogError, context: &str, diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs index b48aeca2b0..a5d918cbaf 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -1,9 +1,12 @@ use std::time::Duration; use anyhow::Result; +use app_test_support::ChatGptAuthFixture; use app_test_support::TestAppServer; use app_test_support::create_mock_responses_server_repeating_assistant; +use app_test_support::start_analytics_events_server; use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; use app_test_support::write_mock_responses_config_toml; use codex_app_server_protocol::ExternalAgentConfigDetectResponse; use codex_app_server_protocol::ExternalAgentConfigImportCompletedNotification; @@ -11,7 +14,6 @@ use codex_app_server_protocol::ExternalAgentConfigImportHistoriesReadResponse; use codex_app_server_protocol::ExternalAgentConfigImportProgressNotification; use codex_app_server_protocol::ExternalAgentConfigImportResponse; use codex_app_server_protocol::ExternalAgentConfigMigrationItemType; -use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginListResponse; @@ -25,16 +27,25 @@ use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::UserInput; +use codex_config::types::AuthCredentialsStoreMode; use core_test_support::responses; use pretty_assertions::assert_eq; use std::collections::BTreeMap; +use std::path::Path; +use std::path::PathBuf; use tempfile::TempDir; #[cfg(unix)] use tokio::io::AsyncWriteExt; use tokio::time::timeout; +use super::analytics::wait_for_analytics_event; + const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); +fn external_agent_home(codex_home: &Path) -> PathBuf { + codex_home.join(concat!(".", "cl", "aude")) +} + fn assert_import_response(response: ExternalAgentConfigImportResponse) -> String { assert!(!response.import_id.is_empty()); response.import_id @@ -158,15 +169,172 @@ async fn external_agent_config_import_sends_completion_notification_for_sync_onl } #[tokio::test] -async fn external_agent_config_import_returns_error_for_failed_sync_import() -> Result<()> { +async fn external_agent_config_import_reports_failed_sync_import_in_completion() -> Result<()> { let codex_home = TempDir::new()?; - std::fs::create_dir_all(codex_home.path().join(".claude"))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + let source_home = external_agent_home(codex_home.path()); + std::fs::create_dir_all(&source_home)?; std::fs::write( - codex_home.path().join(".claude").join("settings.json"), + source_home.join("settings.json"), r#"{"env":{"FOO":"bar"}}"#, )?; std::fs::write(codex_home.path().join("config.toml"), "invalid = [")?; let home_dir = codex_home.path().display().to_string(); + let analytics_capture_file = codex_home.path().join("analytics-events.jsonl"); + let analytics_capture_file = analytics_capture_file.display().to_string(); + let mut mcp = TestAppServer::new_with_env( + codex_home.path(), + &[ + ("HOME", Some(home_dir.as_str())), + ( + "CODEX_ANALYTICS_EVENTS_CAPTURE_FILE", + Some(analytics_capture_file.as_str()), + ), + ], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "externalAgentConfig/import", + Some(serde_json::json!({ + "source": "test_import", + "migrationItems": [ + { + "itemType": "CONFIG", + "description": "Import config", + "cwd": null + }, + { + "itemType": "COMMANDS", + "description": "Import commands", + "cwd": null + } + ] + })), + ) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ExternalAgentConfigImportResponse = to_response(response)?; + let import_id = assert_import_response(response); + + let notification = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"), + ) + .await??; + let completed: ExternalAgentConfigImportCompletedNotification = + serde_json::from_value(notification.params.expect("completed params"))?; + assert_eq!(completed.import_id, import_id); + let config_result = completed + .item_type_results + .iter() + .find(|result| result.item_type == ExternalAgentConfigMigrationItemType::Config) + .expect("config result"); + assert!(config_result.successes.is_empty()); + assert_eq!(config_result.failures.len(), 1); + let config_failure = &config_result.failures[0]; + assert_eq!( + config_failure.error_type.as_deref(), + Some("invalid_existing_config") + ); + assert_eq!(config_failure.failure_stage, "import_request_failed"); + assert!( + config_failure + .message + .contains("invalid existing config.toml"), + "unexpected failure: {config_failure:?}" + ); + let commands_result = completed + .item_type_results + .iter() + .find(|result| result.item_type == ExternalAgentConfigMigrationItemType::Commands) + .expect("commands result"); + assert!(commands_result.successes.is_empty()); + assert!(commands_result.failures.is_empty()); + + let events = timeout(DEFAULT_TIMEOUT, async { + loop { + let contents = match std::fs::read_to_string(&analytics_capture_file) { + Ok(contents) => contents, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + } + Err(err) => return Err(err.into()), + }; + let mut captured_events = Vec::new(); + for line in contents.lines() { + let payload: serde_json::Value = serde_json::from_str(line)?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + captured_events.extend(events.iter().cloned()); + } + if captured_events.iter().any(|event| { + event["event_type"] == "codex_onboarding_external_agent_import_complete" + && event["event_params"]["type"] == "COMMANDS" + }) { + return Ok::, anyhow::Error>(captured_events); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await??; + let event = events + .iter() + .find(|event| { + event["event_type"] == "codex_onboarding_external_agent_import_failure" + && event["event_params"]["type"] == "CONFIG" + }) + .expect("config failure analytics event"); + let event_params = &event["event_params"]; + assert_eq!(event_params["import_id"], import_id); + assert_eq!(event_params["source"], "test_import"); + assert_eq!(event_params["type"], "CONFIG"); + assert_eq!(event_params["failure_stage"], "import_request_failed"); + assert_eq!(event_params["error_type"], "invalid_existing_config"); + assert!(event_params.get("raw_errors").is_none()); + assert!(event_params.get("message").is_none()); + assert!(!events.iter().any(|event| { + event["event_type"] == "codex_onboarding_external_agent_import_failure" + && event["event_params"]["type"] == "COMMANDS" + })); + + Ok(()) +} + +#[tokio::test] +async fn external_agent_config_import_completed_tracks_analytics_event() -> Result<()> { + let analytics_server = start_analytics_events_server().await?; + let codex_home = TempDir::new()?; + write_analytics_config(codex_home.path(), &analytics_server.uri())?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let missing_session_path = + external_agent_home(codex_home.path()).join("projects/repo/missing.jsonl"); + let project_root = codex_home.path().join("repo"); + let home_dir = codex_home.path().display().to_string(); let mut mcp = TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) .await?; @@ -176,25 +344,70 @@ async fn external_agent_config_import_returns_error_for_failed_sync_import() -> .send_raw_request( "externalAgentConfig/import", Some(serde_json::json!({ + "source": "test_import", "migrationItems": [{ - "itemType": "CONFIG", - "description": "Import config", - "cwd": null + "itemType": "SESSIONS", + "description": "Migrate recent sessions", + "cwd": null, + "details": { + "sessions": [{ + "path": missing_session_path, + "cwd": project_root, + "title": "missing session" + }] + } }] })), ) .await?; - - let error: JSONRPCError = timeout( + let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, - mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; - assert_eq!(error.error.code, -32603); - assert!( - error.error.message.contains("invalid existing config.toml"), - "unexpected error: {error:?}" - ); + let response: ExternalAgentConfigImportResponse = to_response(response)?; + let import_id = assert_import_response(response); + + let notification = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"), + ) + .await??; + let completed: ExternalAgentConfigImportCompletedNotification = + serde_json::from_value(notification.params.expect("completed params"))?; + assert_eq!(completed.import_id, import_id); + assert_eq!(completed.item_type_results.len(), 1); + assert_eq!(completed.item_type_results[0].successes.len(), 0); + assert_eq!(completed.item_type_results[0].failures.len(), 1); + + let event = wait_for_analytics_event( + &analytics_server, + DEFAULT_TIMEOUT, + "codex_onboarding_external_agent_import_complete", + ) + .await?; + let event_params = &event["event_params"]; + assert_eq!(event_params["import_id"], serde_json::json!(import_id)); + assert_eq!(event_params["source"], "test_import"); + assert_eq!(event_params["type"], "SESSIONS"); + assert_eq!(event_params["success_count"], 0); + assert_eq!(event_params["failed_count"], 1); + assert!(event_params.get("raw_errors").is_none()); + + let event = wait_for_analytics_event( + &analytics_server, + DEFAULT_TIMEOUT, + "codex_onboarding_external_agent_import_failure", + ) + .await?; + let event_params = &event["event_params"]; + assert_eq!(event_params["import_id"], serde_json::json!(import_id)); + assert_eq!(event_params["source"], "test_import"); + assert_eq!(event_params["type"], "SESSIONS"); + assert_eq!(event_params["failure_stage"], "session_missing"); + assert_eq!(event_params["error_type"], "session_missing"); + assert!(event_params.get("raw_errors").is_none()); + assert!(event_params.get("message").is_none()); Ok(()) } @@ -226,7 +439,8 @@ async fn external_agent_config_import_sends_completion_notification_for_local_pl plugin_root.join(".codex-plugin/plugin.json"), r#"{"name":"sample","version":"0.1.0"}"#, )?; - std::fs::create_dir_all(codex_home.path().join(".claude"))?; + let source_home = external_agent_home(codex_home.path()); + std::fs::create_dir_all(&source_home)?; let settings = serde_json::json!({ "enabledPlugins": { "sample@debug": true @@ -239,7 +453,7 @@ async fn external_agent_config_import_sends_completion_notification_for_local_pl } }); std::fs::write( - codex_home.path().join(".claude").join("settings.json"), + source_home.join("settings.json"), serde_json::to_string_pretty(&settings)?, )?; @@ -318,11 +532,12 @@ async fn external_agent_config_import_sends_completion_notification_for_local_pl async fn external_agent_config_import_sends_completion_notification_after_pending_plugins_finish() -> Result<()> { let codex_home = TempDir::new()?; - std::fs::create_dir_all(codex_home.path().join(".claude"))?; + let source_home = external_agent_home(codex_home.path()); + std::fs::create_dir_all(&source_home)?; // This test only needs a pending non-local plugin import. Use an invalid // source so the background completion path cannot make a real network clone. std::fs::write( - codex_home.path().join(".claude").join("settings.json"), + source_home.join("settings.json"), r#"{ "enabledPlugins": { "formatter@acme-tools": true @@ -387,7 +602,7 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { create_config_toml(codex_home.path(), &server.uri())?; let project_root = codex_home.path().join("repo"); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; @@ -613,7 +828,7 @@ required = true std::fs::write(codex_home.path().join("config.toml"), config)?; let project_root = codex_home.path().join("repo"); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; @@ -698,7 +913,7 @@ async fn external_agent_config_import_accepts_detected_session_payload_after_res create_config_toml(codex_home.path(), &server.uri())?; let project_root = codex_home.path().join("repo"); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; @@ -788,7 +1003,7 @@ async fn external_agent_config_import_skips_already_imported_session_versions() create_config_toml(codex_home.path(), &server.uri())?; let project_root = codex_home.path().join("repo"); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; @@ -882,7 +1097,7 @@ async fn external_agent_config_import_returns_before_background_session_import_f create_config_toml(codex_home.path(), &server.uri())?; let project_root = codex_home.path().join("repo"); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; @@ -1043,7 +1258,7 @@ async fn external_agent_config_import_compacts_huge_session_before_first_follow_ let project_root = codex_home.path().join("repo"); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; @@ -1209,3 +1424,10 @@ stream_max_retries = 0 ), ) } + +fn write_analytics_config(codex_home: &std::path::Path, base_url: &str) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!("chatgpt_base_url = \"{base_url}\"\n"), + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 6965e759e0..cc008e73a9 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -511,6 +511,46 @@ async fn plugin_install_rejects_invalid_remote_plugin_name() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_install_tracks_analytics_when_remote_detail_fetch_fails() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + configure_remote_plugin_test(codex_home.path(), &server)?; + mount_empty_remote_installed_plugins(&server).await; + mount_backend_analytics_events(&server).await; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("failed with status 404")); + + let payload = wait_for_plugin_analytics_payload(&server).await?; + let event_params = &payload["events"][0]["event_params"]; + assert_eq!( + payload["events"][0]["event_type"], + "codex_plugin_install_failed" + ); + assert_eq!(event_params["plugin_id"], REMOTE_PLUGIN_ID); + assert_eq!(event_params["plugin_name"], "unknown"); + assert_eq!( + event_params["marketplace_name"], + "caller-marketplace-is-ignored" + ); + assert_eq!( + event_params["error_type"], + "remote_catalog_unexpected_status" + ); + Ok(()) +} + #[tokio::test] async fn plugin_install_rejects_remote_plugin_disabled_by_admin_before_download() -> Result<()> { let codex_home = TempDir::new()?; @@ -571,6 +611,42 @@ async fn plugin_install_rejects_remote_plugin_disabled_by_admin_before_download( Ok(()) } +#[tokio::test] +async fn plugin_install_rejects_remote_plugin_not_available() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + configure_remote_plugin_test(codex_home.path(), &server)?; + mount_remote_plugin_detail_with_install_policy( + &server, + REMOTE_PLUGIN_ID, + "1.2.3", + /*install_policy*/ "NOT_AVAILABLE", + ) + .await; + mount_empty_remote_installed_plugins(&server).await; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("not available for install")); + wait_for_remote_plugin_request_count( + &server, + "POST", + &format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"), + /*expected_count*/ 0, + ) + .await?; + Ok(()) +} + #[tokio::test] async fn plugin_install_rejects_when_workspace_codex_plugins_disabled() -> Result<()> { let codex_home = TempDir::new()?; @@ -820,6 +896,66 @@ async fn plugin_install_tracks_analytics_event() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_install_failure_tracks_analytics_event() -> Result<()> { + let analytics_server = start_analytics_events_server().await?; + let codex_home = TempDir::new()?; + write_analytics_config(codex_home.path(), &analytics_server.uri())?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let repo_root = TempDir::new()?; + write_plugin_marketplace( + repo_root.path(), + "debug", + "sample-plugin", + "./missing-plugin", + /*install_policy*/ None, + /*auth_policy*/ None, + )?; + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, + plugin_name: "sample-plugin".to_string(), + }) + .await?; + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!(err.error.code, -32600); + + let payload = wait_for_plugin_analytics_payload(&analytics_server).await?; + let event_params = &payload["events"][0]["event_params"]; + assert_eq!( + payload["events"][0]["event_type"], + "codex_plugin_install_failed" + ); + assert_eq!(event_params["plugin_id"], "sample-plugin@debug"); + assert_eq!(event_params["plugin_name"], "sample-plugin"); + assert_eq!(event_params["marketplace_name"], "debug"); + assert_eq!(event_params["has_skills"], json!(null)); + assert_eq!(event_params["mcp_server_count"], json!(null)); + assert_eq!(event_params["connector_ids"], json!(null)); + assert_eq!(event_params["product_client_id"], DEFAULT_CLIENT_NAME); + assert_eq!(event_params["error_type"], "store_invalid"); + Ok(()) +} + #[tokio::test] async fn plugin_install_tracks_remote_plugin_analytics_event() -> Result<()> { let codex_home = TempDir::new()?; @@ -887,6 +1023,7 @@ async fn plugin_install_errors_when_remote_bundle_download_fails() -> Result<()> mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await; mount_empty_remote_installed_plugins(&server).await; mount_remote_plugin_install(&server, REMOTE_PLUGIN_ID).await; + mount_backend_analytics_events(&server).await; let mut mcp = TestAppServer::new_with_env( codex_home.path(), @@ -918,6 +1055,15 @@ async fn plugin_install_errors_when_remote_bundle_download_fails() -> Result<()> /*expected_count*/ 0, ) .await?; + let payload = wait_for_plugin_analytics_payload(&server).await?; + let event_params = &payload["events"][0]["event_params"]; + assert_eq!( + payload["events"][0]["event_type"], + "codex_plugin_install_failed" + ); + assert_eq!(event_params["plugin_id"], REMOTE_PLUGIN_ID); + assert_eq!(event_params["marketplace_name"], "openai-curated-remote"); + assert_eq!(event_params["error_type"], "remote_bundle_download_status"); assert!( !codex_home .path() @@ -1867,6 +2013,45 @@ async fn mount_remote_plugin_detail_with_status_and_app_manifest( bundle_download_url: Option<&str>, status: PluginAvailability, app_manifest: Option, +) { + mount_remote_plugin_detail_with_options( + server, + remote_plugin_id, + release_version, + bundle_download_url, + status, + "AVAILABLE", + app_manifest, + ) + .await; +} + +async fn mount_remote_plugin_detail_with_install_policy( + server: &MockServer, + remote_plugin_id: &str, + release_version: &str, + install_policy: &str, +) { + mount_remote_plugin_detail_with_options( + server, + remote_plugin_id, + release_version, + /*bundle_download_url*/ None, + PluginAvailability::Available, + install_policy, + /*app_manifest*/ None, + ) + .await; +} + +async fn mount_remote_plugin_detail_with_options( + server: &MockServer, + remote_plugin_id: &str, + release_version: &str, + bundle_download_url: Option<&str>, + status: PluginAvailability, + install_policy: &str, + app_manifest: Option, ) { let status = match status { PluginAvailability::Available => "ENABLED", @@ -1883,7 +2068,7 @@ async fn mount_remote_plugin_detail_with_status_and_app_manifest( "id": "{remote_plugin_id}", "name": "linear", "scope": "GLOBAL", - "installation_policy": "AVAILABLE", + "installation_policy": "{install_policy}", "authentication_policy": "ON_USE", "status": "{status}", "release": {{ diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 7fbbcc0023..88b6cd3bb1 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -69,6 +69,7 @@ use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginIdError; +use codex_plugin::PluginTelemetryMetadata; use codex_plugin::app_connector_ids_from_declarations; use codex_plugin::prompt_safe_plugin_description; use codex_protocol::protocol::HookEventName; @@ -1079,12 +1080,29 @@ impl PluginsManager { &self, request: PluginInstallRequest, ) -> Result { - let resolved = find_installable_marketplace_plugin( + let resolved = match find_installable_marketplace_plugin( &request.marketplace_path, &request.plugin_name, self.restriction_product, - )?; - self.install_resolved_plugin(resolved).await + ) { + Ok(resolved) => resolved, + Err(err) => { + self.track_plugin_install_resolution_failed(&err); + return Err(err.into()); + } + }; + let plugin_id = resolved.plugin_id.clone(); + match self.install_resolved_plugin(resolved).await { + Ok(outcome) => Ok(outcome), + Err(err) => { + self.track_plugin_install_failed( + &plugin_id, + plugin_install_error_type(&err), + err.to_string(), + ); + Err(err) + } + } } pub async fn install_plugin_with_remote_sync( @@ -1093,21 +1111,101 @@ impl PluginsManager { auth: Option<&CodexAuth>, request: PluginInstallRequest, ) -> Result { - let resolved = find_installable_marketplace_plugin( + let resolved = match find_installable_marketplace_plugin( &request.marketplace_path, &request.plugin_name, self.restriction_product, - )?; + ) { + Ok(resolved) => resolved, + Err(err) => { + self.track_plugin_install_resolution_failed(&err); + return Err(err.into()); + } + }; let plugin_id = resolved.plugin_id.as_key(); // This only forwards the backend mutation before the local install flow. - crate::remote_legacy::enable_remote_plugin( + if let Err(err) = crate::remote_legacy::enable_remote_plugin( &remote_plugin_service_config(config), auth, &plugin_id, ) .await - .map_err(PluginInstallError::from)?; - self.install_resolved_plugin(resolved).await + { + let err = PluginInstallError::from(err); + self.track_plugin_install_failed( + &resolved.plugin_id, + plugin_install_error_type(&err), + err.to_string(), + ); + return Err(err); + } + let plugin_id = resolved.plugin_id.clone(); + match self.install_resolved_plugin(resolved).await { + Ok(outcome) => Ok(outcome), + Err(err) => { + self.track_plugin_install_failed( + &plugin_id, + plugin_install_error_type(&err), + err.to_string(), + ); + Err(err) + } + } + } + + fn track_plugin_install_resolution_failed(&self, err: &MarketplaceError) { + let plugin_id = match err { + MarketplaceError::PluginNotFound { + plugin_name, + marketplace_name, + } + | MarketplaceError::PluginNotAvailable { + plugin_name, + marketplace_name, + } => PluginId::new(plugin_name.clone(), marketplace_name.clone()).ok(), + MarketplaceError::Io { .. } + | MarketplaceError::MarketplaceNotFound { .. } + | MarketplaceError::InvalidMarketplaceFile { .. } + | MarketplaceError::PluginsDisabled + | MarketplaceError::InvalidPlugin(_) => None, + }; + if let Some(plugin_id) = plugin_id { + self.track_plugin_install_failed( + &plugin_id, + marketplace_error_type(err), + err.to_string(), + ); + } else { + tracing::warn!( + error_type = %marketplace_error_type(err), + error = %err, + "plugin install failed while resolving marketplace plugin" + ); + } + } + + fn track_plugin_install_failed( + &self, + plugin_id: &PluginId, + error_type: &'static str, + error_message: String, + ) { + tracing::warn!( + plugin_id = %plugin_id.as_key(), + error_type = %error_type, + error = %error_message, + "plugin install failed" + ); + let analytics_events_client = match self.analytics_events_client.read() { + Ok(client) => client.clone(), + Err(err) => err.into_inner().clone(), + }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_plugin_install_failed( + PluginTelemetryMetadata::from_plugin_id(plugin_id), + error_type.to_string(), + ); + } } async fn install_resolved_plugin( @@ -2173,6 +2271,54 @@ impl PluginInstallError { } } +fn plugin_install_error_type(err: &PluginInstallError) -> &'static str { + match err { + PluginInstallError::Marketplace(err) => marketplace_error_type(err), + PluginInstallError::Remote(err) => remote_plugin_mutation_error_type(err), + PluginInstallError::Store(err) => plugin_store_error_type(err), + PluginInstallError::Config(_) => "config", + PluginInstallError::Join(_) => "join", + } +} + +fn marketplace_error_type(err: &MarketplaceError) -> &'static str { + match err { + MarketplaceError::Io { .. } => "marketplace_io", + MarketplaceError::MarketplaceNotFound { .. } => "marketplace_not_found", + MarketplaceError::InvalidMarketplaceFile { .. } => "invalid_marketplace_file", + MarketplaceError::PluginNotFound { .. } => "plugin_not_found", + MarketplaceError::PluginNotAvailable { .. } => "plugin_not_available", + MarketplaceError::PluginsDisabled => "plugins_disabled", + MarketplaceError::InvalidPlugin(_) => "invalid_plugin", + } +} + +fn remote_plugin_mutation_error_type(err: &RemotePluginMutationError) -> &'static str { + match err { + RemotePluginMutationError::AuthRequired => "remote_mutation_auth_required", + RemotePluginMutationError::UnsupportedAuthMode => "remote_mutation_unsupported_auth_mode", + RemotePluginMutationError::AuthToken(_) => "remote_mutation_auth_token", + RemotePluginMutationError::InvalidBaseUrl(_) => "remote_mutation_invalid_base_url", + RemotePluginMutationError::InvalidBaseUrlPath => "remote_mutation_invalid_base_url_path", + RemotePluginMutationError::Request { .. } => "remote_mutation_request", + RemotePluginMutationError::UnexpectedStatus { .. } => "remote_mutation_unexpected_status", + RemotePluginMutationError::Decode { .. } => "remote_mutation_decode", + RemotePluginMutationError::UnexpectedPluginId { .. } => { + "remote_mutation_unexpected_plugin_id" + } + RemotePluginMutationError::UnexpectedEnabledState { .. } => { + "remote_mutation_unexpected_enabled_state" + } + } +} + +fn plugin_store_error_type(err: &PluginStoreError) -> &'static str { + match err { + PluginStoreError::Io { .. } => "store_io", + PluginStoreError::Invalid(_) => "store_invalid", + } +} + #[derive(Debug, thiserror::Error)] pub enum PluginUninstallError { #[error("{0}")] diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 4049ca14a6..e57b26840d 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -391,7 +391,10 @@ impl AppServerSession { .client .request_typed(ClientRequest::ExternalAgentConfigImport { request_id, - params: ExternalAgentConfigImportParams { migration_items }, + params: ExternalAgentConfigImportParams { + migration_items, + source: None, + }, }) .await .wrap_err("externalAgentConfig/import failed during Claude Code import"); From a0586ad12d59925be16e0b1c236c471c3375dbeb Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Wed, 17 Jun 2026 13:27:25 -0700 Subject: [PATCH 007/112] exec-server: expose environment registry payloads (#28651) ## Why Services that proxy the exec-server environment registry endpoints need to deserialize and forward the same Noise registration and harness-key validation payloads. Those wire models currently live as private, serialize-only structs in `exec-server`, which forces consumers to duplicate the contract. ## What changed - Add owned serde models for registration and harness-key validation requests and responses. - Use those models in the existing exec-server registry client. - Re-export the models from `codex-exec-server` and `codex-core-api`. - Keep the harness authorization request free of a derived `Debug` implementation so it is not accidentally logged. ## Testing - Focused exec-server registration and harness-key validation tests: 2 passed. - `cargo check -p codex-core-api` The full `codex-exec-server` suite compiled and ran 254 tests: 222 passed, while 32 existing filesystem sandbox tests could not run under the nested macOS sandbox (`sandbox_apply: Operation not permitted`). Co-authored-by: Codex --- codex-rs/core-api/src/lib.rs | 4 ++ .../exec-server/src/environment_registry.rs | 34 +++++++++++++++ codex-rs/exec-server/src/lib.rs | 5 +++ codex-rs/exec-server/src/remote.rs | 41 ++++--------------- 4 files changed, 52 insertions(+), 32 deletions(-) create mode 100644 codex-rs/exec-server/src/environment_registry.rs diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index aac0e21681..5a64a2b85e 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -48,6 +48,10 @@ pub use codex_core::resolve_installation_id; pub use codex_core::skills::SkillsService; pub use codex_core::thread_store_from_config; pub use codex_exec_server::EnvironmentManager; +pub use codex_exec_server::EnvironmentRegistryHarnessKeyValidationRequest; +pub use codex_exec_server::EnvironmentRegistryHarnessKeyValidationResponse; +pub use codex_exec_server::EnvironmentRegistryRegistrationRequest; +pub use codex_exec_server::EnvironmentRegistryRegistrationResponse; pub use codex_exec_server::ExecServerError; pub use codex_exec_server::ExecServerRuntimePaths; pub use codex_exec_server::NoiseChannelIdentity; diff --git a/codex-rs/exec-server/src/environment_registry.rs b/codex-rs/exec-server/src/environment_registry.rs new file mode 100644 index 0000000000..09ac5415f5 --- /dev/null +++ b/codex-rs/exec-server/src/environment_registry.rs @@ -0,0 +1,34 @@ +use serde::Deserialize; +use serde::Serialize; + +use crate::NoiseChannelPublicKey; + +/// Request body for registering an executor with the environment registry. +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +pub struct EnvironmentRegistryRegistrationRequest { + pub security_profile: String, + pub executor_public_key: NoiseChannelPublicKey, +} + +/// Environment registry response returned after executor registration. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct EnvironmentRegistryRegistrationResponse { + pub environment_id: String, + pub url: String, + pub security_profile: String, + pub executor_registration_id: String, +} + +/// Request body for authorizing a harness key with the environment registry. +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +pub struct EnvironmentRegistryHarnessKeyValidationRequest { + pub executor_registration_id: String, + pub harness_public_key: NoiseChannelPublicKey, + pub harness_key_authorization: String, +} + +/// Environment registry response returned after harness key validation. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct EnvironmentRegistryHarnessKeyValidationResponse { + pub valid: bool, +} diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index d6afe80e1e..df71eda95a 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -4,6 +4,7 @@ mod client_transport; mod connection; mod environment; mod environment_provider; +mod environment_registry; mod environment_toml; mod file_read; mod fs_helper; @@ -56,6 +57,10 @@ pub use environment::REMOTE_ENVIRONMENT_ID; pub use environment_provider::DefaultEnvironmentProvider; pub use environment_provider::EnvironmentProvider; pub use environment_provider::EnvironmentProviderFuture; +pub use environment_registry::EnvironmentRegistryHarnessKeyValidationRequest; +pub use environment_registry::EnvironmentRegistryHarnessKeyValidationResponse; +pub use environment_registry::EnvironmentRegistryRegistrationRequest; +pub use environment_registry::EnvironmentRegistryRegistrationResponse; pub use fs_helper::CODEX_FS_HELPER_ARG1; pub use fs_helper_main::main as run_fs_helper_main; pub use local_file_system::LOCAL_FS; diff --git a/codex-rs/exec-server/src/remote.rs b/codex-rs/exec-server/src/remote.rs index 7a470f3d95..ace1578da8 100644 --- a/codex-rs/exec-server/src/remote.rs +++ b/codex-rs/exec-server/src/remote.rs @@ -3,7 +3,6 @@ use std::time::Duration; use codex_api::SharedAuthProvider; use reqwest::StatusCode; use serde::Deserialize; -use serde::Serialize; use tokio::time::sleep; use tokio_tungstenite::connect_async_with_config; use tracing::debug; @@ -12,6 +11,10 @@ use tracing::warn; use codex_utils_rustls_provider::ensure_rustls_crypto_provider; +use crate::EnvironmentRegistryHarnessKeyValidationRequest; +use crate::EnvironmentRegistryHarnessKeyValidationResponse; +use crate::EnvironmentRegistryRegistrationRequest; +use crate::EnvironmentRegistryRegistrationResponse; use crate::ExecServerError; use crate::ExecServerRuntimePaths; use crate::NoiseChannelIdentity; @@ -67,8 +70,8 @@ impl EnvironmentRegistryClient { )) .headers(self.auth_provider.to_auth_headers()) .json(&EnvironmentRegistryRegistrationRequest { - security_profile: NOISE_RELAY_SECURITY_PROFILE, - executor_public_key, + security_profile: NOISE_RELAY_SECURITY_PROFILE.to_string(), + executor_public_key: executor_public_key.clone(), }) .send() .await?; @@ -120,32 +123,6 @@ impl EnvironmentRegistryClient { } } -#[derive(Serialize)] -struct EnvironmentRegistryRegistrationRequest<'a> { - security_profile: &'static str, - executor_public_key: &'a NoiseChannelPublicKey, -} - -#[derive(Debug, Clone, Eq, PartialEq, Deserialize)] -struct EnvironmentRegistryRegistrationResponse { - environment_id: String, - url: String, - security_profile: String, - executor_registration_id: String, -} - -#[derive(Serialize)] -struct EnvironmentRegistryHarnessKeyValidationRequest<'a> { - executor_registration_id: &'a str, - harness_public_key: &'a NoiseChannelPublicKey, - harness_key_authorization: &'a str, -} - -#[derive(Deserialize)] -struct EnvironmentRegistryHarnessKeyValidationResponse { - valid: bool, -} - #[derive(Clone)] struct RegistryHarnessKeyValidator { client: EnvironmentRegistryClient, @@ -172,9 +149,9 @@ impl HarnessKeyValidator for RegistryHarnessKeyValidator { )) .headers(self.client.auth_provider.to_auth_headers()) .json(&EnvironmentRegistryHarnessKeyValidationRequest { - executor_registration_id: &self.executor_registration_id, - harness_public_key, - harness_key_authorization: authorization, + executor_registration_id: self.executor_registration_id.clone(), + harness_public_key: harness_public_key.clone(), + harness_key_authorization: authorization.to_string(), }) .send() .await?; From a760b63f838db94369f91b829a366c76f4761107 Mon Sep 17 00:00:00 2001 From: Casey Chow Date: Wed, 17 Jun 2026 17:06:42 -0400 Subject: [PATCH 008/112] fix(plugins): support root local marketplace plugins (#28771) ## Summary - allow local marketplace `source.path: "."` and `source.path: "./"` to resolve to the marketplace root - keep `""` invalid and preserve rejection of non-root paths without `./` plus non-normal/traversal paths - add focused regression coverage for repo-root plugin layouts and rejected local paths ## Tests - `RUSTUP_TOOLCHAIN=stable just fmt` - `RUSTUP_TOOLCHAIN=stable just test -p codex-core-plugins` - `RUSTUP_TOOLCHAIN=stable just fix -p codex-core-plugins` Note: plain pinned-toolchain `just fmt` was blocked locally by a rustup `clippy` component conflict, so validation used the working stable 1.95 toolchain fallback. --- codex-rs/core-plugins/src/marketplace.rs | 22 ++- .../core-plugins/src/marketplace_tests.rs | 138 +++++++++++++++--- 2 files changed, 128 insertions(+), 32 deletions(-) diff --git a/codex-rs/core-plugins/src/marketplace.rs b/codex-rs/core-plugins/src/marketplace.rs index 228495c48b..ce4fcd2ffc 100644 --- a/codex-rs/core-plugins/src/marketplace.rs +++ b/codex-rs/core-plugins/src/marketplace.rs @@ -544,20 +544,26 @@ fn resolve_local_plugin_source_path( marketplace_path: &AbsolutePathBuf, path: &str, ) -> Result { - let Some(path) = path.strip_prefix("./") else { + match path { + "" => { + return Err(MarketplaceError::InvalidMarketplaceFile { + path: marketplace_path.to_path_buf(), + message: "local plugin source path must not be empty".to_string(), + }); + } + "." | "./" => return marketplace_root_dir(marketplace_path), + _ => {} + } + + // Non-root local sources must keep the explicit `./` prefix and remain normalized. + let Some(relative_path) = path.strip_prefix("./") else { return Err(MarketplaceError::InvalidMarketplaceFile { path: marketplace_path.to_path_buf(), message: "local plugin source path must start with `./`".to_string(), }); }; - if path.is_empty() { - return Err(MarketplaceError::InvalidMarketplaceFile { - path: marketplace_path.to_path_buf(), - message: "local plugin source path must not be empty".to_string(), - }); - } - let relative_source_path = Path::new(path); + let relative_source_path = Path::new(relative_path); if relative_source_path .components() .any(|component| !matches!(component, Component::Normal(_))) diff --git a/codex-rs/core-plugins/src/marketplace_tests.rs b/codex-rs/core-plugins/src/marketplace_tests.rs index 47c88b73aa..61385f1db0 100644 --- a/codex-rs/core-plugins/src/marketplace_tests.rs +++ b/codex-rs/core-plugins/src/marketplace_tests.rs @@ -420,6 +420,92 @@ fn list_marketplaces_supports_alternate_manifest_layout() { ); } +#[test] +fn list_marketplaces_supports_repo_root_local_plugin_sources() { + for path in [".", "./"] { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(repo_root.join(".codex-plugin")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + format!( + r#"{{ + "name": "repo-root-marketplace", + "plugins": [ + {{ + "name": "repo-root-plugin", + "source": {{ + "source": "local", + "path": "{path}" + }} + }} + ] +}}"# + ), + ) + .unwrap(); + fs::write( + repo_root.join(".codex-plugin/plugin.json"), + r#"{ + "name":"repo-root-plugin", + "interface": { + "displayName": "Repo Root Plugin" + } +}"#, + ) + .unwrap(); + + let marketplaces = list_marketplaces_with_home( + &[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()], + /*home_dir*/ None, + ) + .unwrap() + .marketplaces; + + assert_eq!( + marketplaces, + vec![Marketplace { + name: "repo-root-marketplace".to_string(), + path: AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")) + .unwrap(), + interface: None, + plugins: vec![MarketplacePlugin { + name: "repo-root-plugin".to_string(), + local_version: None, + source: MarketplacePluginSource::Local { + path: AbsolutePathBuf::try_from(repo_root).unwrap(), + }, + policy: MarketplacePluginPolicy { + installation: MarketplacePluginInstallPolicy::Available, + authentication: MarketplacePluginAuthPolicy::OnInstall, + products: None, + }, + interface: Some(PluginManifestInterface { + display_name: Some("Repo Root Plugin".to_string()), + short_description: None, + long_description: None, + developer_name: None, + category: None, + capabilities: Vec::new(), + website_url: None, + privacy_policy_url: None, + terms_of_service_url: None, + default_prompt: None, + brand_color: None, + composer_icon: None, + logo: None, + screenshots: Vec::new(), + }), + keywords: Vec::new(), + }], + }] + ); + } +} + #[test] fn list_marketplaces_includes_plugins_without_discoverable_manifest() { let tmp = tempdir().unwrap(); @@ -1423,37 +1509,41 @@ fn list_marketplaces_ignores_plugin_interface_assets_without_dot_slash() { #[test] fn find_marketplace_plugin_skips_invalid_local_paths() { - let tmp = tempdir().unwrap(); - let repo_root = tmp.path().join("repo"); - fs::create_dir_all(repo_root.join(".git")).unwrap(); - fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); - fs::write( - repo_root.join(".agents/plugins/marketplace.json"), - r#"{ + for path in ["", "plugin-1", "././", "./plugins/../", "../plugin-1"] { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + format!( + r#"{{ "name": "codex-curated", "plugins": [ - { + {{ "name": "local-plugin", - "source": { + "source": {{ "source": "local", - "path": "../plugin-1" - } - } + "path": "{path}" + }} + }} ] -}"#, - ) - .unwrap(); +}}"# + ), + ) + .unwrap(); - let err = find_marketplace_plugin( - &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), - "local-plugin", - ) - .unwrap_err(); + let err = find_marketplace_plugin( + &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), + "local-plugin", + ) + .unwrap_err(); - assert_eq!( - err.to_string(), - "plugin `local-plugin` was not found in marketplace `codex-curated`" - ); + assert_eq!( + err.to_string(), + "plugin `local-plugin` was not found in marketplace `codex-curated`" + ); + } } #[test] From 243243ab8f0146cfc59c22dbf00cb6f7a34da536 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Wed, 17 Jun 2026 14:48:17 -0700 Subject: [PATCH 009/112] bazel: refresh expired macOS SDK pin (#28791) ## Why macOS Bazel jobs fail before target analysis because the pinned Apple CDN object now returns HTTP 403. ## What Uprev the pin to Apple's currently live macOS 26.5 Command Line Tools package, including its checksum and SDK extraction path. ## Validation - Built `@macos_sdk//sysroot` from a fresh Bazel output root. - Regenerated and checked `MODULE.bazel.lock`; it remains unchanged. --- MODULE.bazel | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/MODULE.bazel b/MODULE.bazel index 98619420aa..e9ec662d09 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -31,11 +31,11 @@ register_toolchains("@llvm//toolchain:all") osx = use_extension("@llvm//extensions:osx.bzl", "osx") osx.from_archive( - sha256 = "1bde70c0b1c2ab89ff454acbebf6741390d7b7eb149ca2a3ca24cc9203a408b7", - strip_prefix = "Payload/Library/Developer/CommandLineTools/SDKs/MacOSX26.4.sdk", + sha256 = "5f044578cd78a3a9b9c965a42d56bad609ee5d252e1d4e6aa7c42fc3f35fee7b", + strip_prefix = "Payload/Library/Developer/CommandLineTools/SDKs/MacOSX26.5.sdk", type = "pkg", urls = [ - "https://swcdn.apple.com/content/downloads/32/53/047-96692-A_OAHIHT53YB/ybtshxmrcju8m2qvw3w5elr4rajtg1x3y3/CLTools_macOSNMOS_SDK.pkg", + "https://swcdn.apple.com/content/downloads/09/08/047-91568-A_Y1CFZWQCD4/4xekpyz43i26dbp4enxfro8eb1q7wiujh5/CLTools_macOSNMOS_SDK.pkg", ], ) osx.frameworks(names = [ From 38211a3ff8e99a908257f34eb1fe9cd802fce501 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Wed, 17 Jun 2026 14:53:54 -0700 Subject: [PATCH 010/112] [codex] trace tools build latency (#28782) Add more tracing spans around tool building. --- codex-rs/core-plugins/src/manager.rs | 9 +++++++ codex-rs/core-plugins/src/remote.rs | 2 ++ codex-rs/core/src/connectors.rs | 1 + .../core/src/tools/handlers/tool_search.rs | 7 ++++++ codex-rs/core/src/tools/registry.rs | 2 ++ codex-rs/core/src/tools/router.rs | 1 + codex-rs/core/src/tools/spec_plan.rs | 24 +++++++++++++++++++ codex-rs/login/src/auth/manager.rs | 2 ++ 8 files changed, 48 insertions(+) diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 88b6cd3bb1..fb004f4943 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -942,6 +942,14 @@ impl PluginsManager { Ok(featured_plugin_ids) } + #[instrument( + level = "trace", + skip_all, + fields( + plugins_enabled = config.plugins_enabled, + remote_plugin_enabled = config.remote_plugin_enabled + ) + )] pub async fn recommended_plugins_mode_for_config( &self, config: &PluginsConfigInput, @@ -1014,6 +1022,7 @@ impl PluginsManager { /// Returns endpoint recommendations eligible for installation in the current client. /// `None` selects the legacy discovery workflow. + #[instrument(level = "trace", skip_all)] pub async fn recommended_plugin_candidates_for_config( &self, input: RecommendedPluginCandidatesInput<'_>, diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 1f8d3f7201..a0007aa1df 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -27,6 +27,7 @@ use std::fs; use std::path::Path; use std::path::PathBuf; use std::time::Duration; +use tracing::instrument; use url::Url; mod catalog_cache; @@ -805,6 +806,7 @@ pub async fn fetch_and_cache_global_remote_plugin_catalog( Ok(()) } +#[instrument(level = "trace", skip_all)] pub async fn fetch_recommended_plugins( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index f1c8c5607b..496f14e86a 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -94,6 +94,7 @@ pub(crate) async fn list_accessible_and_enabled_connectors_from_manager( .collect() } +#[instrument(level = "trace", skip_all)] pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( config: &Config, plugins_manager: &PluginsManager, diff --git a/codex-rs/core/src/tools/handlers/tool_search.rs b/codex-rs/core/src/tools/handlers/tool_search.rs index 5c201388b6..272da2f226 100644 --- a/codex-rs/core/src/tools/handlers/tool_search.rs +++ b/codex-rs/core/src/tools/handlers/tool_search.rs @@ -20,6 +20,7 @@ use codex_tools::ToolSpec; use codex_tools::coalesce_loadable_tool_specs; use std::sync::Arc; use std::sync::Mutex; +use tracing::instrument; pub struct ToolSearchHandler { search_infos: Vec, @@ -33,6 +34,7 @@ pub(crate) struct ToolSearchHandlerCache { } impl ToolSearchHandlerCache { + #[instrument(level = "trace", skip_all, fields(search_info_count = search_infos.len()))] pub(crate) fn get_or_build(&self, search_infos: Vec) -> Arc { { let cached = self.cached(); @@ -64,6 +66,11 @@ impl ToolSearchHandlerCache { } impl ToolSearchHandler { + #[instrument( + level = "trace", + skip_all, + fields(search_info_count = search_infos.len()) + )] pub(crate) fn new(search_infos: Vec) -> Self { let search_source_infos = search_infos .iter() diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 23294e6944..2220d4244a 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -34,6 +34,7 @@ use codex_tools::ToolSearchInfo; use codex_tools::ToolSpec; use futures::future::BoxFuture; use serde_json::Value; +use tracing::instrument; pub(crate) type ToolTelemetryTags = Vec<(&'static str, String)>; @@ -327,6 +328,7 @@ impl ToolRegistry { Self { tools } } + #[instrument(level = "trace", skip_all)] pub(crate) fn from_tools(tools: impl IntoIterator>) -> Self { let mut tools_by_name = HashMap::new(); for tool in tools { diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index 0acd6e37c2..b488448398 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -240,6 +240,7 @@ impl ToolRouter { } } +#[instrument(level = "trace", skip_all)] pub(crate) fn extension_tool_executors( session: &Session, ) -> Vec>> { diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 7680e5d4ff..c697f287aa 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -256,6 +256,7 @@ fn spec_for_model_request( } } +#[instrument(level = "trace", skip_all)] fn hosted_model_tool_specs(context: &CoreToolPlanContext<'_>) -> Vec { let turn_context = context.turn_context; // Responses Lite accepts schemas for client-executed tools, not hosted Responses tools. @@ -500,6 +501,7 @@ fn build_code_mode_executors( ] } +#[instrument(level = "trace", skip_all, fields(tool_spec_count = specs.len()))] fn merge_into_namespaces(specs: Vec) -> Vec { let mut merged_specs = Vec::with_capacity(specs.len()); let mut namespace_indices = BTreeMap::::new(); @@ -592,6 +594,7 @@ fn standalone_web_search_enabled(turn_context: &TurnContext) -> bool { .enabled(Feature::StandaloneWebSearch)) } +#[instrument(level = "trace", skip_all)] fn add_shell_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { let turn_context = context.turn_context; let features = turn_context.config.features.get(); @@ -643,6 +646,7 @@ fn unified_exec_should_include_shell_parameter(turn_context: &TurnContext) -> bo .any(|environment| environment.environment.is_remote()) } +#[instrument(level = "trace", skip_all)] fn add_mcp_resource_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { if context.mcp_tools.is_some() { planned_tools.add(ListMcpResourcesHandler); @@ -651,6 +655,7 @@ fn add_mcp_resource_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut } } +#[instrument(level = "trace", skip_all)] fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { let turn_context = context.turn_context; let features = turn_context.config.features.get(); @@ -722,6 +727,7 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut } } +#[instrument(level = "trace", skip_all)] fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { let turn_context = context.turn_context; if collab_tools_enabled(turn_context) { @@ -810,6 +816,14 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu } } +#[instrument( + level = "trace", + skip_all, + fields( + direct_mcp_tool_count = context.mcp_tools.map_or(0, <[ToolInfo]>::len), + deferred_mcp_tool_count = context.deferred_mcp_tools.map_or(0, <[ToolInfo]>::len) + ) +)] fn add_mcp_runtime_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { if let Some(mcp_tools) = context.mcp_tools { for tool in mcp_tools { @@ -836,6 +850,11 @@ fn add_mcp_runtime_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut } } +#[instrument( + level = "trace", + skip_all, + fields(dynamic_tool_count = context.dynamic_tools.len()) +)] fn add_dynamic_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { for spec in context.dynamic_tools { match spec { @@ -868,6 +887,11 @@ fn add_dynamic_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut Plan } } +#[instrument( + level = "trace", + skip_all, + fields(extension_tool_executor_count = context.extension_tool_executors.len()) +)] fn add_extension_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { // Extension ToolContributor implementations are resolved into executors // before planning. Core only adapts those executors into its runtime set. diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index f4ed2a9369..bb548ffe78 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -17,6 +17,7 @@ use std::sync::atomic::AtomicU64; use std::sync::atomic::Ordering; use tokio::sync::Semaphore; use tokio::sync::watch; +use tracing::instrument; use codex_agent_identity::ChatGptEnvironment; use codex_agent_identity::decode_agent_identity_jwt; @@ -1719,6 +1720,7 @@ impl AuthManager { /// Current cached auth (clone). May be `None` if not logged in or load failed. /// For managed ChatGPT auth that needs a proactive refresh, first performs /// a guarded reload and then refreshes only if the on-disk auth is unchanged. + #[instrument(level = "trace", skip_all)] pub async fn auth(&self) -> Option { if let Some(auth) = self.resolve_external_api_key_auth().await { return Some(auth); From e7b6e0d8591870130018eff5d3fe518304145053 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Wed, 17 Jun 2026 15:17:07 -0700 Subject: [PATCH 011/112] path-uri: decouple native path parsing (#28778) ## Why `PathUri::join` should not depend on the app-server compatibility wrapper `LegacyAppPathString` to parse native paths. Native path parsing belongs to the URI abstraction that it constructs. ## What Move platform-independent native path parsing into the root `PathUri` module. `PathUri::join` and `LegacyAppPathString` now share the crate-private `PathUri::from_absolute_native_path` constructor. --- .../utils/path-uri/src/api_path_string.rs | 135 +----------------- codex-rs/utils/path-uri/src/lib.rs | 130 ++++++++++++++++- 2 files changed, 135 insertions(+), 130 deletions(-) diff --git a/codex-rs/utils/path-uri/src/api_path_string.rs b/codex-rs/utils/path-uri/src/api_path_string.rs index 0881c64d7c..5fc204b178 100644 --- a/codex-rs/utils/path-uri/src/api_path_string.rs +++ b/codex-rs/utils/path-uri/src/api_path_string.rs @@ -1,4 +1,6 @@ +use crate::PathConvention; use crate::PathUri; +use crate::is_windows_separator_byte; use codex_utils_absolute_path::AbsolutePathBuf; use schemars::JsonSchema; use serde::Deserialize; @@ -33,10 +35,6 @@ use ts_rs::TS; pub struct LegacyAppPathString(String); impl LegacyAppPathString { - pub(super) fn from_str(path: &str) -> Self { - Self(path.to_string()) - } - /// Renders an absolute path using the current host's path convention. pub fn from_abs_path(path: &AbsolutePathBuf) -> Self { Self(path.to_string_lossy().into_owned()) @@ -69,13 +67,11 @@ impl LegacyAppPathString { &self, convention: PathConvention, ) -> Result { - let path = match convention { - PathConvention::Posix => parse_posix_path(&self.0), - PathConvention::Windows => parse_windows_path(&self.0), - }; - path.ok_or_else(|| LegacyAppPathStringError::InvalidNativePath { - path: self.0.clone(), - convention, + PathUri::from_absolute_native_path(&self.0, convention).ok_or_else(|| { + LegacyAppPathStringError::InvalidNativePath { + path: self.0.clone(), + convention, + } }) } @@ -115,88 +111,6 @@ impl From for LegacyAppPathString { } } -fn parse_posix_path(path: &str) -> Option { - let path = path.strip_prefix('/')?; - if path.contains('\0') { - return Some(PathUri::from_opaque_path_bytes( - format!("/{path}").as_bytes(), - )); - } - path_uri_from_segments(/*host*/ None, path.split('/')) -} - -fn parse_windows_path(path: &str) -> Option { - let bytes = path.as_bytes(); - let uses_namespace = matches!( - bytes, - [first, second, namespace @ (b'.' | b'?'), separator, ..] - if is_windows_separator_byte(*first) - && is_windows_separator_byte(*second) - && is_windows_separator_byte(*separator) - && matches!(*namespace, b'.' | b'?') - ); - if uses_namespace || path.contains('\0') { - return Some(windows_opaque_path_uri(path)); - } - - if matches!( - bytes, - [drive, b':', separator, ..] - if drive.is_ascii_alphabetic() && is_windows_separator_byte(*separator) - ) { - return path_uri_from_segments( - /*host*/ None, - std::iter::once(&path[..2]).chain(path[3..].split(is_windows_separator_char)), - ); - } - - if matches!(bytes, [first, second, ..] - if is_windows_separator_byte(*first) && is_windows_separator_byte(*second)) - { - let mut components = path[2..].split(is_windows_separator_char); - let host = components.next().filter(|host| !host.is_empty())?; - let share = components.next().filter(|share| !share.is_empty())?; - return path_uri_from_segments(Some(host), std::iter::once(share).chain(components)) - .or_else(|| Some(windows_opaque_path_uri(path))); - } - - None -} - -fn path_uri_from_segments<'a>( - host: Option<&str>, - segments: impl Iterator, -) -> Option { - let mut url = url::Url::parse("file:///").ok()?; - if let Some(host) = host { - url.set_host(Some(host)).ok()?; - } - { - let mut url_segments = url.path_segments_mut().ok()?; - url_segments.clear(); - for segment in segments { - url_segments.push(segment); - } - } - PathUri::try_from(url).ok() -} - -fn windows_opaque_path_uri(path: &str) -> PathUri { - let path_bytes = path - .encode_utf16() - .flat_map(u16::to_le_bytes) - .collect::>(); - PathUri::from_opaque_path_bytes(&path_bytes) -} - -fn is_windows_separator_char(character: char) -> bool { - matches!(character, '\\' | '/') -} - -fn is_windows_separator_byte(character: u8) -> bool { - matches!(character, b'\\' | b'/') -} - fn render_opaque_fallback( path: &PathUri, path_bytes: &[u8], @@ -372,41 +286,6 @@ pub enum LegacyAppPathStringError { }, } -/// Path syntax used to render a [`PathUri`] as an operating-system path. -/// -/// This describes path grammar rather than a specific operating system because -/// Linux and macOS share the POSIX representation relevant here. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename_all = "snake_case")] -pub enum PathConvention { - Posix, - Windows, -} - -impl PathConvention { - /// Returns the path convention used by the current process. - #[cfg(windows)] - pub const fn native() -> Self { - Self::Windows - } - - /// Returns the path convention used by the current process. - #[cfg(unix)] - pub const fn native() -> Self { - Self::Posix - } -} - -impl fmt::Display for PathConvention { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Posix => f.write_str("POSIX"), - Self::Windows => f.write_str("Windows"), - } - } -} - #[cfg(test)] #[path = "api_path_string_tests.rs"] mod tests; diff --git a/codex-rs/utils/path-uri/src/lib.rs b/codex-rs/utils/path-uri/src/lib.rs index a7f3c83369..5e8b5b2cbc 100644 --- a/codex-rs/utils/path-uri/src/lib.rs +++ b/codex-rs/utils/path-uri/src/lib.rs @@ -21,7 +21,6 @@ mod api_path_string; pub use api_path_string::LegacyAppPathString; pub use api_path_string::LegacyAppPathStringError; -pub use api_path_string::PathConvention; pub const FILE_SCHEME: &str = "file"; const BAD_PATH_URI_PREFIX: &str = "file:///%00/bad/path/"; @@ -97,6 +96,16 @@ impl PathUri { Self::from_opaque_path_bytes(&path_bytes) } + pub(crate) fn from_absolute_native_path( + path: &str, + convention: PathConvention, + ) -> Option { + match convention { + PathConvention::Posix => parse_posix_path(path), + PathConvention::Windows => parse_windows_path(path), + } + } + fn from_opaque_path_bytes(path_bytes: &[u8]) -> Self { let encoded_path = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(path_bytes); let Ok(uri) = Self::parse(&format!("{BAD_PATH_URI_PREFIX}{encoded_path}")) else { @@ -240,7 +249,7 @@ impl PathUri { })?; // An absolute native path is already fully resolved, so replace the base URI's main path // instead of appending it. - if let Ok(absolute) = LegacyAppPathString::from_str(path).to_path_uri(convention) { + if let Some(absolute) = Self::from_absolute_native_path(path, convention) { return Ok(absolute); } let path_bytes = path.as_bytes(); @@ -521,6 +530,88 @@ fn infer_opaque_path_convention(path_bytes: &[u8]) -> Option { (has_drive || has_unc_prefix).then_some(PathConvention::Windows) } +fn parse_posix_path(path: &str) -> Option { + let path = path.strip_prefix('/')?; + if path.contains('\0') { + return Some(PathUri::from_opaque_path_bytes( + format!("/{path}").as_bytes(), + )); + } + path_uri_from_segments(/*host*/ None, path.split('/')) +} + +fn parse_windows_path(path: &str) -> Option { + let bytes = path.as_bytes(); + let uses_namespace = matches!( + bytes, + [first, second, namespace @ (b'.' | b'?'), separator, ..] + if is_windows_separator_byte(*first) + && is_windows_separator_byte(*second) + && is_windows_separator_byte(*separator) + && matches!(*namespace, b'.' | b'?') + ); + if uses_namespace || path.contains('\0') { + return Some(windows_opaque_path_uri(path)); + } + + if matches!( + bytes, + [drive, b':', separator, ..] + if drive.is_ascii_alphabetic() && is_windows_separator_byte(*separator) + ) { + return path_uri_from_segments( + /*host*/ None, + std::iter::once(&path[..2]).chain(path[3..].split(is_windows_separator_char)), + ); + } + + if matches!(bytes, [first, second, ..] + if is_windows_separator_byte(*first) && is_windows_separator_byte(*second)) + { + let mut components = path[2..].split(is_windows_separator_char); + let host = components.next().filter(|host| !host.is_empty())?; + let share = components.next().filter(|share| !share.is_empty())?; + return path_uri_from_segments(Some(host), std::iter::once(share).chain(components)) + .or_else(|| Some(windows_opaque_path_uri(path))); + } + + None +} + +fn path_uri_from_segments<'a>( + host: Option<&str>, + segments: impl Iterator, +) -> Option { + let mut url = Url::parse("file:///").ok()?; + if let Some(host) = host { + url.set_host(Some(host)).ok()?; + } + { + let mut url_segments = url.path_segments_mut().ok()?; + url_segments.clear(); + for segment in segments { + url_segments.push(segment); + } + } + PathUri::try_from(url).ok() +} + +fn windows_opaque_path_uri(path: &str) -> PathUri { + let path_bytes = path + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect::>(); + PathUri::from_opaque_path_bytes(&path_bytes) +} + +fn is_windows_separator_char(character: char) -> bool { + matches!(character, '\\' | '/') +} + +pub(crate) fn is_windows_separator_byte(character: u8) -> bool { + matches!(character, b'\\' | b'/') +} + /// Rejects URI metadata that has no defined meaning for `file:` URIs. fn validate_common_known_uri(url: &Url) -> Result<(), PathUriParseError> { if !url.username().is_empty() || url.password().is_some() { @@ -573,6 +664,41 @@ pub enum PathUriParseError { JoinPathMustBeRelative(String), } +/// Path syntax used to render a [`PathUri`] as an operating-system path. +/// +/// This describes path grammar rather than a specific operating system because +/// Linux and macOS share the POSIX representation relevant here. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum PathConvention { + Posix, + Windows, +} + +impl PathConvention { + /// Returns the path convention used by the current process. + #[cfg(windows)] + pub const fn native() -> Self { + Self::Windows + } + + /// Returns the path convention used by the current process. + #[cfg(unix)] + pub const fn native() -> Self { + Self::Posix + } +} + +impl fmt::Display for PathConvention { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Posix => f.write_str("POSIX"), + Self::Windows => f.write_str("Windows"), + } + } +} + #[cfg(test)] #[path = "tests.rs"] mod tests; From c274a83f8bb0f9b5520a3f1d475060bebbe3b728 Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Wed, 17 Jun 2026 17:20:53 -0700 Subject: [PATCH 012/112] feat(exec-server): add Noise rendezvous environment (#28774) ## Why Codex can run a remote exec server through the Noise relay, but the normal environment-manager path could not establish an environment-registry-backed harness connection. Signed rendezvous URLs and harness authorizations are short-lived, so reconnects must fetch a fresh bundle instead of retaining stale connection credentials. A stalled registry request must also fail within the regular remote connection deadline, without exposing these credentials in debug logs. Issue: N/A (internal environment-service integration). ## What Changed - Add environment-manager configuration for a registry-backed Noise rendezvous environment. - Request a fresh bundle from `/cloud/environment/{environment_id}/connect` for every physical harness connection, using the existing 10-second remote connection timeout. - Share the Environment Registry register, connect, and validate wire payloads through `codex-exec-server` and `codex-core-api`. - Redact the signed rendezvous URL and harness authorization from the public connect response's `Debug` output. - Add focused coverage for registry bundle retrieval, stalled requests, and credential redaction. --- codex-rs/core-api/src/lib.rs | 2 + codex-rs/exec-server/Cargo.toml | 1 + codex-rs/exec-server/src/environment.rs | 108 +++++++ .../exec-server/src/environment_registry.rs | 34 +++ .../src/environment_registry_tests.rs | 22 ++ codex-rs/exec-server/src/lib.rs | 2 + codex-rs/exec-server/src/remote.rs | 264 ++++++++++++++++++ 7 files changed, 433 insertions(+) create mode 100644 codex-rs/exec-server/src/environment_registry_tests.rs diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index 5a64a2b85e..de3a7de905 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -48,6 +48,8 @@ pub use codex_core::resolve_installation_id; pub use codex_core::skills::SkillsService; pub use codex_core::thread_store_from_config; pub use codex_exec_server::EnvironmentManager; +pub use codex_exec_server::EnvironmentRegistryConnectRequest; +pub use codex_exec_server::EnvironmentRegistryConnectResponse; pub use codex_exec_server::EnvironmentRegistryHarnessKeyValidationRequest; pub use codex_exec_server::EnvironmentRegistryHarnessKeyValidationResponse; pub use codex_exec_server::EnvironmentRegistryRegistrationRequest; diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index 426567976c..31fc4ea0aa 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -28,6 +28,7 @@ codex-utils-path-uri = { workspace = true } codex-utils-pty = { workspace = true } codex-utils-rustls-provider = { workspace = true } futures = { workspace = true } +http = { workspace = true } reqwest = { workspace = true, features = ["json", "rustls-tls", "stream"] } prost = "0.14.3" serde = { workspace = true, features = ["derive"] } diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 0c703ecef3..6159fb2dd2 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -25,11 +25,19 @@ use crate::local_process::LocalProcess; use crate::process::ExecBackend; use crate::protocol::EnvironmentInfo; use crate::protocol::ShellInfo; +use crate::remote::NoiseRendezvousEnvironmentConfig; use crate::remote_file_system::RemoteFileSystem; use crate::remote_process::RemoteProcess; use codex_shell_command::shell_detect::DetectedShell; pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL"; +pub const CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR: &str = + "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL"; +pub const CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR: &str = + "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID"; +pub const CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR: &str = "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN"; +pub const CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID_ENV_VAR: &str = + "CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID"; /// Owns the execution/filesystem environments available to the Codex runtime. /// @@ -98,6 +106,9 @@ impl EnvironmentManager { codex_home: impl AsRef, local_runtime_paths: Option, ) -> Result { + if let Some(config) = noise_environment_config_from_env()? { + return Self::from_noise_environment_config(config, local_runtime_paths); + } let provider = environment_provider_from_codex_home(codex_home.as_ref())?; Self::from_snapshot(provider.snapshot().await?, local_runtime_paths) } @@ -107,6 +118,9 @@ impl EnvironmentManager { pub async fn from_env( local_runtime_paths: Option, ) -> Result { + if let Some(config) = noise_environment_config_from_env()? { + return Self::from_noise_environment_config(config, local_runtime_paths); + } let provider = DefaultEnvironmentProvider::from_env(); Self::from_snapshot(provider.snapshot().await?, local_runtime_paths) } @@ -122,6 +136,23 @@ impl EnvironmentManager { } } + fn from_noise_environment_config( + config: NoiseRendezvousEnvironmentConfig, + local_runtime_paths: Option, + ) -> Result { + let manager = Self { + default_environment: Some(REMOTE_ENVIRONMENT_ID.to_string()), + environments: RwLock::new(HashMap::new()), + local_environment: None, + local_runtime_paths, + }; + manager.upsert_noise_environment( + REMOTE_ENVIRONMENT_ID.to_string(), + config.connect_provider(), + )?; + Ok(manager) + } + /// Builds a test-only manager that keeps the provider default while also /// allowing tests to select the local environment explicitly. pub async fn create_for_tests_with_local( @@ -317,6 +348,53 @@ impl EnvironmentManager { } } +fn noise_environment_config_from_env() +-> Result, ExecServerError> { + noise_environment_config_from_values( + optional_environment_value(CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR), + optional_environment_value(CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR), + optional_environment_value(CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR), + optional_environment_value(CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID_ENV_VAR), + ) +} + +fn noise_environment_config_from_values( + registry_url: Option, + environment_id: Option, + auth_token: Option, + chatgpt_account_id: Option, +) -> Result, ExecServerError> { + let (registry_url, environment_id, auth_token) = + match (registry_url, environment_id, auth_token) { + (None, None, None) => return Ok(None), + (Some(registry_url), Some(environment_id), Some(auth_token)) => { + (registry_url, environment_id, auth_token) + } + _ => { + return Err(ExecServerError::EnvironmentRegistryConfig(format!( + "Noise environment requires {CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR}, \ +{CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR}, and \ +{CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR}" + ))); + } + }; + + let config = NoiseRendezvousEnvironmentConfig::new( + registry_url, + environment_id, + auth_token, + chatgpt_account_id, + )?; + Ok(Some(config)) +} + +fn optional_environment_value(name: &str) -> Option { + std::env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + /// Concrete execution/filesystem environment selected for a session. /// /// This bundles the selected backend metadata together with the local runtime @@ -528,6 +606,7 @@ mod tests { use super::EnvironmentManager; use super::LOCAL_ENVIRONMENT_ID; use super::REMOTE_ENVIRONMENT_ID; + use super::noise_environment_config_from_values; use crate::ExecServerRuntimePaths; use crate::ProcessId; use crate::environment_provider::EnvironmentDefault; @@ -547,6 +626,35 @@ mod tests { assert!(manager.try_local_environment().is_none()); } + #[test] + fn noise_environment_config_selects_remote_as_default() { + let config = noise_environment_config_from_values( + Some("http://registry.example/api".to_string()), + Some("environment-requested".to_string()), + Some("registry-token".to_string()), + Some("workspace-123".to_string()), + ) + .expect("parse noise environment configuration") + .expect("noise environment configuration"); + + let manager = EnvironmentManager::from_noise_environment_config( + config, /*local_runtime_paths*/ None, + ) + .expect("build environment manager"); + + assert_eq!( + manager.default_environment_id(), + Some(REMOTE_ENVIRONMENT_ID) + ); + assert!( + manager + .default_environment() + .expect("remote environment") + .is_remote() + ); + assert_local_environment_unavailable(&manager); + } + #[tokio::test] async fn create_local_environment_does_not_connect() { let environment = Environment::create(/*exec_server_url*/ None, test_runtime_paths()) diff --git a/codex-rs/exec-server/src/environment_registry.rs b/codex-rs/exec-server/src/environment_registry.rs index 09ac5415f5..bc0755fd68 100644 --- a/codex-rs/exec-server/src/environment_registry.rs +++ b/codex-rs/exec-server/src/environment_registry.rs @@ -19,6 +19,36 @@ pub struct EnvironmentRegistryRegistrationResponse { pub executor_registration_id: String, } +/// Request body for connecting a harness key with the environment registry. +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +pub struct EnvironmentRegistryConnectRequest { + pub harness_public_key: NoiseChannelPublicKey, +} + +/// Environment registry response returned after connecting a harness key. +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +pub struct EnvironmentRegistryConnectResponse { + pub environment_id: String, + pub url: String, + pub security_profile: String, + pub executor_registration_id: String, + pub executor_public_key: NoiseChannelPublicKey, + pub harness_key_authorization: String, +} + +impl std::fmt::Debug for EnvironmentRegistryConnectResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EnvironmentRegistryConnectResponse") + .field("environment_id", &self.environment_id) + .field("url", &"") + .field("security_profile", &self.security_profile) + .field("executor_registration_id", &self.executor_registration_id) + .field("executor_public_key", &self.executor_public_key) + .field("harness_key_authorization", &"") + .finish() + } +} + /// Request body for authorizing a harness key with the environment registry. #[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] pub struct EnvironmentRegistryHarnessKeyValidationRequest { @@ -32,3 +62,7 @@ pub struct EnvironmentRegistryHarnessKeyValidationRequest { pub struct EnvironmentRegistryHarnessKeyValidationResponse { pub valid: bool, } + +#[cfg(test)] +#[path = "environment_registry_tests.rs"] +mod tests; diff --git a/codex-rs/exec-server/src/environment_registry_tests.rs b/codex-rs/exec-server/src/environment_registry_tests.rs new file mode 100644 index 0000000000..8c32e0fb45 --- /dev/null +++ b/codex-rs/exec-server/src/environment_registry_tests.rs @@ -0,0 +1,22 @@ +use crate::EnvironmentRegistryConnectResponse; +use crate::NoiseChannelIdentity; + +#[test] +fn connect_response_debug_redacts_authorizations() { + let response = EnvironmentRegistryConnectResponse { + environment_id: "environment-1".to_string(), + url: "wss://rendezvous.test?sig=secret-url-authorization".to_string(), + security_profile: "noise_hybrid_ik_v1".to_string(), + executor_registration_id: "registration-1".to_string(), + executor_public_key: NoiseChannelIdentity::generate() + .expect("identity") + .public_key(), + harness_key_authorization: "secret-harness-authorization".to_string(), + }; + + let debug = format!("{response:?}"); + + assert!(debug.contains("")); + assert!(!debug.contains("secret-url-authorization")); + assert!(!debug.contains("secret-harness-authorization")); +} diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index df71eda95a..c96c443447 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -57,6 +57,8 @@ pub use environment::REMOTE_ENVIRONMENT_ID; pub use environment_provider::DefaultEnvironmentProvider; pub use environment_provider::EnvironmentProvider; pub use environment_provider::EnvironmentProviderFuture; +pub use environment_registry::EnvironmentRegistryConnectRequest; +pub use environment_registry::EnvironmentRegistryConnectResponse; pub use environment_registry::EnvironmentRegistryHarnessKeyValidationRequest; pub use environment_registry::EnvironmentRegistryHarnessKeyValidationResponse; pub use environment_registry::EnvironmentRegistryRegistrationRequest; diff --git a/codex-rs/exec-server/src/remote.rs b/codex-rs/exec-server/src/remote.rs index ace1578da8..e2111318eb 100644 --- a/codex-rs/exec-server/src/remote.rs +++ b/codex-rs/exec-server/src/remote.rs @@ -1,6 +1,12 @@ +use std::sync::Arc; use std::time::Duration; +use codex_api::AuthProvider; use codex_api::SharedAuthProvider; +use futures::FutureExt; +use http::HeaderMap; +use http::HeaderName; +use http::HeaderValue; use reqwest::StatusCode; use serde::Deserialize; use tokio::time::sleep; @@ -11,6 +17,8 @@ use tracing::warn; use codex_utils_rustls_provider::ensure_rustls_crypto_provider; +use crate::EnvironmentRegistryConnectRequest; +use crate::EnvironmentRegistryConnectResponse; use crate::EnvironmentRegistryHarnessKeyValidationRequest; use crate::EnvironmentRegistryHarnessKeyValidationResponse; use crate::EnvironmentRegistryRegistrationRequest; @@ -19,6 +27,9 @@ use crate::ExecServerError; use crate::ExecServerRuntimePaths; use crate::NoiseChannelIdentity; use crate::NoiseChannelPublicKey; +use crate::NoiseRendezvousConnectBundle; +use crate::NoiseRendezvousConnectProvider; +use crate::client_api::DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT; use crate::noise_relay::noise_relay_websocket_config; use crate::relay::HarnessKeyValidator; use crate::relay::run_multiplexed_environment; @@ -32,6 +43,7 @@ struct EnvironmentRegistryClient { base_url: String, auth_provider: SharedAuthProvider, http: reqwest::Client, + connect_timeout: Duration, } impl std::fmt::Debug for EnvironmentRegistryClient { @@ -52,6 +64,7 @@ impl EnvironmentRegistryClient { http: reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .build()?, + connect_timeout: DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT, }) } @@ -102,6 +115,53 @@ impl EnvironmentRegistryClient { Ok(response) } + /// Authorize one Noise harness key and obtain the full rendezvous bundle. + async fn connect_environment( + &self, + environment_id: &str, + harness_public_key: NoiseChannelPublicKey, + ) -> Result { + let response = self + .http + .post(endpoint_url( + &self.base_url, + &format!("/cloud/environment/{environment_id}/connect"), + )) + .headers(self.auth_provider.to_auth_headers()) + .json(&EnvironmentRegistryConnectRequest { harness_public_key }) + .timeout(self.connect_timeout) + .send() + .await?; + let response: EnvironmentRegistryConnectResponse = + self.parse_json_response(response).await?; + if response.environment_id != environment_id { + return Err(ExecServerError::Protocol( + "environment registry returned a different environment id".to_string(), + )); + } + if response.security_profile != NOISE_RELAY_SECURITY_PROFILE { + return Err(ExecServerError::Protocol(format!( + "environment registry returned unsupported security profile `{}`", + response.security_profile + ))); + } + if response.url.trim().is_empty() + || response.executor_registration_id.trim().is_empty() + || response.harness_key_authorization.trim().is_empty() + { + return Err(ExecServerError::Protocol( + "environment registry returned incomplete Noise connection data".to_string(), + )); + } + Ok(NoiseRendezvousConnectBundle { + websocket_url: response.url, + environment_id: response.environment_id, + executor_registration_id: response.executor_registration_id, + executor_public_key: response.executor_public_key, + harness_key_authorization: response.harness_key_authorization, + }) + } + async fn parse_json_response( &self, response: reqwest::Response, @@ -182,6 +242,130 @@ impl HarnessKeyValidator for RegistryHarnessKeyValidator { } } +/// Noise connection configuration for a Codex harness. +/// +/// The provider holds the authenticated registry client so every reconnect +/// receives fresh URL and harness-key authorization material. +#[derive(Clone)] +pub(crate) struct NoiseRendezvousEnvironmentConfig { + provider: Arc, +} + +impl std::fmt::Debug for NoiseRendezvousEnvironmentConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("NoiseRendezvousEnvironmentConfig") + .field("provider", &"") + .finish() + } +} + +impl NoiseRendezvousEnvironmentConfig { + pub(crate) fn new( + base_url: String, + environment_id: String, + bearer_token: String, + chatgpt_account_id: Option, + ) -> Result { + let environment_id = normalize_environment_id(environment_id)?; + let auth_provider = static_bearer_auth_provider(bearer_token, chatgpt_account_id)?; + let client = EnvironmentRegistryClient::new(base_url, auth_provider)?; + Ok(Self { + provider: Arc::new(EnvironmentRegistryNoiseConnectProvider { + client, + environment_id, + }), + }) + } + + pub(crate) fn connect_provider(&self) -> Arc { + Arc::clone(&self.provider) + } +} + +#[derive(Clone, Debug)] +struct EnvironmentRegistryNoiseConnectProvider { + client: EnvironmentRegistryClient, + environment_id: String, +} + +impl NoiseRendezvousConnectProvider for EnvironmentRegistryNoiseConnectProvider { + fn connect_bundle( + &self, + harness_public_key: NoiseChannelPublicKey, + ) -> futures::future::BoxFuture<'_, Result> { + async move { + self.client + .connect_environment(&self.environment_id, harness_public_key) + .await + } + .boxed() + } +} + +#[derive(Clone)] +struct StaticBearerAuthProvider { + authorization: HeaderValue, + chatgpt_account_id: Option, +} + +impl std::fmt::Debug for StaticBearerAuthProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StaticBearerAuthProvider") + .field("authorization", &"") + .field( + "chatgpt_account_id", + &self.chatgpt_account_id.as_ref().map(|_| ""), + ) + .finish() + } +} + +impl AuthProvider for StaticBearerAuthProvider { + fn add_auth_headers(&self, headers: &mut HeaderMap) { + headers.insert(http::header::AUTHORIZATION, self.authorization.clone()); + if let Some(chatgpt_account_id) = &self.chatgpt_account_id { + headers.insert( + HeaderName::from_static("chatgpt-account-id"), + chatgpt_account_id.clone(), + ); + } + } +} + +fn static_bearer_auth_provider( + bearer_token: String, + chatgpt_account_id: Option, +) -> Result { + let bearer_token = bearer_token.trim(); + if bearer_token.is_empty() { + return Err(ExecServerError::EnvironmentRegistryConfig( + "environment registry bearer token is required".to_string(), + )); + } + let authorization = + HeaderValue::try_from(format!("Bearer {bearer_token}")).map_err(|error| { + ExecServerError::EnvironmentRegistryConfig(format!( + "environment registry bearer token is not a valid HTTP header: {error}" + )) + })?; + let chatgpt_account_id = chatgpt_account_id + .as_deref() + .map(str::trim) + .filter(|account_id| !account_id.is_empty()) + .map(|account_id| { + HeaderValue::try_from(account_id).map_err(|error| { + ExecServerError::EnvironmentRegistryConfig(format!( + "ChatGPT account id is not a valid HTTP header: {error}" + )) + }) + }) + .transpose()?; + Ok(Arc::new(StaticBearerAuthProvider { + authorization, + chatgpt_account_id, + })) +} + /// Configuration for registering an exec-server for remote use. #[derive(Clone)] pub struct RemoteEnvironmentConfig { @@ -459,6 +643,86 @@ mod tests { ); } + #[tokio::test] + async fn noise_connect_provider_requests_and_validates_a_full_bundle() { + let server = MockServer::start().await; + let harness_public_key = NoiseChannelIdentity::generate() + .expect("identity") + .public_key(); + let executor_public_key = NoiseChannelIdentity::generate() + .expect("identity") + .public_key(); + Mock::given(method("POST")) + .and(path("/cloud/environment/environment-requested/connect")) + .and(header("authorization", "Bearer registry-token")) + .and(header("chatgpt-account-id", "workspace-123")) + .and(body_partial_json(serde_json::json!({ + "harness_public_key": harness_public_key.clone(), + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "environment_id": "environment-requested", + "url": "wss://rendezvous.test/cloud-agent/default/ws/environment/environment-requested?role=harness&sig=abc", + "security_profile": NOISE_RELAY_SECURITY_PROFILE, + "executor_registration_id": "registration-1", + "executor_public_key": executor_public_key.clone(), + "harness_key_authorization": "authorization-1", + }))) + .mount(&server) + .await; + let config = NoiseRendezvousEnvironmentConfig::new( + server.uri(), + "environment-requested".to_string(), + "registry-token".to_string(), + Some("workspace-123".to_string()), + ) + .expect("noise configuration"); + + let bundle = config + .connect_provider() + .connect_bundle(harness_public_key) + .await + .expect("Noise connect bundle"); + + assert_eq!( + bundle.websocket_url, + "wss://rendezvous.test/cloud-agent/default/ws/environment/environment-requested?role=harness&sig=abc" + ); + assert_eq!(bundle.environment_id, "environment-requested"); + assert_eq!(bundle.executor_registration_id, "registration-1"); + assert_eq!(bundle.executor_public_key, executor_public_key); + assert_eq!(bundle.harness_key_authorization, "authorization-1"); + } + + #[tokio::test] + async fn connect_environment_times_out_when_registry_stalls() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/cloud/environment/environment-requested/connect")) + .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(1))) + .mount(&server) + .await; + let mut client = + EnvironmentRegistryClient::new(server.uri(), static_registry_auth_provider()) + .expect("client"); + client.connect_timeout = Duration::from_millis(50); + let harness_public_key = NoiseChannelIdentity::generate() + .expect("identity") + .public_key(); + + let error = match client + .connect_environment("environment-requested", harness_public_key) + .await + { + Ok(_) => panic!("stalled connect response should time out"), + Err(error) => error, + }; + + assert!(matches!( + error, + ExecServerError::EnvironmentRegistryRequest(error) if error.is_timeout() + )); + } + #[tokio::test] async fn register_environment_does_not_follow_redirects_with_auth_headers() { let server = MockServer::start().await; From dbd2857f4bdd220b986f7d6bf69fbcd7dc34b520 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 17 Jun 2026 18:27:43 -0700 Subject: [PATCH 013/112] [codex] Add optional IDs to response items (#28812) ## Why `ResponseItem` variants do not have a consistent internal ID shape: some variants carry required IDs, some carry optional IDs, and some cannot represent an ID at all. The existing fields also use inconsistent serde, TypeScript, and JSON-schema annotations. A single enum-level access path is needed before history recording can assign and retain IDs. This PR establishes that internal model only. It intentionally does not generate or serialize IDs; allocation and wire persistence are isolated in the stacked follow-up. ## What changed - Give every concrete `ResponseItem` variant an `Option` ID field. - Apply the same internal-only annotations to every ID field: `#[serde(default, skip_serializing)]`, `#[ts(skip)]`, and `#[schemars(skip)]`. - Add `ResponseItem::id()` and `ResponseItem::set_id()` as the shared accessors. - Preserve IDs when history items are rewritten for truncation. - Adapt consumers that previously assumed reasoning and image-generation IDs were required. - Regenerate app-server schemas so the hidden fields are represented consistently. The serde catch-all `ResponseItem::Other` remains ID-less because it must remain a unit variant. ## Test plan - `cargo check --tests -p codex-core -p codex-api -p codex-rollout-trace -p codex-image-generation-extension` - `just test -p codex-protocol` - `just test -p codex-app-server-protocol` - `just test -p codex-api -p codex-rollout-trace -p codex-image-generation-extension` - `just test -p codex-core event_mapping` --- .../schema/json/ClientRequest.json | 50 +------ .../codex_app_server_protocol.schemas.json | 50 +------ .../codex_app_server_protocol.v2.schemas.json | 50 +------ .../RawResponseItemCompletedNotification.json | 50 +------ .../schema/json/v2/ThreadResumeParams.json | 50 +------ .../schema/typescript/ResponseItem.ts | 6 +- .../app-server/tests/suite/v2/compaction.rs | 1 + codex-rs/codex-api/src/requests/responses.rs | 2 +- codex-rs/core/src/agent/control_tests.rs | 2 +- codex-rs/core/src/client_common_tests.rs | 4 + codex-rs/core/src/compact_remote.rs | 5 + codex-rs/core/src/compact_remote_v2.rs | 10 +- codex-rs/core/src/compact_tests.rs | 2 + codex-rs/core/src/context_manager/history.rs | 4 + .../core/src/context_manager/history_tests.rs | 55 +++++++- .../core/src/context_manager/normalize.rs | 4 + codex-rs/core/src/event_mapping.rs | 4 +- codex-rs/core/src/event_mapping_tests.rs | 4 +- codex-rs/core/src/guardian/tests.rs | 2 + codex-rs/core/src/image_preparation_tests.rs | 2 + codex-rs/core/src/session/tests.rs | 8 +- codex-rs/core/src/stream_events_utils.rs | 4 + .../core/src/stream_events_utils_tests.rs | 5 +- codex-rs/core/src/thread_manager_tests.rs | 2 +- .../src/thread_rollout_truncation_tests.rs | 2 +- codex-rs/core/src/tools/code_mode/delegate.rs | 1 + codex-rs/core/src/turn_timing_tests.rs | 1 + codex-rs/core/tests/suite/client.rs | 7 +- codex-rs/core/tests/suite/compact.rs | 3 + codex-rs/core/tests/suite/compact_remote.rs | 6 + codex-rs/ext/image-generation/src/tests.rs | 5 +- codex-rs/memories/write/src/phase1.rs | 1 + codex-rs/protocol/src/models.rs | 129 ++++++++++++++++-- codex-rs/protocol/src/protocol.rs | 2 + codex-rs/rollout-trace/src/inference.rs | 2 +- codex-rs/thread-store/src/local/mod.rs | 1 + 36 files changed, 278 insertions(+), 258 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 509670f61b..7498994980 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2289,13 +2289,6 @@ }, "type": "array" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -2437,14 +2430,6 @@ "null" ] }, - "id": { - "description": "Legacy id field retained for compatibility with older payloads.", - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -2482,13 +2467,6 @@ "call_id": { "type": "string" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -2537,13 +2515,6 @@ "execution": { "type": "string" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -2615,13 +2586,6 @@ "call_id": { "type": "string" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "input": { "type": "string" }, @@ -2758,13 +2722,6 @@ } ] }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -2798,7 +2755,11 @@ { "properties": { "id": { - "type": "string" + "description": "Existing provider ID retained on serialized history for compatibility.", + "type": [ + "string", + "null" + ] }, "metadata": { "anyOf": [ @@ -2831,7 +2792,6 @@ } }, "required": [ - "id", "result", "status", "type" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 99a8a24e39..5b0c59c425 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -14791,13 +14791,6 @@ }, "type": "array" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -14939,14 +14932,6 @@ "null" ] }, - "id": { - "description": "Legacy id field retained for compatibility with older payloads.", - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -14984,13 +14969,6 @@ "call_id": { "type": "string" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -15039,13 +15017,6 @@ "execution": { "type": "string" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -15117,13 +15088,6 @@ "call_id": { "type": "string" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "input": { "type": "string" }, @@ -15260,13 +15224,6 @@ } ] }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -15300,7 +15257,11 @@ { "properties": { "id": { - "type": "string" + "description": "Existing provider ID retained on serialized history for compatibility.", + "type": [ + "string", + "null" + ] }, "metadata": { "anyOf": [ @@ -15333,7 +15294,6 @@ } }, "required": [ - "id", "result", "status", "type" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index b0399a2232..45cfbd95c2 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -11236,13 +11236,6 @@ }, "type": "array" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -11384,14 +11377,6 @@ "null" ] }, - "id": { - "description": "Legacy id field retained for compatibility with older payloads.", - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -11429,13 +11414,6 @@ "call_id": { "type": "string" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -11484,13 +11462,6 @@ "execution": { "type": "string" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -11562,13 +11533,6 @@ "call_id": { "type": "string" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "input": { "type": "string" }, @@ -11705,13 +11669,6 @@ } ] }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -11745,7 +11702,11 @@ { "properties": { "id": { - "type": "string" + "description": "Existing provider ID retained on serialized history for compatibility.", + "type": [ + "string", + "null" + ] }, "metadata": { "anyOf": [ @@ -11778,7 +11739,6 @@ } }, "required": [ - "id", "result", "status", "type" diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index 3efca03224..f77a68dc1d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -377,13 +377,6 @@ }, "type": "array" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -525,14 +518,6 @@ "null" ] }, - "id": { - "description": "Legacy id field retained for compatibility with older payloads.", - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -570,13 +555,6 @@ "call_id": { "type": "string" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -625,13 +603,6 @@ "execution": { "type": "string" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -703,13 +674,6 @@ "call_id": { "type": "string" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "input": { "type": "string" }, @@ -846,13 +810,6 @@ } ] }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -886,7 +843,11 @@ { "properties": { "id": { - "type": "string" + "description": "Existing provider ID retained on serialized history for compatibility.", + "type": [ + "string", + "null" + ] }, "metadata": { "anyOf": [ @@ -919,7 +880,6 @@ } }, "required": [ - "id", "result", "status", "type" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 093c27044d..e09dd2c6fc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -448,13 +448,6 @@ }, "type": "array" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -596,14 +589,6 @@ "null" ] }, - "id": { - "description": "Legacy id field retained for compatibility with older payloads.", - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -641,13 +626,6 @@ "call_id": { "type": "string" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -696,13 +674,6 @@ "execution": { "type": "string" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -774,13 +745,6 @@ "call_id": { "type": "string" }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "input": { "type": "string" }, @@ -917,13 +881,6 @@ } ] }, - "id": { - "type": [ - "string", - "null" - ], - "writeOnly": true - }, "metadata": { "anyOf": [ { @@ -957,7 +914,11 @@ { "properties": { "id": { - "type": "string" + "description": "Existing provider ID retained on serialized history for compatibility.", + "type": [ + "string", + "null" + ] }, "metadata": { "anyOf": [ @@ -990,7 +951,6 @@ } }, "required": [ - "id", "result", "status", "type" diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts index 5bd9a1032b..f0cd1c8ee6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts @@ -16,4 +16,8 @@ export type ResponseItem = { "type": "message", role: string, content: Array Result<() metadata: None, }, ResponseItem::Compaction { + id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), metadata: None, }, diff --git a/codex-rs/codex-api/src/requests/responses.rs b/codex-rs/codex-api/src/requests/responses.rs index 5f3e1ba876..836f525c49 100644 --- a/codex-rs/codex-api/src/requests/responses.rs +++ b/codex-rs/codex-api/src/requests/responses.rs @@ -17,7 +17,7 @@ pub(crate) fn attach_item_ids(payload_json: &mut Value, original_items: &[Respon }; for (value, item) in items.iter_mut().zip(original_items.iter()) { - if let ResponseItem::Reasoning { id, .. } + if let ResponseItem::Reasoning { id: Some(id), .. } | ResponseItem::Message { id: Some(id), .. } | ResponseItem::WebSearchCall { id: Some(id), .. } | ResponseItem::FunctionCall { id: Some(id), .. } diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 94f7b01324..2d4b221a9e 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -878,7 +878,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)), assistant_message("parent unknown phase", /*phase*/ None), ResponseItem::Reasoning { - id: "parent-reasoning".to_string(), + id: Some("parent-reasoning".to_string()), summary: Vec::new(), content: None, encrypted_content: None, diff --git a/codex-rs/core/src/client_common_tests.rs b/codex-rs/core/src/client_common_tests.rs index b71acc3989..8107049ab3 100644 --- a/codex-rs/core/src/client_common_tests.rs +++ b/codex-rs/core/src/client_common_tests.rs @@ -23,6 +23,7 @@ fn prompt_with_image_outputs() -> Prompt { metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "function-call".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { @@ -33,6 +34,7 @@ fn prompt_with_image_outputs() -> Prompt { metadata: None, }, ResponseItem::CustomToolCallOutput { + id: None, call_id: "custom-call".to_string(), name: None, output: FunctionCallOutputPayload::from_content_items(vec![ @@ -69,6 +71,7 @@ fn responses_lite_request_copies_strip_image_details() { metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "function-call".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { @@ -79,6 +82,7 @@ fn responses_lite_request_copies_strip_image_details() { metadata: None, }, ResponseItem::CustomToolCallOutput { + id: None, call_id: "custom-call".to_string(), name: None, output: FunctionCallOutputPayload::from_content_items(vec![ diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 08ca7efcdf..c88d8062d6 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -403,20 +403,24 @@ pub(crate) fn trim_function_call_history_to_fit_context_window( fn rewritten_output_for_context_window(item: &ResponseItem) -> Option { Some(match item { ResponseItem::FunctionCallOutput { + id, call_id, output, metadata, } => ResponseItem::FunctionCallOutput { + id: id.clone(), call_id: call_id.clone(), output: truncated_output_payload(output), metadata: metadata.clone(), }, ResponseItem::CustomToolCallOutput { + id, call_id, name, output, metadata, } => ResponseItem::CustomToolCallOutput { + id: id.clone(), call_id: call_id.clone(), name: name.clone(), output: truncated_output_payload(output), @@ -429,6 +433,7 @@ fn rewritten_output_for_context_window(item: &ResponseItem) -> Option ResponseItem::ToolSearchOutput { + id: item.id().map(str::to_string), call_id: call_id.clone(), status: status.clone(), execution: execution.clone(), diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 4b4071a0d9..6c3a939024 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -231,7 +231,10 @@ async fn run_remote_compact_task_inner_impl( ) .await?; let mut input = prompt_input.clone(); - input.push(ResponseItem::CompactionTrigger { metadata: None }); + input.push(ResponseItem::CompactionTrigger { + id: None, + metadata: None, + }); let prompt = Prompt { input, tools: tool_router.model_visible_specs(), @@ -609,11 +612,13 @@ mod tests { metadata: None, }, ResponseItem::Compaction { + id: None, encrypted_content: "old".to_string(), metadata: None, }, ]; let output = ResponseItem::Compaction { + id: None, encrypted_content: "new".to_string(), metadata: None, }; @@ -642,6 +647,7 @@ mod tests { new.clone(), ]; let output = ResponseItem::Compaction { + id: None, encrypted_content: "new".to_string(), metadata: None, }; @@ -673,6 +679,7 @@ mod tests { metadata: None, }]; let output = ResponseItem::Compaction { + id: None, encrypted_content: "new".to_string(), metadata: None, }; @@ -800,6 +807,7 @@ mod tests { #[tokio::test] async fn collect_compaction_output_accepts_additional_output_items() { let compaction = ResponseItem::Compaction { + id: None, encrypted_content: "encrypted".to_string(), metadata: None, }; diff --git a/codex-rs/core/src/compact_tests.rs b/codex-rs/core/src/compact_tests.rs index c1004004ad..a09a6dbb3e 100644 --- a/codex-rs/core/src/compact_tests.rs +++ b/codex-rs/core/src/compact_tests.rs @@ -654,6 +654,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() #[test] fn insert_initial_context_before_last_real_user_or_summary_keeps_compaction_last() { let compacted_history = vec![ResponseItem::Compaction { + id: None, encrypted_content: "encrypted".to_string(), metadata: None, }]; @@ -680,6 +681,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_compaction_last metadata: None, }, ResponseItem::Compaction { + id: None, encrypted_content: "encrypted".to_string(), metadata: None, }, diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 47f37a32f9..6ce1c6148f 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -339,20 +339,24 @@ impl ContextManager { let policy_with_serialization_budget = policy * 1.2; match item { ResponseItem::FunctionCallOutput { + id, call_id, output, metadata, } => ResponseItem::FunctionCallOutput { + id: id.clone(), call_id: call_id.clone(), output: truncate_function_output_payload(output, policy_with_serialization_budget), metadata: metadata.clone(), }, ResponseItem::CustomToolCallOutput { + id, call_id, name, output, metadata, } => ResponseItem::CustomToolCallOutput { + id: id.clone(), call_id: call_id.clone(), name: name.clone(), output: truncate_function_output_payload(output, policy_with_serialization_budget), diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index bf5bfc3b7a..8b9609a25a 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -154,6 +154,7 @@ fn reference_context_item() -> TurnContextItem { fn custom_tool_call_output(call_id: &str, output: &str) -> ResponseItem { ResponseItem::CustomToolCallOutput { + id: None, call_id: call_id.to_string(), name: None, output: FunctionCallOutputPayload::from_text(output.to_string()), @@ -163,7 +164,7 @@ fn custom_tool_call_output(call_id: &str, output: &str) -> ResponseItem { fn reasoning_msg(text: &str) -> ResponseItem { ResponseItem::Reasoning { - id: String::new(), + id: None, summary: vec![ReasoningItemReasoningSummary::SummaryText { text: "summary".to_string(), }], @@ -177,7 +178,7 @@ fn reasoning_msg(text: &str) -> ResponseItem { fn reasoning_with_encrypted_content(len: usize) -> ResponseItem { ResponseItem::Reasoning { - id: String::new(), + id: None, summary: vec![ReasoningItemReasoningSummary::SummaryText { text: "summary".to_string(), }], @@ -222,7 +223,7 @@ fn filters_non_api_messages() { items, vec![ ResponseItem::Reasoning { - id: String::new(), + id: None, summary: vec![ReasoningItemReasoningSummary::SummaryText { text: "summary".to_string(), }], @@ -409,6 +410,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputText { @@ -430,6 +432,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { metadata: None, }, ResponseItem::CustomToolCallOutput { + id: None, call_id: "tool-1".to_string(), name: None, output: FunctionCallOutputPayload::from_content_items(vec![ @@ -476,6 +479,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputText { @@ -497,6 +501,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { metadata: None, }, ResponseItem::CustomToolCallOutput { + id: None, call_id: "tool-1".to_string(), name: None, output: FunctionCallOutputPayload::from_content_items(vec![ @@ -544,7 +549,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { fn for_prompt_preserves_image_generation_calls_when_images_are_supported() { let history = create_history_with_items(vec![ ResponseItem::ImageGenerationCall { - id: "ig_123".to_string(), + id: Some("ig_123".to_string()), status: "generating".to_string(), revised_prompt: Some("lobster".to_string()), result: "Zm9v".to_string(), @@ -565,7 +570,7 @@ fn for_prompt_preserves_image_generation_calls_when_images_are_supported() { history.for_prompt(&default_input_modalities()), vec![ ResponseItem::ImageGenerationCall { - id: "ig_123".to_string(), + id: Some("ig_123".to_string()), status: "generating".to_string(), revised_prompt: Some("lobster".to_string()), result: "Zm9v".to_string(), @@ -597,7 +602,7 @@ fn for_prompt_clears_image_generation_result_when_images_are_unsupported() { metadata: None, }, ResponseItem::ImageGenerationCall { - id: "ig_123".to_string(), + id: Some("ig_123".to_string()), status: "completed".to_string(), revised_prompt: Some("lobster".to_string()), result: "Zm9v".to_string(), @@ -618,7 +623,7 @@ fn for_prompt_clears_image_generation_result_when_images_are_unsupported() { metadata: None, }, ResponseItem::ImageGenerationCall { - id: "ig_123".to_string(), + id: Some("ig_123".to_string()), status: "completed".to_string(), revised_prompt: Some("lobster".to_string()), result: String::new(), @@ -662,6 +667,7 @@ fn remove_first_item_removes_matching_output_for_function_call() { metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), metadata: None, @@ -676,6 +682,7 @@ fn remove_first_item_removes_matching_output_for_function_call() { fn remove_first_item_removes_matching_call_for_output() { let items = vec![ ResponseItem::FunctionCallOutput { + id: None, call_id: "call-2".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), metadata: None, @@ -699,6 +706,7 @@ fn replace_last_turn_images_replaces_tool_output_images() { let items = vec![ user_input_text_msg("hi"), ResponseItem::FunctionCallOutput { + id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload { body: FunctionCallOutputBody::ContentItems(vec![ @@ -721,6 +729,7 @@ fn replace_last_turn_images_replaces_tool_output_images() { vec![ user_input_text_msg("hi"), ResponseItem::FunctionCallOutput { + id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload { body: FunctionCallOutputBody::ContentItems(vec![ @@ -771,6 +780,7 @@ fn remove_first_item_handles_local_shell_pair() { metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "call-3".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), metadata: None, @@ -998,6 +1008,7 @@ fn remove_first_item_handles_custom_tool_pair() { metadata: None, }, ResponseItem::CustomToolCallOutput { + id: None, call_id: "tool-1".to_string(), name: None, output: FunctionCallOutputPayload::from_text("ok".to_string()), @@ -1026,6 +1037,7 @@ fn normalization_retains_local_shell_outputs() { metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "shell-1".to_string(), output: FunctionCallOutputPayload::from_text("Total output lines: 1\n\nok".to_string()), metadata: None, @@ -1047,6 +1059,7 @@ fn record_items_truncates_function_call_output_content() { let long_line = "a very long line to trigger truncation\n"; let long_output = long_line.repeat(2_500); let item = ResponseItem::FunctionCallOutput { + id: None, call_id: "call-100".to_string(), output: FunctionCallOutputPayload { body: FunctionCallOutputBody::Text(long_output.clone()), @@ -1086,6 +1099,7 @@ fn record_items_truncates_custom_tool_call_output_content() { let line = "custom output that is very long\n"; let long_output = line.repeat(2_500); let item = ResponseItem::CustomToolCallOutput { + id: None, call_id: "tool-200".to_string(), name: None, output: FunctionCallOutputPayload::from_text(long_output.clone()), @@ -1118,6 +1132,7 @@ fn record_items_respects_custom_token_limit() { let policy = TruncationPolicy::Tokens(10); let long_output = "tokenized content repeated many times ".repeat(200); let item = ResponseItem::FunctionCallOutput { + id: None, call_id: "call-custom-limit".to_string(), output: FunctionCallOutputPayload { body: FunctionCallOutputBody::Text(long_output), @@ -1261,6 +1276,7 @@ fn normalize_adds_missing_output_for_function_call() { metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "call-x".to_string(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), metadata: None, @@ -1296,6 +1312,7 @@ fn normalize_adds_missing_output_for_custom_tool_call() { metadata: None, }, ResponseItem::CustomToolCallOutput { + id: None, call_id: "tool-x".to_string(), name: None, output: FunctionCallOutputPayload::from_text("aborted".to_string()), @@ -1342,6 +1359,7 @@ fn normalize_adds_missing_output_for_local_shell_call_with_id() { metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "shell-1".to_string(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), metadata: None, @@ -1354,6 +1372,7 @@ fn normalize_adds_missing_output_for_local_shell_call_with_id() { #[test] fn normalize_removes_orphan_function_call_output() { let items = vec![ResponseItem::FunctionCallOutput { + id: None, call_id: "orphan-1".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), metadata: None, @@ -1369,6 +1388,7 @@ fn normalize_removes_orphan_function_call_output() { #[test] fn normalize_removes_orphan_custom_tool_call_output() { let items = vec![ResponseItem::CustomToolCallOutput { + id: None, call_id: "orphan-2".to_string(), name: None, output: FunctionCallOutputPayload::from_text("ok".to_string()), @@ -1396,6 +1416,7 @@ fn normalize_mixed_inserts_and_removals() { }, // Orphan output that should be removed ResponseItem::FunctionCallOutput { + id: None, call_id: "c2".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), metadata: None, @@ -1440,6 +1461,7 @@ fn normalize_mixed_inserts_and_removals() { metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "c1".to_string(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), metadata: None, @@ -1453,6 +1475,7 @@ fn normalize_mixed_inserts_and_removals() { metadata: None, }, ResponseItem::CustomToolCallOutput { + id: None, call_id: "t1".to_string(), name: None, output: FunctionCallOutputPayload::from_text("aborted".to_string()), @@ -1472,6 +1495,7 @@ fn normalize_mixed_inserts_and_removals() { metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "s1".to_string(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), metadata: None, @@ -1504,6 +1528,7 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() { metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "call-x".to_string(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), metadata: None, @@ -1538,6 +1563,7 @@ fn normalize_adds_missing_output_for_tool_search_call() { metadata: None, }, ResponseItem::ToolSearchOutput { + id: None, call_id: Some("search-call-x".to_string()), status: "completed".to_string(), execution: "client".to_string(), @@ -1590,6 +1616,7 @@ fn normalize_adds_missing_output_for_local_shell_call_with_id_panics_in_debug() #[should_panic] fn normalize_removes_orphan_function_call_output_panics_in_debug() { let items = vec![ResponseItem::FunctionCallOutput { + id: None, call_id: "orphan-1".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), metadata: None, @@ -1603,6 +1630,7 @@ fn normalize_removes_orphan_function_call_output_panics_in_debug() { #[should_panic] fn normalize_removes_orphan_custom_tool_call_output_panics_in_debug() { let items = vec![ResponseItem::CustomToolCallOutput { + id: None, call_id: "orphan-2".to_string(), name: None, output: FunctionCallOutputPayload::from_text("ok".to_string()), @@ -1616,6 +1644,7 @@ fn normalize_removes_orphan_custom_tool_call_output_panics_in_debug() { #[test] fn normalize_removes_orphan_client_tool_search_output() { let items = vec![ResponseItem::ToolSearchOutput { + id: None, call_id: Some("orphan-search".to_string()), status: "completed".to_string(), execution: "client".to_string(), @@ -1634,6 +1663,7 @@ fn normalize_removes_orphan_client_tool_search_output() { #[should_panic] fn normalize_removes_orphan_client_tool_search_output_panics_in_debug() { let items = vec![ResponseItem::ToolSearchOutput { + id: None, call_id: Some("orphan-search".to_string()), status: "completed".to_string(), execution: "client".to_string(), @@ -1647,6 +1677,7 @@ fn normalize_removes_orphan_client_tool_search_output_panics_in_debug() { #[test] fn normalize_keeps_server_tool_search_output_without_matching_call() { let items = vec![ResponseItem::ToolSearchOutput { + id: None, call_id: Some("server-search".to_string()), status: "completed".to_string(), execution: "server".to_string(), @@ -1660,6 +1691,7 @@ fn normalize_keeps_server_tool_search_output_without_matching_call() { assert_eq!( h.raw_items(), vec![ResponseItem::ToolSearchOutput { + id: None, call_id: Some("server-search".to_string()), status: "completed".to_string(), execution: "server".to_string(), @@ -1683,6 +1715,7 @@ fn normalize_mixed_inserts_and_removals_panics_in_debug() { metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "c2".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), metadata: None, @@ -1757,6 +1790,7 @@ fn image_data_url_payload_does_not_dominate_function_call_output_estimate() { let payload = "B".repeat(50_000); let image_url = format!("data:image/png;base64,{payload}"); let item = ResponseItem::FunctionCallOutput { + id: None, call_id: "call-abc".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputText { @@ -1783,6 +1817,7 @@ fn image_data_url_payload_does_not_dominate_custom_tool_call_output_estimate() { let payload = "C".repeat(50_000); let image_url = format!("data:image/png;base64,{payload}"); let item = ResponseItem::CustomToolCallOutput { + id: None, call_id: "call-js-repl".to_string(), name: None, output: FunctionCallOutputPayload::from_content_items(vec![ @@ -1818,6 +1853,7 @@ fn non_base64_image_urls_are_unchanged() { metadata: None, }; let function_output_item = ResponseItem::FunctionCallOutput { + id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { @@ -1842,6 +1878,7 @@ fn non_base64_image_urls_are_unchanged() { fn encrypted_function_output_uses_plaintext_byte_estimate() { let encrypted_content = "A".repeat(1_868); let item = ResponseItem::FunctionCallOutput { + id: None, call_id: "call-encrypted".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::EncryptedContent { @@ -1883,6 +1920,7 @@ fn non_image_base64_data_url_is_unchanged() { let payload = "C".repeat(4_096); let image_url = format!("data:application/octet-stream;base64,{payload}"); let item = ResponseItem::FunctionCallOutput { + id: None, call_id: "call-octet".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { @@ -1971,6 +2009,7 @@ fn original_detail_images_scale_with_dimensions() { let payload = BASE64_STANDARD.encode(bytes.get_ref()); let image_url = format!("data:image/png;base64,{payload}"); let item = ResponseItem::FunctionCallOutput { + id: None, call_id: "call-original".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { @@ -2002,6 +2041,7 @@ fn original_detail_images_are_capped_at_max_patch_count() { let payload = BASE64_STANDARD.encode(bytes.get_ref()); let image_url = format!("data:image/png;base64,{payload}"); let item = ResponseItem::FunctionCallOutput { + id: None, call_id: "call-original-capped".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { @@ -2036,6 +2076,7 @@ fn original_detail_webp_images_scale_with_dimensions() { let payload = BASE64_STANDARD.encode(bytes.get_ref()); let image_url = format!("data:image/webp;base64,{payload}"); let item = ResponseItem::FunctionCallOutput { + id: None, call_id: "call-original-webp".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { diff --git a/codex-rs/core/src/context_manager/normalize.rs b/codex-rs/core/src/context_manager/normalize.rs index 1da58b6192..13aacca1e6 100644 --- a/codex-rs/core/src/context_manager/normalize.rs +++ b/codex-rs/core/src/context_manager/normalize.rs @@ -47,6 +47,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { missing_outputs_to_insert.push(( idx, ResponseItem::FunctionCallOutput { + id: None, call_id: call_id.clone(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), metadata: None, @@ -61,6 +62,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { missing_outputs_to_insert.push(( idx, ResponseItem::ToolSearchOutput { + id: None, call_id: Some(call_id.clone()), status: "completed".to_string(), execution: "client".to_string(), @@ -78,6 +80,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { missing_outputs_to_insert.push(( idx, ResponseItem::CustomToolCallOutput { + id: None, call_id: call_id.clone(), name: None, output: FunctionCallOutputPayload::from_text("aborted".to_string()), @@ -96,6 +99,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { missing_outputs_to_insert.push(( idx, ResponseItem::FunctionCallOutput { + id: None, call_id: call_id.clone(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), metadata: None, diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 11e0294c33..9e96ebff52 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -178,7 +178,7 @@ pub fn parse_turn_item(item: &ResponseItem) -> Option { }) .collect(); Some(TurnItem::Reasoning(ReasoningItem { - id: id.clone(), + id: id.clone().unwrap_or_default(), summary_text, raw_content, })) @@ -202,7 +202,7 @@ pub fn parse_turn_item(item: &ResponseItem) -> Option { .. } => Some(TurnItem::ImageGeneration( codex_protocol::items::ImageGenerationItem { - id: id.clone(), + id: id.clone()?, status: status.clone(), revised_prompt: revised_prompt.clone(), result: result.clone(), diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs index ad5b4c877d..b7453c902f 100644 --- a/codex-rs/core/src/event_mapping_tests.rs +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -391,7 +391,7 @@ fn parses_agent_message() { #[test] fn parses_reasoning_summary_and_raw_content() { let item = ResponseItem::Reasoning { - id: "reasoning_1".to_string(), + id: Some("reasoning_1".to_string()), summary: vec![ ReasoningItemReasoningSummary::SummaryText { text: "Step 1".to_string(), @@ -424,7 +424,7 @@ fn parses_reasoning_summary_and_raw_content() { #[test] fn parses_reasoning_including_raw_content() { let item = ResponseItem::Reasoning { - id: "reasoning_2".to_string(), + id: Some("reasoning_2".to_string()), summary: vec![ReasoningItemReasoningSummary::SummaryText { text: "Summarized step".to_string(), }], diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index b19cd9d35e..d6eb93dd14 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -309,6 +309,7 @@ async fn seed_guardian_parent_history(session: &Arc, turn: &Arc Opti match input { ResponseInputItem::FunctionCallOutput { call_id, output } => { Some(ResponseItem::FunctionCallOutput { + id: None, call_id: call_id.clone(), output: output.clone(), metadata: None, @@ -634,6 +635,7 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti name, output, } => Some(ResponseItem::CustomToolCallOutput { + id: None, call_id: call_id.clone(), name: name.clone(), output: output.clone(), @@ -642,6 +644,7 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti ResponseInputItem::McpToolCallOutput { call_id, output } => { let output = output.as_function_call_output_payload(); Some(ResponseItem::FunctionCallOutput { + id: None, call_id: call_id.clone(), output, metadata: None, @@ -653,6 +656,7 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti execution, tools, } => Some(ResponseItem::ToolSearchOutput { + id: None, call_id: Some(call_id.clone()), status: status.clone(), execution: execution.clone(), diff --git a/codex-rs/core/src/stream_events_utils_tests.rs b/codex-rs/core/src/stream_events_utils_tests.rs index b89d4f5c6e..45c5d18e68 100644 --- a/codex-rs/core/src/stream_events_utils_tests.rs +++ b/codex-rs/core/src/stream_events_utils_tests.rs @@ -64,6 +64,7 @@ fn external_context_pollution_items_include_web_search_and_tool_search() { metadata: None, }, ResponseItem::ToolSearchOutput { + id: None, call_id: Some("search-1".to_string()), status: "completed".to_string(), execution: "client".to_string(), @@ -104,6 +105,7 @@ fn external_context_pollution_items_exclude_local_tool_calls() { metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), metadata: None, @@ -117,6 +119,7 @@ fn external_context_pollution_items_exclude_local_tool_calls() { metadata: None, }, ResponseItem::CustomToolCallOutput { + id: None, call_id: "custom-1".to_string(), name: Some("apply_patch".to_string()), output: FunctionCallOutputPayload::from_text("ok".to_string()), @@ -418,7 +421,7 @@ fn completed_item_keeps_mailbox_delivery_open_for_commentary_messages() { #[test] fn completed_item_defers_mailbox_delivery_for_image_generation_calls() { let item = ResponseItem::ImageGenerationCall { - id: "ig-1".to_string(), + id: Some("ig-1".to_string()), status: "completed".to_string(), revised_prompt: None, result: "Zm9v".to_string(), diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index cec54b5e44..be952bbcbc 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -76,7 +76,7 @@ fn truncates_before_requested_user_message() { user_msg("u2"), assistant_msg("a3"), ResponseItem::Reasoning { - id: "r1".to_string(), + id: Some("r1".to_string()), summary: vec![ReasoningItemReasoningSummary::SummaryText { text: "s".to_string(), }], diff --git a/codex-rs/core/src/thread_rollout_truncation_tests.rs b/codex-rs/core/src/thread_rollout_truncation_tests.rs index bb6c3ac03e..bdbdbc4c75 100644 --- a/codex-rs/core/src/thread_rollout_truncation_tests.rs +++ b/codex-rs/core/src/thread_rollout_truncation_tests.rs @@ -73,7 +73,7 @@ fn truncates_rollout_from_start_before_nth_user_only() { user_msg("u2"), assistant_msg("a3"), ResponseItem::Reasoning { - id: "r1".to_string(), + id: Some("r1".to_string()), summary: vec![ReasoningItemReasoningSummary::SummaryText { text: "s".to_string(), }], diff --git a/codex-rs/core/src/tools/code_mode/delegate.rs b/codex-rs/core/src/tools/code_mode/delegate.rs index 15177944ca..7dd6615095 100644 --- a/codex-rs/core/src/tools/code_mode/delegate.rs +++ b/codex-rs/core/src/tools/code_mode/delegate.rs @@ -299,6 +299,7 @@ impl CoreTurnHost { self.exec .session .inject_if_running(vec![ResponseItem::CustomToolCallOutput { + id: None, call_id, name: Some(PUBLIC_TOOL_NAME.to_string()), output: FunctionCallOutputPayload::from_text(text), diff --git a/codex-rs/core/src/turn_timing_tests.rs b/codex-rs/core/src/turn_timing_tests.rs index af5a54fec8..9f1bcd31b1 100644 --- a/codex-rs/core/src/turn_timing_tests.rs +++ b/codex-rs/core/src/turn_timing_tests.rs @@ -149,6 +149,7 @@ fn response_item_records_turn_ttft_ignores_empty_non_output_items() { })); assert!(!response_item_records_turn_ttft( &ResponseItem::FunctionCallOutput { + id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), metadata: None, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index c5dfd96cf6..54e24d49cb 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -611,6 +611,7 @@ async fn resume_replays_legacy_js_repl_image_rollout_shapes() { RolloutLine { timestamp: "2024-01-01T00:00:02.000Z".to_string(), item: RolloutItem::ResponseItem(ResponseItem::CustomToolCallOutput { + id: None, call_id: "legacy-js-call".to_string(), name: None, output: FunctionCallOutputPayload::from_text("legacy js_repl stdout".to_string()), @@ -751,6 +752,7 @@ async fn resume_replays_image_tool_outputs_with_detail() { RolloutLine { timestamp: "2024-01-01T00:00:01.500Z".to_string(), item: RolloutItem::ResponseItem(ResponseItem::FunctionCallOutput { + id: None, call_id: function_call_id.to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { @@ -775,6 +777,7 @@ async fn resume_replays_image_tool_outputs_with_detail() { RolloutLine { timestamp: "2024-01-01T00:00:02.500Z".to_string(), item: RolloutItem::ResponseItem(ResponseItem::CustomToolCallOutput { + id: None, call_id: custom_call_id.to_string(), name: None, output: FunctionCallOutputPayload::from_content_items(vec![ @@ -2518,7 +2521,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { let mut prompt = Prompt::default(); prompt.input.push(ResponseItem::Reasoning { - id: "reasoning-id".into(), + id: Some("reasoning-id".into()), summary: vec![ReasoningItemReasoningSummary::SummaryText { text: "summary".into(), }], @@ -2555,6 +2558,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { metadata: None, }); prompt.input.push(ResponseItem::FunctionCallOutput { + id: None, call_id: "function-call-id".into(), output: FunctionCallOutputPayload::from_text("ok".into()), metadata: None, @@ -2581,6 +2585,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { metadata: None, }); prompt.input.push(ResponseItem::CustomToolCallOutput { + id: None, call_id: "custom-tool-call-id".into(), name: None, output: FunctionCallOutputPayload::from_text("ok".into()), diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index cff88f57a8..3b709d54a2 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1995,6 +1995,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { metadata: None, }, codex_protocol::models::ResponseItem::Compaction { + id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), metadata: None, }, @@ -3994,6 +3995,7 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() { metadata: None, }, codex_protocol::models::ResponseItem::Compaction { + id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), metadata: None, }, @@ -4121,6 +4123,7 @@ async fn auto_compact_runs_when_reasoning_header_clears_between_turns() { metadata: None, }, codex_protocol::models::ResponseItem::Compaction { + id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), metadata: None, }, diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index f9b35f686a..365cf35b02 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -157,6 +157,7 @@ fn format_labeled_requests_snapshot( fn compacted_summary_only_output(summary: &str) -> Vec { vec![ResponseItem::Compaction { + id: None, encrypted_content: summary_with_prefix(summary), metadata: None, }] @@ -329,6 +330,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { .await; let compacted_history = vec![ResponseItem::Compaction { + id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), metadata: None, }]; @@ -2356,6 +2358,7 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> let compacted_history = vec![ ResponseItem::Compaction { + id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), metadata: None, }, @@ -2511,6 +2514,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res metadata: None, }, ResponseItem::Compaction { + id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), metadata: None, }, @@ -2653,6 +2657,7 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() metadata: None, }, ResponseItem::Compaction { + id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), metadata: None, }, @@ -4059,6 +4064,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinject .await; let compacted_history = vec![ResponseItem::Compaction { + id: None, encrypted_content: summary_with_prefix("REMOTE_SUMMARY_ONLY"), metadata: None, }]; diff --git a/codex-rs/ext/image-generation/src/tests.rs b/codex-rs/ext/image-generation/src/tests.rs index 449c7beb09..e2f5ac666c 100644 --- a/codex-rs/ext/image-generation/src/tests.rs +++ b/codex-rs/ext/image-generation/src/tests.rs @@ -87,6 +87,7 @@ async fn recent_image_fallback_selects_newest_images_in_chronological_order() { metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "mcp-call".to_string(), output: image_output("mcp"), metadata: None, @@ -100,19 +101,21 @@ async fn recent_image_fallback_selects_newest_images_in_chronological_order() { metadata: None, }, ResponseItem::CustomToolCallOutput { + id: None, call_id: "code-mode-call".to_string(), name: Some("exec".to_string()), output: image_output("code-mode"), metadata: None, }, ResponseItem::ImageGenerationCall { - id: "generated-call".to_string(), + id: Some("generated-call".to_string()), status: "completed".to_string(), revised_prompt: None, result: "generated".to_string(), metadata: None, }, ResponseItem::FunctionCallOutput { + id: None, call_id: "orphan-call".to_string(), output: image_output("orphan"), metadata: None, diff --git a/codex-rs/memories/write/src/phase1.rs b/codex-rs/memories/write/src/phase1.rs index 775e898874..026826b7c9 100644 --- a/codex-rs/memories/write/src/phase1.rs +++ b/codex-rs/memories/write/src/phase1.rs @@ -742,6 +742,7 @@ mod tests { let serialized = job::serialize_filtered_rollout_response_items(&[RolloutItem::ResponseItem( ResponseItem::FunctionCallOutput { + id: None, call_id: "call_123".to_string(), output: codex_protocol::models::FunctionCallOutputPayload { body: codex_protocol::models::FunctionCallOutputBody::Text( diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 9443866c6f..3c0753cb43 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -920,6 +920,7 @@ pub enum ResponseItem { Message { #[serde(default, skip_serializing)] #[ts(skip)] + #[schemars(skip)] id: Option, role: String, content: Vec, @@ -934,6 +935,10 @@ pub enum ResponseItem { metadata: Option, }, AgentMessage { + #[serde(default, skip_serializing)] + #[ts(skip)] + #[schemars(skip)] + id: Option, author: String, recipient: String, content: Vec, @@ -945,7 +950,7 @@ pub enum ResponseItem { #[serde(default, skip_serializing)] #[ts(skip)] #[schemars(skip)] - id: String, + id: Option, summary: Vec, #[serde(default, skip_serializing_if = "should_serialize_reasoning_content")] #[ts(optional)] @@ -959,6 +964,7 @@ pub enum ResponseItem { /// Legacy id field retained for compatibility with older payloads. #[serde(default, skip_serializing)] #[ts(skip)] + #[schemars(skip)] id: Option, /// Set when using the Responses API. call_id: Option, @@ -971,6 +977,7 @@ pub enum ResponseItem { FunctionCall { #[serde(default, skip_serializing)] #[ts(skip)] + #[schemars(skip)] id: Option, name: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -988,6 +995,7 @@ pub enum ResponseItem { ToolSearchCall { #[serde(default, skip_serializing)] #[ts(skip)] + #[schemars(skip)] id: Option, call_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1006,6 +1014,10 @@ pub enum ResponseItem { // - an array of structured content items (`content_items`) // We keep this behavior centralized in `FunctionCallOutputPayload`. FunctionCallOutput { + #[serde(default, skip_serializing)] + #[ts(skip)] + #[schemars(skip)] + id: Option, call_id: String, #[ts(as = "FunctionCallOutputBody")] #[schemars(with = "FunctionCallOutputBody")] @@ -1017,6 +1029,7 @@ pub enum ResponseItem { CustomToolCall { #[serde(default, skip_serializing)] #[ts(skip)] + #[schemars(skip)] id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1033,6 +1046,10 @@ pub enum ResponseItem { // `function_call_output.output` so freeform tools can return either plain // text or structured content items. CustomToolCallOutput { + #[serde(default, skip_serializing)] + #[ts(skip)] + #[schemars(skip)] + id: Option, call_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1045,6 +1062,10 @@ pub enum ResponseItem { metadata: Option, }, ToolSearchOutput { + #[serde(default, skip_serializing)] + #[ts(skip)] + #[schemars(skip)] + id: Option, call_id: Option, status: String, execution: String, @@ -1065,6 +1086,7 @@ pub enum ResponseItem { WebSearchCall { #[serde(default, skip_serializing)] #[ts(skip)] + #[schemars(skip)] id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1086,7 +1108,10 @@ pub enum ResponseItem { // "result":"..." // } ImageGenerationCall { - id: String, + /// Existing provider ID retained on serialized history for compatibility. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + id: Option, status: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1098,17 +1123,29 @@ pub enum ResponseItem { }, #[serde(alias = "compaction_summary")] Compaction { + #[serde(default, skip_serializing)] + #[ts(skip)] + #[schemars(skip)] + id: Option, encrypted_content: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] metadata: Option, }, CompactionTrigger { + #[serde(default, skip_serializing)] + #[ts(skip)] + #[schemars(skip)] + id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] metadata: Option, }, ContextCompaction { + #[serde(default, skip_serializing)] + #[ts(skip)] + #[schemars(skip)] + id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] encrypted_content: Option, @@ -1126,6 +1163,50 @@ impl ResponseItem { matches!(self, Self::Message { role, .. } if role == "user") } + /// Returns the non-empty Responses API item ID, if present. + pub fn id(&self) -> Option<&str> { + match self { + Self::Message { id, .. } + | Self::AgentMessage { id, .. } + | Self::LocalShellCall { id, .. } + | Self::FunctionCall { id, .. } + | Self::ToolSearchCall { id, .. } + | Self::FunctionCallOutput { id, .. } + | Self::CustomToolCall { id, .. } + | Self::CustomToolCallOutput { id, .. } + | Self::ToolSearchOutput { id, .. } + | Self::WebSearchCall { id, .. } + | Self::Reasoning { id, .. } + | Self::ImageGenerationCall { id, .. } + | Self::Compaction { id, .. } + | Self::CompactionTrigger { id, .. } + | Self::ContextCompaction { id, .. } => id.as_deref().filter(|id| !id.is_empty()), + Self::Other => None, + } + } + + /// Sets the Responses API item ID for variants that carry one. + pub fn set_id(&mut self, new_id: String) { + match self { + Self::Message { id, .. } + | Self::AgentMessage { id, .. } + | Self::LocalShellCall { id, .. } + | Self::FunctionCall { id, .. } + | Self::ToolSearchCall { id, .. } + | Self::FunctionCallOutput { id, .. } + | Self::CustomToolCall { id, .. } + | Self::CustomToolCallOutput { id, .. } + | Self::ToolSearchOutput { id, .. } + | Self::WebSearchCall { id, .. } + | Self::Reasoning { id, .. } + | Self::ImageGenerationCall { id, .. } + | Self::Compaction { id, .. } + | Self::CompactionTrigger { id, .. } + | Self::ContextCompaction { id, .. } => *id = Some(new_id), + Self::Other => {} + } + } + /// Returns the non-empty turn ID stamped onto this item, if present. pub fn turn_id(&self) -> Option<&str> { self.metadata() @@ -1168,7 +1249,7 @@ impl ResponseItem { | Self::WebSearchCall { metadata, .. } | Self::ImageGenerationCall { metadata, .. } | Self::Compaction { metadata, .. } - | Self::CompactionTrigger { metadata } + | Self::CompactionTrigger { metadata, .. } | Self::ContextCompaction { metadata, .. } => metadata.as_ref(), Self::Other => None, } @@ -1189,7 +1270,7 @@ impl ResponseItem { | Self::WebSearchCall { metadata, .. } | Self::ImageGenerationCall { metadata, .. } | Self::Compaction { metadata, .. } - | Self::CompactionTrigger { metadata } + | Self::CompactionTrigger { metadata, .. } | Self::ContextCompaction { metadata, .. } => Some(metadata), Self::Other => None, } @@ -1435,6 +1516,7 @@ impl From for ResponseItem { metadata: None, }, ResponseInputItem::FunctionCallOutput { call_id, output } => Self::FunctionCallOutput { + id: None, call_id, output, metadata: None, @@ -1442,6 +1524,7 @@ impl From for ResponseItem { ResponseInputItem::McpToolCallOutput { call_id, output } => { let output = output.into_function_call_output_payload(); Self::FunctionCallOutput { + id: None, call_id, output, metadata: None, @@ -1452,6 +1535,7 @@ impl From for ResponseItem { name, output, } => Self::CustomToolCallOutput { + id: None, call_id, name, output, @@ -1467,6 +1551,7 @@ impl From for ResponseItem { status, execution, tools, + id: None, metadata: None, }, } @@ -2060,6 +2145,16 @@ mod tests { Ok(()) } + #[test] + fn response_item_id_getter_and_setter() { + let mut item = response_item_with_metadata(/*metadata*/ None); + assert_eq!(item.id(), None); + + item.set_id("msg_test".to_string()); + + assert_eq!(item.id(), Some("msg_test")); + } + fn response_item_with_metadata(metadata: Option) -> ResponseItem { ResponseItem::Message { id: None, @@ -2176,7 +2271,7 @@ mod tests { assert_eq!( item, ResponseItem::ImageGenerationCall { - id: "ig_123".to_string(), + id: Some("ig_123".to_string()), status: "completed".to_string(), revised_prompt: Some("A small blue square".to_string()), result: "Zm9v".to_string(), @@ -2198,7 +2293,7 @@ mod tests { assert_eq!( item, ResponseItem::ImageGenerationCall { - id: "ig_123".to_string(), + id: Some("ig_123".to_string()), status: "completed".to_string(), revised_prompt: None, result: "Zm9v".to_string(), @@ -2900,6 +2995,7 @@ mod tests { assert_eq!( item, ResponseItem::Compaction { + id: None, encrypted_content: "abc".into(), metadata: None, } @@ -2916,6 +3012,7 @@ mod tests { assert_eq!( item, ResponseItem::ContextCompaction { + id: None, encrypted_content: Some("abc".into()), metadata: None, } @@ -2925,7 +3022,10 @@ mod tests { #[test] fn serializes_compaction_trigger_without_payload() -> Result<()> { - let item = ResponseItem::CompactionTrigger { metadata: None }; + let item = ResponseItem::CompactionTrigger { + id: None, + metadata: None, + }; assert_eq!( serde_json::to_value(item)?, @@ -2938,7 +3038,10 @@ mod tests { #[test] fn serializes_stamped_compaction_trigger_metadata() -> Result<()> { - let mut item = ResponseItem::CompactionTrigger { metadata: None }; + let mut item = ResponseItem::CompactionTrigger { + id: None, + metadata: None, + }; item.stamp_turn_id_if_missing("turn-1"); assert_eq!( @@ -2959,7 +3062,13 @@ mod tests { let item: ResponseItem = serde_json::from_str(json)?; - assert_eq!(item, ResponseItem::CompactionTrigger { metadata: None }); + assert_eq!( + item, + ResponseItem::CompactionTrigger { + id: None, + metadata: None, + } + ); Ok(()) } @@ -3188,6 +3297,7 @@ mod tests { assert_eq!( ResponseItem::from(input.clone()), ResponseItem::ToolSearchOutput { + id: None, call_id: Some("search-1".to_string()), status: "completed".to_string(), execution: "client".to_string(), @@ -3275,6 +3385,7 @@ mod tests { assert_eq!( parsed_output, ResponseItem::ToolSearchOutput { + id: None, call_id: None, status: "completed".to_string(), execution: "server".to_string(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 0a9271c9f0..a0e5b24d47 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -762,6 +762,7 @@ impl InterAgentCommunication { }], }; ResponseItem::AgentMessage { + id: None, author: self.author.to_string(), recipient: self.recipient.to_string(), content, @@ -4284,6 +4285,7 @@ mod tests { assert_eq!( communication.to_model_input_item(), ResponseItem::AgentMessage { + id: None, author: "/root/worker".to_string(), recipient: "/root".to_string(), content: vec![ diff --git a/codex-rs/rollout-trace/src/inference.rs b/codex-rs/rollout-trace/src/inference.rs index f826d9f47c..7329fd3cf2 100644 --- a/codex-rs/rollout-trace/src/inference.rs +++ b/codex-rs/rollout-trace/src/inference.rs @@ -496,7 +496,7 @@ mod tests { #[test] fn traced_response_item_preserves_reasoning_content_omitted_by_normal_serializer() { let item = ResponseItem::Reasoning { - id: "rs-1".to_string(), + id: Some("rs-1".to_string()), summary: vec![ReasoningItemReasoningSummary::SummaryText { text: "summary".to_string(), }], diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index 550e7d56bb..19a0d2955b 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -510,6 +510,7 @@ mod tests { memory_citation: None, })), RolloutItem::ResponseItem(ResponseItem::FunctionCallOutput { + id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload::from_text("tool output".to_string()), metadata: None, From f22d15b679a13fa8be815249ba1508e73a8c81ed Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 17 Jun 2026 22:12:02 -0400 Subject: [PATCH 014/112] fix(install): support older awk checksum parsing (#28784) ## Why The standalone installer validates package checksums with an awk interval expression. Older mawk releases do not support that expression, so they reject valid 64-character digests and report that the release manifest is missing an entry. This affects both x64 and ARM64 systems on common Debian-derived environments. Fixes #24219. ## What Changed Replace the awk interval expression with an explicit length check plus rejection of non-hexadecimal characters. This preserves the existing SHA-256 validation and lowercase normalization while working with older awk implementations. ## How to Test 1. Build and run the checksum predicate with mawk 1.3.4 20121129. 2. Confirm the old interval predicate rejects a valid 64-character digest. 3. Confirm the updated predicate accepts that digest. 4. Put the old mawk binary first on PATH as awk and run scripts/install/install.sh with an isolated HOME, CODEX_HOME, and CODEX_INSTALL_DIR. 5. Confirm Codex installs successfully and the installed binary reports version 0.140.0. 6. Verify the predicate rejects wrong-length digests, non-hexadecimal digests, and entries for another asset while accepting uppercase hexadecimal digests. --- scripts/install/install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/install/install.sh b/scripts/install/install.sh index 23980b0c5e..7513efc429 100755 --- a/scripts/install/install.sh +++ b/scripts/install/install.sh @@ -213,7 +213,7 @@ package_archive_digest() { manifest_path="$2" digest="$(awk -v asset="$asset" ' - $2 == asset && $1 ~ /^[0-9a-fA-F]{64}$/ { + $2 == asset && length($1) == 64 && $1 !~ /[^0-9a-fA-F]/ { print tolower($1) found = 1 exit From a306ac4ee31aaf72c6f9e7cb75663efdcc19a6f1 Mon Sep 17 00:00:00 2001 From: guinness-oai Date: Wed, 17 Jun 2026 19:13:38 -0700 Subject: [PATCH 015/112] [codex] Use unique IDs for realtime-routed turns (#28826) ## Why A durable realtime voice orchestrator can reconnect and resume through multiple fresh `Session` instances. Realtime handoffs were using the Session-local `auto-compact-N` counter as their turn identity, but that counter restarts at zero for every resumed Session. The durable thread could therefore accumulate duplicate turn IDs, violating the uniqueness assumptions made by app-server and web clients. In Codex Apps, a new delegated response stream could be attached to an older turn with the same ID, placing live output higher in history and putting turn-scoped actions at risk. Persisted rollout and reconstructed model-context order were already correct because raw response items remain append-only and chronological. This change restores unique identity for reconstructed and live turn surfaces. ## What changed - Generate a UUIDv7 specifically for each realtime-routed delegation. - Leave the existing `auto-compact-N` identity path unchanged for actual internal auto-compaction turns. - Extend the inbound realtime handoff integration test to require a UUID turn ID from `turn/started`. ## Verification - `just test -p codex-core inbound_handoff_request_starts_turn` - `just fix -p codex-core` - `just fmt` --- codex-rs/core/src/session/mod.rs | 2 +- codex-rs/core/tests/suite/realtime_conversation.rs | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index cf19123591..e21b5394d9 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1134,7 +1134,7 @@ impl Session { pub(crate) async fn route_realtime_text_input(self: &Arc, text: String) { handlers::user_input_or_turn_inner( self, - self.next_internal_sub_id(), + Uuid::now_v7().to_string(), Op::UserInput { items: vec![UserInput::Text { text, diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 61fc38e8b1..a2aa6d61d1 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -50,6 +50,7 @@ use std::sync::Mutex; use std::time::Duration; use tokio::sync::oneshot; use tokio::time::timeout; +use uuid::Uuid; use wiremock::Match; use wiremock::Mock; use wiremock::Request as WiremockRequest; @@ -3019,6 +3020,14 @@ async fn inbound_handoff_request_starts_turn() -> Result<()> { }) .await; + let turn_id = loop { + let event = test.codex.next_event().await?; + if let EventMsg::TurnStarted(turn_started) = event.msg { + break turn_started.turn_id; + } + }; + Uuid::parse_str(&turn_id).context("realtime-routed turn ID should be a UUID")?; + wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) }) From 683bd170dc36812f73867aa6f421a4a8cf4bd133 Mon Sep 17 00:00:00 2001 From: jiayuhuang-openai Date: Wed, 17 Jun 2026 19:22:29 -0700 Subject: [PATCH 016/112] [codex] control automatic realtime handoff delivery (#27986) ## What Built on the realtime speech-control plumbing merged in #27917. - Add optional `codexResponseHandoffPrefix` to `thread/realtime/start`. - Apply that prefix only to automatic V1 commentary sent through `conversation.handoff.append`; final answers remain unprefixed. - Add opt-in `clientManagedHandoffs`. When true, core suppresses automatic response handoffs and completion output so delivery is controlled by explicit client append APIs. - Preserve existing automatic behavior by default. `codexResponsesAsItems: true` continues to select item routing when client-managed mode is disabled. ## Why Voice clients need two delivery policies: automatic background context with silent commentary instructions and fully client-owned handoffs. Phase-aware prefixing keeps routine commentary silent without suppressing the final answer, while client-managed mode lets an app decide exactly which updates to append. ## Validation - `just fmt` - `cargo test -p codex-app-server-protocol serialize_thread_realtime_start` - `RUST_MIN_STACK=16777216 cargo test -p codex-core --test all conversation_handoff_persists_across_item_done_until_turn_complete` - `RUST_MIN_STACK=16777216 cargo test -p codex-app-server --test all webrtc_v1_client_managed_handoffs_disable_automatic_output` - `RUST_MIN_STACK=16777216 cargo test -p codex-app-server --test all webrtc_v1_final_automatic_handoff_omits_silent_prefix` - `cargo build -p codex-cli --bin codex` - Local Codex Apps compatibility check: 43 focused webview tests passed, and a live voice session routed through the source-built app-server. The explicit `RUST_MIN_STACK` avoids a macOS Tokio test-worker stack overflow seen with the default test environment. --- .../src/protocol/common.rs | 14 ++ .../src/protocol/v2/realtime.rs | 9 + codex-rs/app-server/README.md | 11 +- .../src/request_processors/turn_processor.rs | 2 + .../tests/suite/v2/experimental_api.rs | 4 + .../tests/suite/v2/realtime_conversation.rs | 160 +++++++++++++++++- codex-rs/core/src/realtime_conversation.rs | 64 ++++++- .../core/src/realtime_conversation_tests.rs | 2 + codex-rs/core/src/session/mod.rs | 4 +- codex-rs/core/src/session/turn.rs | 6 +- codex-rs/core/tests/suite/compact_remote.rs | 2 + .../core/tests/suite/realtime_conversation.rs | 95 +++++++++-- codex-rs/protocol/src/protocol.rs | 6 + 13 files changed, 355 insertions(+), 24 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index f22cab25f1..edeabfc7ce 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -3082,8 +3082,10 @@ mod tests { request_id: RequestId::Integer(9), params: v2::ThreadRealtimeStartParams { architecture: Some(RealtimeConversationArchitecture::Avas), + client_managed_handoffs: Some(true), codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: Some("silent context".to_string()), thread_id: "thr_123".to_string(), model: Some("realtime-treatment-model".to_string()), output_modality: RealtimeOutputModality::Audio, @@ -3102,8 +3104,10 @@ mod tests { "params": { "architecture": "avas", "threadId": "thr_123", + "clientManagedHandoffs": true, "codexResponsesAsItems": null, "codexResponseItemPrefix": null, + "codexResponseHandoffPrefix": "silent context", "model": "realtime-treatment-model", "outputModality": "audio", "includeStartupContext": false, @@ -3125,8 +3129,10 @@ mod tests { request_id: RequestId::Integer(9), params: v2::ThreadRealtimeStartParams { architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: "thr_123".to_string(), model: None, output_modality: RealtimeOutputModality::Audio, @@ -3145,8 +3151,10 @@ mod tests { "params": { "architecture": null, "threadId": "thr_123", + "clientManagedHandoffs": null, "codexResponsesAsItems": null, "codexResponseItemPrefix": null, + "codexResponseHandoffPrefix": null, "model": null, "outputModality": "audio", "includeStartupContext": null, @@ -3163,8 +3171,10 @@ mod tests { request_id: RequestId::Integer(9), params: v2::ThreadRealtimeStartParams { architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: "thr_123".to_string(), model: None, output_modality: RealtimeOutputModality::Audio, @@ -3183,8 +3193,10 @@ mod tests { "params": { "architecture": null, "threadId": "thr_123", + "clientManagedHandoffs": null, "codexResponsesAsItems": null, "codexResponseItemPrefix": null, + "codexResponseHandoffPrefix": null, "model": null, "outputModality": "audio", "includeStartupContext": null, @@ -3367,8 +3379,10 @@ mod tests { request_id: RequestId::Integer(1), params: v2::ThreadRealtimeStartParams { architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: "thr_123".to_string(), model: None, output_modality: RealtimeOutputModality::Audio, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs b/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs index 793b316f53..b30ecd7b62 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs @@ -70,12 +70,21 @@ pub struct ThreadRealtimeStartParams { /// Overrides the configured realtime architecture for this session only. #[ts(optional = nullable)] pub architecture: Option, + /// Leaves Codex response handoffs to the client's explicit append calls instead of forwarding + /// them automatically. Defaults to false. + #[ts(optional = nullable)] + pub client_managed_handoffs: Option, /// Sends automatic Codex responses as realtime conversation items instead of handoff appends. #[ts(optional = nullable)] pub codex_responses_as_items: Option, /// Optional prefix added to automatic Codex response items when `codexResponsesAsItems` is true. #[ts(optional = nullable)] pub codex_response_item_prefix: Option, + /// Optional prefix added to automatic V1 Codex commentary sent with + /// `conversation.handoff.append` when `codexResponsesAsItems` is not true. Final answers are + /// sent without the prefix. + #[ts(optional = nullable)] + pub codex_response_handoff_prefix: Option, /// Overrides the configured realtime model for this session only. #[ts(optional = nullable)] pub model: Option, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index d342313917..7dde50d193 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -165,7 +165,7 @@ Example with notification opt-out: - `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success. - `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Review and manual compaction turns reject `turn/steer`. - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. -- `thread/realtime/start` — start a thread-scoped realtime session (experimental); pass `outputModality: "text"` or `outputModality: "audio"` to choose model output, optionally pass `model` and `version` to override configured realtime selection for this session only, and pass `includeStartupContext: false` to omit Codex's generated startup context. By default, automatic Codex text follows the protocol's speakable output path. Pass `codexResponsesAsItems: true` to send automatic Codex responses as realtime conversation items instead, and optionally pass `codexResponseItemPrefix` to prepend experiment instructions to those items. Returns `{}` and streams `thread/realtime/*` notifications. Omit `transport` for the websocket transport, or pass `{ "type": "webrtc", "sdp": "..." }` to create a WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`. +- `thread/realtime/start` — start a thread-scoped realtime session (experimental); pass `outputModality: "text"` or `outputModality: "audio"` to choose model output, optionally pass `model` and `version` to override configured realtime selection for this session only, and pass `includeStartupContext: false` to omit Codex's generated startup context. By default, automatic Codex text follows the protocol's speakable output path. Pass `clientManagedHandoffs: true` to disable automatic Codex response delivery so only the client's explicit append calls produce handoffs. Pass `codexResponsesAsItems: true` to send automatic Codex responses as realtime conversation items instead, and optionally pass `codexResponseItemPrefix` to prepend experiment instructions to those items. For V1 sessions, pass `codexResponseHandoffPrefix` while item mode is disabled to route automatic Codex commentary through `conversation.handoff.append` with that prefix; final answers remain unprefixed. Returns `{}` and streams `thread/realtime/*` notifications. Omit `transport` for the websocket transport, or pass `{ "type": "webrtc", "sdp": "..." }` to create a WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`. - `thread/realtime/appendAudio` — append an input audio chunk to the active realtime session (experimental); returns `{}`. - `thread/realtime/appendText` — append text input to the active realtime session with a required `role` of `user` or `developer` (experimental); returns `{}`. Older clients that omit `role` default to `user`. - `thread/realtime/appendSpeech` — append text that the realtime model should speak to the user (experimental); returns `{}`. @@ -882,12 +882,19 @@ Clients may also pass `model` and `version` on `thread/realtime/start` to select different realtime session configuration without changing thread or user config. Pass `includeStartupContext: false` to skip Codex's startup context for this session while still using the selected backend prompt. +Pass `clientManagedHandoffs: true` to suppress automatic Codex response handoffs +and items. The client can then choose which updates to deliver with +`thread/realtime/appendText` or `thread/realtime/appendSpeech`. Pass `codexResponsesAsItems: true` to inject automatic Codex responses with `conversation.item.create` instead of the protocol's default speakable output path. When using that mode, `codexResponseItemPrefix` can prepend short experiment instructions to each automatic Codex response item. Omit `codexResponsesAsItems`, or pass `false`, to preserve the default speakable -behavior. Call +behavior. For V1 sessions, `codexResponseHandoffPrefix` instead routes automatic +Codex commentary through `conversation.handoff.append` and prepends the provided +text. Final answers remain unprefixed. Item mode takes precedence when +`codexResponsesAsItems` is true. +Call `thread/realtime/appendText` to append app-provided realtime text items, or `thread/realtime/appendSpeech` when the app decides a realtime update should be spoken. diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index d48e8c04b5..fbfcb797b6 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -931,8 +931,10 @@ impl TurnRequestProcessor { thread.as_ref(), Op::RealtimeConversationStart(ConversationStartParams { architecture: params.architecture, + client_managed_handoffs: params.client_managed_handoffs.unwrap_or(false), codex_responses_as_items: params.codex_responses_as_items.unwrap_or(false), codex_response_item_prefix: params.codex_response_item_prefix, + codex_response_handoff_prefix: params.codex_response_handoff_prefix, model: params.model, output_modality: params.output_modality, include_startup_context: params.include_startup_context.unwrap_or(true), diff --git a/codex-rs/app-server/tests/suite/v2/experimental_api.rs b/codex-rs/app-server/tests/suite/v2/experimental_api.rs index b12bd43b43..3b4f6e949a 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_api.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_api.rs @@ -80,8 +80,10 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R let request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: "thr_123".to_string(), model: None, output_modality: RealtimeOutputModality::Audio, @@ -192,8 +194,10 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result< let request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: "thr_123".to_string(), model: None, output_modality: RealtimeOutputModality::Audio, diff --git a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index f88ed799a0..274ee9b90c 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -86,6 +86,8 @@ const V2_HANDOFF_COMPLETE_ACKNOWLEDGEMENT: &str = "Background agent finished. Use the preceding [BACKEND] messages as the result."; const RESPONSE_ITEM_PREFIX: &str = "Use the following context to inform future responses, but do not speak it to the user."; +const RESPONSE_HANDOFF_PREFIX: &str = + "Silent Codex context. Do not speak, acknowledge, or summarize this item."; #[derive(Debug, Clone, Copy)] enum StartupContextConfig<'a> { @@ -313,8 +315,9 @@ impl RealtimeE2eHarness { } async fn start_webrtc_realtime(&mut self, offer_sdp: &str) -> Result { - self.start_webrtc_realtime_with_codex_responses_as_items( - offer_sdp, /*codex_responses_as_items*/ None, + self.start_webrtc_realtime_with_codex_response_routing( + offer_sdp, /*client_managed_handoffs*/ None, + /*codex_responses_as_items*/ None, /*codex_response_handoff_prefix*/ None, ) .await } @@ -323,17 +326,21 @@ impl RealtimeE2eHarness { &mut self, offer_sdp: &str, ) -> Result { - self.start_webrtc_realtime_with_codex_responses_as_items( + self.start_webrtc_realtime_with_codex_response_routing( offer_sdp, + /*client_managed_handoffs*/ None, /*codex_responses_as_items*/ Some(true), + /*codex_response_handoff_prefix*/ None, ) .await } - async fn start_webrtc_realtime_with_codex_responses_as_items( + async fn start_webrtc_realtime_with_codex_response_routing( &mut self, offer_sdp: &str, + client_managed_handoffs: Option, codex_responses_as_items: Option, + codex_response_handoff_prefix: Option<&str>, ) -> Result { // Starts realtime through the public JSON-RPC method, then waits for the same client-visible // notifications a desktop app needs: started first, SDP answer second. @@ -341,10 +348,12 @@ impl RealtimeE2eHarness { .mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { architecture: None, + client_managed_handoffs, thread_id: self.thread_id.clone(), codex_response_item_prefix: codex_responses_as_items .unwrap_or(false) .then(|| RESPONSE_ITEM_PREFIX.to_string()), + codex_response_handoff_prefix: codex_response_handoff_prefix.map(str::to_string), codex_responses_as_items, model: None, output_modality: RealtimeOutputModality::Audio, @@ -611,8 +620,10 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: thread_start.thread.id.clone(), model: Some("realtime-treatment-model".to_string()), output_modality: RealtimeOutputModality::Audio, @@ -867,8 +878,10 @@ async fn realtime_start_can_skip_startup_context() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: thread_start.thread.id.clone(), model: None, output_modality: RealtimeOutputModality::Audio, @@ -964,8 +977,10 @@ async fn realtime_text_output_modality_requests_text_output_and_final_transcript let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: thread_start.thread.id.clone(), model: None, output_modality: RealtimeOutputModality::Text, @@ -1144,8 +1159,10 @@ async fn realtime_conversation_stop_emits_closed_notification() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: thread_start.thread.id.clone(), model: None, output_modality: RealtimeOutputModality::Audio, @@ -1247,8 +1264,10 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: thread_id.clone(), model: None, output_modality: RealtimeOutputModality::Audio, @@ -1480,6 +1499,135 @@ async fn webrtc_v1_default_automatic_output_uses_handoff_append() -> Result<()> Ok(()) } +#[tokio::test] +async fn webrtc_v1_client_managed_handoffs_disable_automatic_output() -> Result<()> { + skip_if_no_network!(Ok(())); + + let mut harness = RealtimeE2eHarness::new( + RealtimeTestVersion::V1, + main_loop_responses(vec![create_final_assistant_message_sse_response( + "client-managed output", + )?]), + realtime_sideband(vec![realtime_sideband_connection(vec![ + vec![session_updated("sess_v1_client_managed_handoffs")], + vec![], + ])]), + ) + .await?; + + let started = harness + .start_webrtc_realtime_with_codex_response_routing( + "v=offer\r\n", + /*client_managed_handoffs*/ Some(true), + /*codex_responses_as_items*/ None, + /*codex_response_handoff_prefix*/ None, + ) + .await?; + assert_eq!(started.started.version, RealtimeConversationVersion::V1); + assert_v1_session_update(&harness.sideband_outbound_request(/*request_index*/ 0).await)?; + + let turn_request_id = harness + .mcp + .send_turn_start_request(TurnStartParams { + thread_id: harness.thread_id.clone(), + input: vec![V2UserInput::Text { + text: "leave realtime delivery to the client".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + harness + .mcp + .read_stream_until_response_message(RequestId::Integer(turn_request_id)), + ) + .await??; + let _: TurnStartResponse = to_response(turn_response)?; + let _ = harness + .read_notification::("turn/completed") + .await?; + + let automatic_handoff = timeout( + Duration::from_millis(200), + harness + .realtime_server + .wait_for_request(/*connection_index*/ 0, /*request_index*/ 1), + ) + .await; + assert!( + automatic_handoff.is_err(), + "automatic Codex output should not reach realtime in client-managed handoff mode" + ); + + harness + .append_speech(harness.thread_id.clone(), "client-selected speech") + .await?; + assert_eq!( + harness.sideband_outbound_request(/*request_index*/ 1).await, + json!({ + "type": "conversation.handoff.append", + "handoff_id": "codex", + "output_text": "client-selected speech", + }) + ); + + harness.shutdown().await; + Ok(()) +} + +#[tokio::test] +async fn webrtc_v1_final_automatic_handoff_omits_silent_prefix() -> Result<()> { + skip_if_no_network!(Ok(())); + + let mut harness = RealtimeE2eHarness::new( + RealtimeTestVersion::V1, + main_loop_responses(vec![create_final_assistant_message_sse_response( + "background progress", + )?]), + realtime_sideband(vec![realtime_sideband_connection(vec![ + vec![ + session_updated("sess_v1_prefixed_handoff"), + json!({ + "type": "conversation.handoff.requested", + "handoff_id": "handoff_prefixed", + "item_id": "item_prefixed", + "input_transcript": "run the background task" + }), + ], + vec![], + vec![], + ])]), + ) + .await?; + + let started = harness + .start_webrtc_realtime_with_codex_response_routing( + "v=offer\r\n", + /*client_managed_handoffs*/ None, + /*codex_responses_as_items*/ None, + Some(RESPONSE_HANDOFF_PREFIX), + ) + .await?; + assert_eq!(started.started.version, RealtimeConversationVersion::V1); + let _ = harness + .read_notification::("turn/completed") + .await?; + + assert_eq!( + harness.sideband_outbound_request(/*request_index*/ 1).await, + json!({ + "type": "conversation.handoff.append", + "handoff_id": "handoff_prefixed", + "output_text": "background progress", + }) + ); + + harness.shutdown().await; + Ok(()) +} + #[tokio::test] async fn webrtc_v1_handoff_request_delegates_context_and_manual_append_speaks() -> Result<()> { skip_if_no_network!(Ok(())); @@ -2468,8 +2616,10 @@ async fn realtime_webrtc_start_surfaces_backend_error() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: thread_start.thread.id, model: None, output_modality: RealtimeOutputModality::Audio, @@ -2533,8 +2683,10 @@ async fn realtime_conversation_requires_feature_flag() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { architecture: None, + client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, thread_id: thread_start.thread.id.clone(), model: None, output_modality: RealtimeOutputModality::Audio, diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index 2b5eb91a23..b684228466 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -30,6 +30,7 @@ use codex_login::read_openai_api_key_from_env; use codex_model_provider_info::ModelProviderInfo; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; +use codex_protocol::models::MessagePhase; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::ConversationAudioParams; use codex_protocol::protocol::ConversationSpeechParams; @@ -107,8 +108,10 @@ struct RealtimeHandoffState { output_tx: Sender, active_handoff: Arc>>, last_output_text: Arc>>, + client_managed_handoffs: bool, codex_responses_as_items: bool, codex_response_item_prefix: Option, + codex_response_handoff_prefix: Option, session_kind: RealtimeSessionKind, } @@ -116,6 +119,7 @@ struct RealtimeHandoffState { enum RealtimeOutbound { StandaloneHandoff { text: String }, HandoffUpdate { handoff_id: String, text: String }, + HandoffAppend { handoff_id: String, text: String }, CompletedHandoff { handoff_id: String, text: String }, ConversationItem { text: String }, HandoffCompleteAck { handoff_id: String }, @@ -210,16 +214,20 @@ struct RealtimeInputChannels { impl RealtimeHandoffState { fn new( output_tx: Sender, + client_managed_handoffs: bool, codex_responses_as_items: bool, codex_response_item_prefix: Option, + codex_response_handoff_prefix: Option, session_kind: RealtimeSessionKind, ) -> Self { Self { output_tx, active_handoff: Arc::new(Mutex::new(None)), last_output_text: Arc::new(Mutex::new(None)), + client_managed_handoffs, codex_responses_as_items, codex_response_item_prefix, + codex_response_handoff_prefix, session_kind, } } @@ -240,8 +248,10 @@ struct RealtimeStart { api_provider: ApiProvider, architecture: RealtimeConversationArchitecture, extra_headers: Option, + client_managed_handoffs: bool, codex_responses_as_items: bool, codex_response_item_prefix: Option, + codex_response_handoff_prefix: Option, realtime_call_api_provider: Option, session_config: RealtimeSessionConfig, model_client: ModelClient, @@ -296,8 +306,10 @@ impl RealtimeConversationManager { api_provider, architecture, extra_headers, + client_managed_handoffs, codex_responses_as_items, codex_response_item_prefix, + codex_response_handoff_prefix, realtime_call_api_provider, session_config, model_client, @@ -321,8 +333,10 @@ impl RealtimeConversationManager { let realtime_active = Arc::new(AtomicBool::new(true)); let handoff = RealtimeHandoffState::new( handoff_output_tx, + client_managed_handoffs, codex_responses_as_items, codex_response_item_prefix, + codex_response_handoff_prefix, session_kind, ); let input_channels = RealtimeInputChannels { @@ -479,7 +493,11 @@ impl RealtimeConversationManager { Ok(()) } - pub(crate) async fn handoff_out(&self, output_text: String) -> CodexResult<()> { + pub(crate) async fn handoff_out( + &self, + output_text: String, + phase: Option, + ) -> CodexResult<()> { let handoff = { let guard = self.state.lock().await; let Some(state) = guard.as_ref() else { @@ -490,6 +508,13 @@ impl RealtimeConversationManager { state.handoff.clone() }; + if handoff.client_managed_handoffs { + return Ok(()); + } + let response_handoff_prefix = match phase { + Some(MessagePhase::Commentary) => handoff.codex_response_handoff_prefix.clone(), + Some(MessagePhase::FinalAnswer) | None => None, + }; let active_handoff = handoff.active_handoff.lock().await.clone(); let output = match active_handoff { Some(handoff_id) => { @@ -502,6 +527,16 @@ impl RealtimeConversationManager { handoff.codex_response_item_prefix.as_deref(), ), } + } else if handoff.session_kind == RealtimeSessionKind::V1 + && handoff.codex_response_handoff_prefix.is_some() + { + RealtimeOutbound::HandoffAppend { + handoff_id, + text: realtime_backend_item( + output_text, + response_handoff_prefix.as_deref(), + ), + } } else { RealtimeOutbound::HandoffUpdate { handoff_id, @@ -520,7 +555,13 @@ impl RealtimeConversationManager { ), } } else { - RealtimeOutbound::StandaloneHandoff { text: output_text } + RealtimeOutbound::StandaloneHandoff { + text: if handoff.session_kind == RealtimeSessionKind::V1 { + realtime_backend_item(output_text, response_handoff_prefix.as_deref()) + } else { + output_text + }, + } } } }; @@ -565,6 +606,9 @@ impl RealtimeConversationManager { let Some(handoff) = handoff else { return Ok(()); }; + if handoff.client_managed_handoffs { + return Ok(()); + } match handoff.session_kind { RealtimeSessionKind::V1 => return Ok(()), RealtimeSessionKind::V2 => {} @@ -675,8 +719,10 @@ struct PreparedRealtimeConversationStart { api_provider: ApiProvider, architecture: RealtimeConversationArchitecture, extra_headers: Option, + client_managed_handoffs: bool, codex_responses_as_items: bool, codex_response_item_prefix: Option, + codex_response_handoff_prefix: Option, realtime_call_api_provider: Option, requested_realtime_session_id: Option, version: RealtimeWsVersion, @@ -744,8 +790,10 @@ async fn prepare_realtime_start( api_provider, architecture, extra_headers, + client_managed_handoffs: params.client_managed_handoffs, codex_responses_as_items: params.codex_responses_as_items, codex_response_item_prefix: params.codex_response_item_prefix, + codex_response_handoff_prefix: params.codex_response_handoff_prefix, realtime_call_api_provider, requested_realtime_session_id, version, @@ -914,8 +962,10 @@ async fn handle_start_inner( api_provider, architecture, extra_headers, + client_managed_handoffs, codex_responses_as_items, codex_response_item_prefix, + codex_response_handoff_prefix, realtime_call_api_provider, requested_realtime_session_id, version, @@ -931,8 +981,10 @@ async fn handle_start_inner( api_provider, architecture, extra_headers, + client_managed_handoffs, codex_responses_as_items, codex_response_item_prefix, + codex_response_handoff_prefix, realtime_call_api_provider, session_config, model_client: sess.services.model_client.clone(), @@ -1368,6 +1420,11 @@ async fn handle_handoff_output( .send_conversation_function_call_output(handoff_id, text) .await } + RealtimeOutbound::HandoffAppend { handoff_id, text } => { + writer + .send_conversation_handoff_append(handoff_id, text) + .await + } RealtimeOutbound::ConversationItem { text } => { writer .send_conversation_item_create(text, ConversationTextRole::Developer) @@ -1388,7 +1445,8 @@ async fn handle_handoff_output( .await; } } - RealtimeOutbound::HandoffUpdate { handoff_id, text } => { + RealtimeOutbound::HandoffUpdate { handoff_id, text } + | RealtimeOutbound::HandoffAppend { handoff_id, text } => { let active_handoff = handoff_state.active_handoff.lock().await.clone(); match active_handoff { Some(active_handoff) if active_handoff == handoff_id => {} diff --git a/codex-rs/core/src/realtime_conversation_tests.rs b/codex-rs/core/src/realtime_conversation_tests.rs index 147e0cc123..55525774b3 100644 --- a/codex-rs/core/src/realtime_conversation_tests.rs +++ b/codex-rs/core/src/realtime_conversation_tests.rs @@ -130,8 +130,10 @@ async fn clears_active_handoff_explicitly() { let (tx, _rx) = bounded(1); let state = RealtimeHandoffState::new( tx, + /*client_managed_handoffs*/ false, /*codex_responses_as_items*/ false, /*codex_response_item_prefix*/ None, + /*codex_response_handoff_prefix*/ None, RealtimeSessionKind::V1, ); diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index e21b5394d9..0ecc706818 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1809,13 +1809,13 @@ impl Session { } async fn maybe_mirror_event_text_to_realtime(&self, msg: &EventMsg) { - let Some(text) = realtime_text_for_event(msg) else { + let Some((text, phase)) = realtime_text_for_event(msg) else { return; }; if self.conversation.running_state().await.is_none() { return; } - if let Err(err) = self.conversation.handoff_out(text).await { + if let Err(err) = self.conversation.handoff_out(text, phase).await { debug!("failed to mirror event text to realtime conversation: {err}"); } } diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 4beef792bb..257e741203 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1460,11 +1460,11 @@ fn agent_message_text(item: &codex_protocol::items::AgentMessageItem) -> String .collect() } -pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option { +pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option<(String, Option)> { match msg { - EventMsg::AgentMessage(event) => Some(event.message.clone()), + EventMsg::AgentMessage(event) => Some((event.message.clone(), event.phase.clone())), EventMsg::ItemCompleted(event) => match &event.item { - TurnItem::AgentMessage(item) => Some(agent_message_text(item)), + TurnItem::AgentMessage(item) => Some((agent_message_text(item), item.phase.clone())), _ => None, }, EventMsg::Error(_) diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 365cf35b02..40c01fa1bc 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -204,8 +204,10 @@ async fn start_realtime_conversation(codex: &codex_core::CodexThread) -> Result< codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index a2aa6d61d1..dccf460f0b 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -67,6 +67,7 @@ const MEMORY_PROMPT_PHRASE: &str = "You have access to a memory folder with guidance from prior runs."; const REALTIME_CONVERSATION_TEST_SUBPROCESS_ENV_VAR: &str = "CODEX_REALTIME_CONVERSATION_TEST_SUBPROCESS"; +const SILENT_CONTEXT_PREFIX: &str = "[BACKEND] Silent Codex context. Do not speak, acknowledge, or summarize this item. Wait for an explicit speakable handoff or direct user request."; #[derive(Debug, Clone)] struct RealtimeCallRequestCapture { @@ -285,8 +286,10 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -430,8 +433,10 @@ async fn conversation_start_defaults_to_v2_and_gpt_realtime_1_5() -> Result<()> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -524,8 +529,10 @@ async fn conversation_webrtc_start_posts_generated_session() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: Some("session-override-model".to_string()), output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -707,8 +714,10 @@ async fn conversation_webrtc_start_uses_avas_architecture_query() -> Result<()> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: Some(RealtimeConversationArchitecture::Avas), + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -808,8 +817,10 @@ async fn conversation_webrtc_start_uses_configured_call_base_url_for_avas() -> R test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: Some(RealtimeConversationArchitecture::Avas), + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -901,8 +912,10 @@ async fn conversation_webrtc_close_while_sideband_connecting_drops_pending_join( test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -991,8 +1004,10 @@ async fn conversation_webrtc_sideband_connect_failure_closes_with_error() -> Res test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1083,8 +1098,10 @@ async fn conversation_start_uses_openai_env_key_fallback_with_chatgpt_auth() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1155,8 +1172,10 @@ async fn conversation_transport_close_emits_closed_event() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1251,8 +1270,10 @@ async fn conversation_start_preflight_failure_emits_realtime_error_only() -> Res test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1301,8 +1322,10 @@ async fn conversation_start_connect_failure_emits_realtime_error_only() -> Resul test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1399,8 +1422,10 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1428,8 +1453,10 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1528,8 +1555,10 @@ async fn conversation_uses_experimental_realtime_ws_base_url_override() -> Resul test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1596,8 +1625,10 @@ async fn conversation_uses_default_realtime_backend_prompt() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1672,8 +1703,10 @@ async fn conversation_uses_empty_instructions_for_null_or_empty_prompt() -> Resu test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1741,8 +1774,10 @@ async fn conversation_uses_explicit_start_voice() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1802,8 +1837,10 @@ async fn conversation_uses_configured_realtime_voice() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1851,8 +1888,10 @@ async fn conversation_rejects_voice_for_wrong_realtime_version() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1901,8 +1940,10 @@ async fn conversation_uses_experimental_realtime_ws_backend_prompt_override() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -1977,8 +2018,10 @@ async fn conversation_uses_experimental_realtime_ws_startup_context_override() - test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -2047,8 +2090,10 @@ async fn conversation_disables_realtime_startup_context_with_empty_override() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -2110,8 +2155,10 @@ async fn conversation_start_injects_startup_context_from_thread_history() -> Res test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -2227,8 +2274,10 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -2338,8 +2387,10 @@ async fn conversation_startup_context_falls_back_to_workspace_map() -> Result<() test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -2401,8 +2452,10 @@ async fn conversation_startup_context_is_truncated_and_sent_once_per_start() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -2485,8 +2538,10 @@ async fn conversation_user_text_turn_is_not_sent_to_realtime() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -2585,8 +2640,10 @@ async fn realtime_v2_noop_tool_call_returns_empty_function_output_without_respon test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -2687,8 +2744,10 @@ async fn conversation_mirrors_assistant_message_text_to_realtime_handoff() -> Re test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -2764,6 +2823,10 @@ async fn conversation_handoff_persists_across_item_done_until_turn_complete() -> skip_if_no_network!(Ok(())); let (gate_second_message_tx, gate_second_message_rx) = oneshot::channel(); + let mut commentary_message = responses::ev_assistant_message("msg-1", "assistant message 1"); + commentary_message["item"]["phase"] = json!("commentary"); + let mut final_message = responses::ev_assistant_message("msg-2", "assistant message 2"); + final_message["item"]["phase"] = json!("final_answer"); let first_chunks = vec![ StreamingSseChunk { gate: None, @@ -2771,17 +2834,11 @@ async fn conversation_handoff_persists_across_item_done_until_turn_complete() -> }, StreamingSseChunk { gate: None, - body: sse_event(responses::ev_assistant_message( - "msg-1", - "assistant message 1", - )), + body: sse_event(commentary_message), }, StreamingSseChunk { gate: Some(gate_second_message_rx), - body: sse_event(responses::ev_assistant_message( - "msg-2", - "assistant message 2", - )), + body: sse_event(final_message), }, StreamingSseChunk { gate: None, @@ -2827,8 +2884,10 @@ async fn conversation_handoff_persists_across_item_done_until_turn_complete() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: Some(SILENT_CONTEXT_PREFIX.to_string()), model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -2873,7 +2932,7 @@ async fn conversation_handoff_persists_across_item_done_until_turn_complete() -> ); assert_eq!( first_append.body_json()["output_text"].as_str(), - Some("\"Agent Final Message\":\n\nassistant message 1") + Some(format!("{SILENT_CONTEXT_PREFIX}\n\nassistant message 1").as_str()) ); let _ = wait_for_event_match(&test.codex, |msg| match msg { @@ -2899,7 +2958,7 @@ async fn conversation_handoff_persists_across_item_done_until_turn_complete() -> ); assert_eq!( second_append.body_json()["output_text"].as_str(), - Some("\"Agent Final Message\":\n\nassistant message 2") + Some("assistant message 2") ); let completion = completions @@ -2982,8 +3041,10 @@ async fn inbound_handoff_request_starts_turn() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -3095,8 +3156,10 @@ async fn inbound_handoff_request_uses_active_transcript() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -3201,8 +3264,10 @@ async fn inbound_handoff_request_sends_transcript_delta_after_each_handoff() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -3305,8 +3370,10 @@ async fn inbound_conversation_item_does_not_start_turn_and_still_forwards_audio( test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -3431,8 +3498,10 @@ async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_au test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -3587,8 +3656,10 @@ async fn inbound_handoff_request_does_not_block_realtime_event_forwarding() -> R test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -3732,8 +3803,10 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, @@ -3888,8 +3961,10 @@ async fn inbound_handoff_request_starts_turn_and_does_not_block_realtime_audio() test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { architecture: None, + client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, + codex_response_handoff_prefix: None, model: None, output_modality: RealtimeOutputModality::Audio, include_startup_context: true, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index a0e5b24d47..f87d71b216 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -182,10 +182,16 @@ pub struct McpServerRefreshConfig { pub struct ConversationStartParams { /// Overrides the configured realtime architecture for this session only. pub architecture: Option, + /// Whether Codex response handoffs are managed through explicit client append calls. + pub client_managed_handoffs: bool, /// Sends automatic Codex responses as realtime conversation items instead of handoff appends. pub codex_responses_as_items: bool, /// Optional prefix added to automatic Codex response items when `codex_responses_as_items` is set. pub codex_response_item_prefix: Option, + /// Optional prefix added to automatic V1 Codex commentary sent with + /// `conversation.handoff.append` when `codex_responses_as_items` is not set. Final answers are + /// sent without the prefix. + pub codex_response_handoff_prefix: Option, /// Overrides the configured realtime model for this session only. pub model: Option, /// Selects whether the realtime session should produce text or audio output. From e922f46a0f863a9630ddf7dc60dcbadb36a6c28a Mon Sep 17 00:00:00 2001 From: guinness-oai Date: Wed, 17 Jun 2026 20:57:13 -0700 Subject: [PATCH 017/112] [codex] Support assistant realtime append text (#28836) ## Why Frontend realtime voice continuity needs to replay a tiny previous-session overlap as actual conversation items, including assistant text. The app-server `thread/realtime/appendText` API already carries a role through to the Rust realtime websocket layer, but the shared role enum only accepted `user` and `developer`. ## What Changed - Added `assistant` to `ConversationTextRole` and regenerated the app-server schema/type fixtures. - Added `output_text` as a realtime conversation content type. - Updated realtime websocket item creation so assistant appendText emits `content: [{ type: "output_text", text }]`, while user and developer continue to emit `input_text`. - Updated app-server docs and tests to cover assistant appendText alongside the existing developer role behavior. ## Validation - `just write-app-server-schema` - `just fmt` (first sandboxed attempt failed because `uv` could not access `~/.cache/uv`; reran with filesystem access and passed) - `just test -p codex-api` passed: 126/126 - `just test -p codex-app-server-protocol` passed: 239/239, including generated JSON/TypeScript fixture checks - `just test -p codex-app-server` was started locally but stopped per request after unrelated local sandbox/Seatbelt failures (`sandbox-exec: sandbox_apply: Operation not permitted`) and one missing local `codex` binary failure; CI should be faster and more authoritative for the full suite. --- .../schema/json/ClientRequest.json | 3 +- .../codex_app_server_protocol.schemas.json | 3 +- .../codex_app_server_protocol.v2.schemas.json | 3 +- .../schema/typescript/ConversationTextRole.ts | 2 +- codex-rs/app-server/README.md | 2 +- .../tests/suite/v2/realtime_conversation.rs | 44 +++++++++++-- .../endpoint/realtime_websocket/methods.rs | 64 +++++++++++++++++-- .../endpoint/realtime_websocket/methods_v1.rs | 9 ++- .../endpoint/realtime_websocket/methods_v2.rs | 9 ++- .../endpoint/realtime_websocket/protocol.rs | 1 + codex-rs/protocol/src/protocol.rs | 1 + 11 files changed, 123 insertions(+), 18 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7498994980..5ebb861de0 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -643,7 +643,8 @@ "ConversationTextRole": { "enum": [ "user", - "developer" + "developer", + "assistant" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 5b0c59c425..9a13e86523 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -8707,7 +8707,8 @@ "ConversationTextRole": { "enum": [ "user", - "developer" + "developer", + "assistant" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 45cfbd95c2..0f6084a993 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -4992,7 +4992,8 @@ "ConversationTextRole": { "enum": [ "user", - "developer" + "developer", + "assistant" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/ConversationTextRole.ts b/codex-rs/app-server-protocol/schema/typescript/ConversationTextRole.ts index 9cba89f837..a4d574b4db 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ConversationTextRole.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ConversationTextRole.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ConversationTextRole = "user" | "developer"; +export type ConversationTextRole = "user" | "developer" | "assistant"; diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 7dde50d193..a6c5ddab7d 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -167,7 +167,7 @@ Example with notification opt-out: - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. - `thread/realtime/start` — start a thread-scoped realtime session (experimental); pass `outputModality: "text"` or `outputModality: "audio"` to choose model output, optionally pass `model` and `version` to override configured realtime selection for this session only, and pass `includeStartupContext: false` to omit Codex's generated startup context. By default, automatic Codex text follows the protocol's speakable output path. Pass `clientManagedHandoffs: true` to disable automatic Codex response delivery so only the client's explicit append calls produce handoffs. Pass `codexResponsesAsItems: true` to send automatic Codex responses as realtime conversation items instead, and optionally pass `codexResponseItemPrefix` to prepend experiment instructions to those items. For V1 sessions, pass `codexResponseHandoffPrefix` while item mode is disabled to route automatic Codex commentary through `conversation.handoff.append` with that prefix; final answers remain unprefixed. Returns `{}` and streams `thread/realtime/*` notifications. Omit `transport` for the websocket transport, or pass `{ "type": "webrtc", "sdp": "..." }` to create a WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`. - `thread/realtime/appendAudio` — append an input audio chunk to the active realtime session (experimental); returns `{}`. -- `thread/realtime/appendText` — append text input to the active realtime session with a required `role` of `user` or `developer` (experimental); returns `{}`. Older clients that omit `role` default to `user`. +- `thread/realtime/appendText` — append text input to the active realtime session with a required `role` of `user`, `developer`, or `assistant` (experimental); returns `{}`. Older clients that omit `role` default to `user`. - `thread/realtime/appendSpeech` — append text that the realtime model should speak to the user (experimental); returns `{}`. - `thread/realtime/stop` — stop the active realtime session for the thread (experimental); returns `{}`. - `review/start` — kick off Codex’s automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review. diff --git a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index 274ee9b90c..c09278bb88 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -709,6 +709,20 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { .await??; let _: ThreadRealtimeAppendTextResponse = to_response(text_append_response)?; + let assistant_append_request_id = mcp + .send_thread_realtime_append_text_request(ThreadRealtimeAppendTextParams { + thread_id: started.thread_id.clone(), + text: "welcome back".to_string(), + role: ConversationTextRole::Assistant, + }) + .await?; + let assistant_append_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(assistant_append_request_id)), + ) + .await??; + let _: ThreadRealtimeAppendTextResponse = to_response(assistant_append_response)?; + let output_audio = read_notification::( &mut mcp, "thread/realtime/outputAudio/delta", @@ -790,7 +804,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { let connections = realtime_server.connections(); assert_eq!(connections.len(), 1); let connection = &connections[0]; - assert_eq!(connection.len(), 3); + assert_eq!(connection.len(), 4); assert_eq!( connection[0].body_json()["type"].as_str(), Some("session.update") @@ -799,13 +813,14 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { connection[0].body_json()["session"]["instructions"].as_str(), Some(startup_context_instructions.as_str()), ); - let text_request = connection + let text_requests = connection .iter() .map(WebSocketRequest::body_json) - .find(|request| request["type"] == "conversation.item.create") - .context("expected conversation item request")?; + .filter(|request| request["type"] == "conversation.item.create") + .collect::>(); + assert_eq!(text_requests.len(), 2); assert_eq!( - text_request, + text_requests[0], json!({ "type": "conversation.item.create", "item": { @@ -818,6 +833,20 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { }, }) ); + assert_eq!( + text_requests[1], + json!({ + "type": "conversation.item.create", + "item": { + "type": "message", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": "welcome back", + }], + }, + }) + ); let mut request_types = [ connection[1].body_json()["type"] .as_str() @@ -827,11 +856,16 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { .as_str() .context("expected websocket request type")? .to_string(), + connection[3].body_json()["type"] + .as_str() + .context("expected websocket request type")? + .to_string(), ]; request_types.sort(); assert_eq!( request_types, [ + "conversation.item.create".to_string(), "conversation.item.create".to_string(), "input_audio_buffer.append".to_string(), ] diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs index 0aa2feb04e..499635579f 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs @@ -1637,10 +1637,29 @@ mod tests { .into_text() .expect("text"); let fourth_json: Value = serde_json::from_str(&fourth).expect("json"); - assert_eq!(fourth_json["type"], "conversation.handoff.append"); - assert_eq!(fourth_json["handoff_id"], "handoff_1"); + assert_eq!(fourth_json["type"], "conversation.item.create"); + assert_eq!(fourth_json["item"]["role"], "assistant"); assert_eq!( - fourth_json["output_text"], + fourth_json["item"]["content"][0]["type"], + Value::String("output_text".to_string()) + ); + assert_eq!( + fourth_json["item"]["content"][0]["text"], + Value::String("assistant context".to_string()) + ); + + let fifth = ws + .next() + .await + .expect("fifth msg") + .expect("fifth msg ok") + .into_text() + .expect("text"); + let fifth_json: Value = serde_json::from_str(&fifth).expect("json"); + assert_eq!(fifth_json["type"], "conversation.handoff.append"); + assert_eq!(fifth_json["handoff_id"], "handoff_1"); + assert_eq!( + fifth_json["output_text"], "\"Agent Final Message\":\n\nhello from background agent" ); @@ -1766,6 +1785,13 @@ mod tests { ) .await .expect("send item"); + connection + .send_conversation_item_create( + "assistant context".to_string(), + ConversationTextRole::Assistant, + ) + .await + .expect("send assistant item"); connection .send_conversation_function_call_output( "handoff_1".to_string(), @@ -1988,16 +2014,35 @@ mod tests { .expect("text"); let third_json: Value = serde_json::from_str(&third).expect("json"); assert_eq!(third_json["type"], "conversation.item.create"); + assert_eq!(third_json["item"]["role"], "assistant"); assert_eq!( - third_json["item"]["type"], + third_json["item"]["content"][0]["type"], + Value::String("output_text".to_string()) + ); + assert_eq!( + third_json["item"]["content"][0]["text"], + Value::String("assistant context".to_string()) + ); + + let fourth = ws + .next() + .await + .expect("fourth msg") + .expect("fourth msg ok") + .into_text() + .expect("text"); + let fourth_json: Value = serde_json::from_str(&fourth).expect("json"); + assert_eq!(fourth_json["type"], "conversation.item.create"); + assert_eq!( + fourth_json["item"]["type"], Value::String("function_call_output".to_string()) ); assert_eq!( - third_json["item"]["call_id"], + fourth_json["item"]["call_id"], Value::String("call_1".to_string()) ); assert_eq!( - third_json["item"]["output"], + fourth_json["item"]["output"], Value::String("delegated result".to_string()) ); }); @@ -2054,6 +2099,13 @@ mod tests { ) .await .expect("send text item"); + connection + .send_conversation_item_create( + "assistant context".to_string(), + ConversationTextRole::Assistant, + ) + .await + .expect("send assistant item"); connection .send_conversation_function_call_output( "call_1".to_string(), diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v1.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v1.rs index a7f73d82f3..aa063d07c6 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v1.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v1.rs @@ -19,12 +19,19 @@ pub(super) fn conversation_item_create_message( text: String, role: ConversationTextRole, ) -> RealtimeOutboundMessage { + let content_type = match role { + ConversationTextRole::Assistant => ConversationContentType::OutputText, + ConversationTextRole::User | ConversationTextRole::Developer => { + ConversationContentType::InputText + } + }; + RealtimeOutboundMessage::ConversationItemCreate { item: ConversationItemPayload::Message(ConversationMessageItem { r#type: ConversationItemType::Message, role, content: vec![ConversationItemContent { - r#type: ConversationContentType::InputText, + r#type: content_type, text, }], }), diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v2.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v2.rs index 702b4be869..ee5d5031af 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v2.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v2.rs @@ -40,12 +40,19 @@ pub(super) fn conversation_item_create_message( text: String, role: ConversationTextRole, ) -> RealtimeOutboundMessage { + let content_type = match role { + ConversationTextRole::Assistant => ConversationContentType::OutputText, + ConversationTextRole::User | ConversationTextRole::Developer => { + ConversationContentType::InputText + } + }; + RealtimeOutboundMessage::ConversationItemCreate { item: ConversationItemPayload::Message(ConversationMessageItem { r#type: ConversationItemType::Message, role, content: vec![ConversationItemContent { - r#type: ConversationContentType::InputText, + r#type: content_type, text, }], }), diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs index 8f61fc1d1d..48f89b0d33 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs @@ -195,6 +195,7 @@ pub(super) struct ConversationItemContent { #[serde(rename_all = "snake_case")] pub(super) enum ConversationContentType { InputText, + OutputText, } #[derive(Debug, Clone, Serialize)] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index f87d71b216..e77590c5e4 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -417,6 +417,7 @@ pub enum ConversationTextRole { #[default] User, Developer, + Assistant, } #[derive(Debug, Clone, PartialEq)] From ac3fe641005d002590518e139a61831a3c3b0fff Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Wed, 17 Jun 2026 20:58:48 -0700 Subject: [PATCH 018/112] Refresh signed exec-server URLs on reconnect (#28374) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - add a provider API that supplies a fresh signed WebSocket URL for each remote exec-server connection - refresh the signed URL after disconnects and retry once when a handshake returns `401 Unauthorized` - allow `EnvironmentManager` consumers to register remote environments backed by the URL provider ## Tests - `just test -p codex-exec-server -E 'test(remote_websocket_client_refreshes_url_after_unauthorized_handshake) | test(remote_websocket_client_refreshes_url_after_disconnect)'` — 2 passed - `cargo check -p codex-core-api` — passed - `just fix -p codex-exec-server` — passed - `just fix -p codex-core-api` — no test targets; no-op - `just fmt` — passed - `just test -p codex-exec-server` — 187 passed; 32 unrelated macOS sandbox tests could not invoke nested `sandbox-exec` (`Operation not permitted`) --- codex-rs/exec-server/src/client_transport.rs | 49 ++++++-- .../exec-server/src/client_transport_tests.rs | 112 ++++++++++++++++++ 2 files changed, 151 insertions(+), 10 deletions(-) create mode 100644 codex-rs/exec-server/src/client_transport_tests.rs diff --git a/codex-rs/exec-server/src/client_transport.rs b/codex-rs/exec-server/src/client_transport.rs index b079d32725..c6cff4ed30 100644 --- a/codex-rs/exec-server/src/client_transport.rs +++ b/codex-rs/exec-server/src/client_transport.rs @@ -117,17 +117,8 @@ impl ExecServerClient { connect_timeout: DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT, initialize_timeout: DEFAULT_REMOTE_EXEC_SERVER_INITIALIZE_TIMEOUT, }; - let bundle = provider.connect_bundle(identity.public_key()).await?; let (connection, options) = - Self::open_noise_rendezvous_connection(NoiseRendezvousConnectArgs { - bundle, - harness_identity: identity, - client_name: ENVIRONMENT_CLIENT_NAME.to_string(), - connect_timeout: DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT, - initialize_timeout: DEFAULT_REMOTE_EXEC_SERVER_INITIALIZE_TIMEOUT, - resume_session_id: None, - }) - .await?; + Self::open_initial_noise_rendezvous_connection(&provider, &identity).await?; Self::connect_with_recovery(connection, options, Some(reconnect_strategy)).await } crate::client_api::ExecServerTransportParams::StdioCommand { @@ -145,6 +136,40 @@ impl ExecServerClient { } } + async fn open_initial_noise_rendezvous_connection( + provider: &Arc, + identity: &NoiseChannelIdentity, + ) -> Result<(JsonRpcConnection, ExecServerClientConnectOptions), ExecServerError> { + let open_connection = |bundle: NoiseRendezvousConnectBundle| { + Self::open_noise_rendezvous_connection(NoiseRendezvousConnectArgs { + bundle, + harness_identity: identity.clone(), + client_name: ENVIRONMENT_CLIENT_NAME.to_string(), + connect_timeout: DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT, + initialize_timeout: DEFAULT_REMOTE_EXEC_SERVER_INITIALIZE_TIMEOUT, + resume_session_id: None, + }) + }; + let bundle = provider.connect_bundle(identity.public_key()).await?; + match open_connection(bundle).await { + Err(error) + if matches!( + &error, + ExecServerError::WebSocketConnect { source, .. } + if matches!( + source, + tokio_tungstenite::tungstenite::Error::Http(response) + if response.status().as_u16() == 401 + ) + ) => + { + let bundle = provider.connect_bundle(identity.public_key()).await?; + open_connection(bundle).await + } + result => result, + } + } + pub async fn connect_websocket( args: RemoteExecServerConnectArgs, ) -> Result { @@ -326,3 +351,7 @@ fn stdio_command_process(stdio_command: &StdioExecServerCommand) -> Command { command.process_group(0); command } + +#[cfg(test)] +#[path = "client_transport_tests.rs"] +mod tests; diff --git a/codex-rs/exec-server/src/client_transport_tests.rs b/codex-rs/exec-server/src/client_transport_tests.rs new file mode 100644 index 0000000000..7dab5fa218 --- /dev/null +++ b/codex-rs/exec-server/src/client_transport_tests.rs @@ -0,0 +1,112 @@ +use std::collections::VecDeque; +use std::sync::Arc; +use std::sync::Mutex; + +use anyhow::Result; +use futures::future::BoxFuture; +use pretty_assertions::assert_eq; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWriteExt; +use tokio::net::TcpListener; +use tokio_tungstenite::accept_async; + +use super::ExecServerClient; +use crate::ExecServerError; +use crate::NoiseChannelIdentity; +use crate::NoiseChannelPublicKey; +use crate::NoiseRendezvousConnectBundle; +use crate::NoiseRendezvousConnectProvider; + +struct SequenceNoiseConnectProvider { + bundles: Mutex>, + returned_urls: Mutex>, +} + +impl SequenceNoiseConnectProvider { + fn new(bundles: Vec) -> Self { + Self { + bundles: Mutex::new(bundles.into()), + returned_urls: Mutex::new(Vec::new()), + } + } + + fn returned_urls(&self) -> Vec { + self.returned_urls + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } +} + +impl NoiseRendezvousConnectProvider for SequenceNoiseConnectProvider { + fn connect_bundle( + &self, + _: NoiseChannelPublicKey, + ) -> BoxFuture<'_, Result> { + let result = self + .bundles + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .pop_front() + .ok_or_else(|| ExecServerError::Protocol("test Noise provider exhausted".to_string())); + if let Ok(bundle) = &result { + self.returned_urls + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(bundle.websocket_url.clone()); + } + Box::pin(async move { result }) + } +} + +fn test_bundle(websocket_url: String) -> Result { + Ok(NoiseRendezvousConnectBundle { + websocket_url, + environment_id: "environment".to_string(), + executor_registration_id: "registration".to_string(), + executor_public_key: NoiseChannelIdentity::generate()?.public_key(), + harness_key_authorization: "authorization".to_string(), + }) +} + +#[tokio::test] +async fn initial_noise_connection_refreshes_bundle_after_unauthorized_handshake() -> Result<()> { + let unauthorized_listener = TcpListener::bind("127.0.0.1:0").await?; + let unauthorized_url = format!("ws://{}", unauthorized_listener.local_addr()?); + let unauthorized_server = tokio::spawn(async move { + let (mut socket, _) = unauthorized_listener.accept().await?; + let mut request = [0_u8; 4096]; + let _ = socket.read(&mut request).await?; + socket + .write_all( + b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await?; + socket.shutdown().await?; + anyhow::Ok(()) + }); + let accepted_listener = TcpListener::bind("127.0.0.1:0").await?; + let accepted_url = format!("ws://{}", accepted_listener.local_addr()?); + let accepted_server = tokio::spawn(async move { + let (socket, _) = accepted_listener.accept().await?; + let _websocket = accept_async(socket).await?; + anyhow::Ok(()) + }); + let sequence = Arc::new(SequenceNoiseConnectProvider::new(vec![ + test_bundle(unauthorized_url.clone())?, + test_bundle(accepted_url.clone())?, + ])); + let provider: Arc = sequence.clone(); + let identity = NoiseChannelIdentity::generate()?; + + let _connection = + ExecServerClient::open_initial_noise_rendezvous_connection(&provider, &identity).await?; + + assert_eq!( + sequence.returned_urls(), + vec![unauthorized_url, accepted_url] + ); + unauthorized_server.await??; + accepted_server.await??; + Ok(()) +} From 78a9e169bbdeae5193a25a3f6773cace50d0cf5d Mon Sep 17 00:00:00 2001 From: Won Park Date: Wed, 17 Jun 2026 21:07:54 -0700 Subject: [PATCH 019/112] Expose selecte namespaces as direct model tools (#28825) ## Why Som tools, such as history and notes, must remain top-level when MCP deferral is enabled while staying unavailable through code-mode `exec`. ## What changed - Added `features.code_mode.direct_only_tool_namespaces`. - Classified matching MCP tools as `DirectModelOnly`. - Kept those tools top-level in `code_mode_only`. - Excluded them from `tool_search` deferral and the nested `exec` surface. - Updated the generated config schema. ## Validation - `code_mode_only_exposes_direct_model_only_mcp_namespaces` - `load_config_resolves_code_mode_config` --- codex-rs/core/config.schema.json | 7 ++ codex-rs/core/src/config/config_tests.rs | 5 ++ codex-rs/core/src/config/mod.rs | 5 ++ codex-rs/core/src/tools/handlers/dynamic.rs | 4 +- codex-rs/core/src/tools/spec_plan.rs | 30 +++++++ codex-rs/core/src/tools/spec_plan_tests.rs | 42 +++++++++ .../core/tests/suite/mcp_tool_exposure.rs | 85 +++++++++++++++++++ codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/features/src/feature_configs.rs | 5 ++ 9 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 codex-rs/core/tests/suite/mcp_tool_exposure.rs diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 9944c2994c..148901a704 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -362,6 +362,13 @@ "CodeModeConfigToml": { "additionalProperties": false, "properties": { + "direct_only_tool_namespaces": { + "description": "Exact tool namespaces to expose only as direct model tools. These tools bypass deferral, remain top-level in code-mode-only sessions, and are omitted from the nested code-mode tool surface.", + "items": { + "type": "string" + }, + "type": "array" + }, "enabled": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 47aa8910da..dd6169fbba 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -438,6 +438,7 @@ async fn load_config_resolves_code_mode_config() -> std::io::Result<()> { [features.code_mode] enabled = true excluded_tool_namespaces = ["mcp__codex_apps", "multi_agent_v1"] +direct_only_tool_namespaces = ["mcp__history", "mcp__notes"] "#, ) .expect("TOML deserialization should succeed"); @@ -452,6 +453,10 @@ excluded_tool_namespaces = ["mcp__codex_apps", "multi_agent_v1"] config.code_mode.excluded_tool_namespaces, vec!["mcp__codex_apps".to_string(), "multi_agent_v1".to_string()] ); + assert_eq!( + config.code_mode.direct_only_tool_namespaces, + vec!["mcp__history".to_string(), "mcp__notes".to_string()] + ); assert!(config.features.enabled(Feature::CodeMode)); Ok(()) } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 72d3d45c9d..a54137b58c 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1061,6 +1061,7 @@ pub struct Config { #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] pub struct CodeModeConfig { pub excluded_tool_namespaces: Vec, + pub direct_only_tool_namespaces: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -2404,6 +2405,10 @@ fn resolve_code_mode_config(config_toml: &ConfigToml) -> CodeModeConfig { .and_then(|config| config.excluded_tool_namespaces.as_ref()) .cloned() .unwrap_or_default(), + direct_only_tool_namespaces: base + .and_then(|config| config.direct_only_tool_namespaces.as_ref()) + .cloned() + .unwrap_or_default(), } } diff --git a/codex-rs/core/src/tools/handlers/dynamic.rs b/codex-rs/core/src/tools/handlers/dynamic.rs index 401278576b..28af3434ff 100644 --- a/codex-rs/core/src/tools/handlers/dynamic.rs +++ b/codex-rs/core/src/tools/handlers/dynamic.rs @@ -56,7 +56,9 @@ impl DynamicToolHandler { namespace.map(|namespace| namespace.name.clone()), tool.name.clone(), ); - let output_tool = dynamic_tool_to_responses_api_tool(tool).ok()?; + let mut output_tool = dynamic_tool_to_responses_api_tool(tool).ok()?; + // Exposure controls deferral; tool search restores this marker for deferred results. + output_tool.defer_loading = None; let spec = match namespace { Some(namespace) => ToolSpec::Namespace(ResponsesApiNamespace { name: namespace.name.clone(), diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index c697f287aa..660f3f4187 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -192,11 +192,41 @@ fn build_tool_specs_and_registry( }; let mut planned_tools = PlannedTools::default(); add_tool_sources(&context, &mut planned_tools); + apply_direct_model_only_namespace_overrides(turn_context, &mut planned_tools); append_tool_search_executor(&context, &mut planned_tools); prepend_code_mode_executors(&context, &mut planned_tools); build_model_visible_specs_and_registry(turn_context, planned_tools) } +fn apply_direct_model_only_namespace_overrides( + turn_context: &TurnContext, + planned_tools: &mut PlannedTools, +) { + for runtime in &mut planned_tools.runtimes { + let configured = runtime + .tool_name() + .namespace + .as_ref() + .is_some_and(|namespace| { + turn_context + .config + .code_mode + .direct_only_tool_namespaces + .contains(namespace) + }); + match runtime.exposure() { + ToolExposure::Direct | ToolExposure::Deferred if configured => { + *runtime = + override_tool_exposure(Arc::clone(runtime), ToolExposure::DirectModelOnly); + } + ToolExposure::Direct + | ToolExposure::Deferred + | ToolExposure::DirectModelOnly + | ToolExposure::Hidden => {} + } + } +} + #[instrument(level = "trace", skip_all)] fn build_model_visible_specs_and_registry( turn_context: &TurnContext, diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 43bcf03a6c..78eba0bc7d 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -1011,6 +1011,48 @@ async fn code_mode_only_exposes_code_executor_and_hides_nested_tools() { ); } +#[tokio::test] +async fn code_mode_only_exposes_configured_dynamic_namespace_directly() { + let plan = probe_with( + |turn| { + set_features(turn, &[Feature::CodeMode, Feature::CodeModeOnly]); + turn.model_info.supports_search_tool = true; + update_config(turn, |config| { + config.code_mode.direct_only_tool_namespaces = vec!["direct_only".to_string()]; + }); + }, + ToolPlanInputs { + dynamic_tools: vec![dynamic_tool( + Some("direct_only"), + "lookup", + /*defer_loading*/ true, + )], + ..ToolPlanInputs::default() + }, + ) + .await; + + plan.assert_visible_contains(&[ + codex_code_mode::PUBLIC_TOOL_NAME, + codex_code_mode::WAIT_TOOL_NAME, + "direct_only", + ]); + plan.assert_visible_lacks(&["tool_search"]); + assert_eq!( + plan.exposure(&ToolName::namespaced("direct_only", "lookup").to_string()), + ToolExposure::DirectModelOnly + ); + let ToolSpec::Namespace(namespace) = plan.visible_spec("direct_only") else { + panic!("expected direct-only namespace spec"); + }; + let ResponsesApiNamespaceTool::Function(tool) = &namespace.tools[0]; + assert_eq!(tool.defer_loading, None); + let ToolSpec::Freeform(exec) = plan.visible_spec(codex_code_mode::PUBLIC_TOOL_NAME) else { + panic!("expected code mode exec tool"); + }; + assert!(!exec.description.contains("direct_only_lookup(args:")); +} + #[tokio::test] async fn excluded_deferred_namespaces_do_not_enable_nested_tool_guidance() { let plan = probe_with( diff --git a/codex-rs/core/tests/suite/mcp_tool_exposure.rs b/codex-rs/core/tests/suite/mcp_tool_exposure.rs new file mode 100644 index 0000000000..44f2f87c3e --- /dev/null +++ b/codex-rs/core/tests/suite/mcp_tool_exposure.rs @@ -0,0 +1,85 @@ +use anyhow::Result; +use codex_features::Feature; +use core_test_support::apps_test_server::AppsTestServer; +use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_TOOL; +use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE; +use core_test_support::apps_test_server::search_capable_apps_builder; +use core_test_support::responses; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::namespace_child_tool; +use core_test_support::responses::sse; +use core_test_support::skip_if_no_network; +use serde_json::Value; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_only_exposes_direct_model_only_mcp_namespaces() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let apps_server = AppsTestServer::mount_searchable(&server).await?; + let response = responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + ) + .await; + + let mut builder = search_capable_apps_builder(apps_server.chatgpt_base_url.clone()) + .with_config(move |config| { + config + .features + .enable(Feature::CodeModeOnly) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::ToolSearchAlwaysDeferMcpTools) + .expect("test config should allow feature update"); + config.code_mode.direct_only_tool_namespaces = + vec![SEARCH_CALENDAR_NAMESPACE.to_string()]; + }); + let test = builder.build(&server).await?; + test.submit_turn("inspect directly exposed MCP tools") + .await?; + let body = response.single_request().body_json(); + let tools = body + .get("tools") + .and_then(Value::as_array) + .expect("request should contain tools"); + + assert!( + namespace_child_tool( + &body, + SEARCH_CALENDAR_NAMESPACE, + SEARCH_CALENDAR_CREATE_TOOL, + ) + .is_some(), + "configured MCP namespace should remain top-level: {body}" + ); + assert!( + !tools.iter().any(|tool| { + tool.get("name") + .or_else(|| tool.get("type")) + .and_then(Value::as_str) + == Some("tool_search") + }), + "configured MCP namespace should not be deferred: {body}" + ); + let exec_description = tools.iter().find_map(|tool| { + (tool.get("name").and_then(Value::as_str) == Some("exec")) + .then(|| tool.get("description").and_then(Value::as_str)) + .flatten() + }); + assert!( + exec_description.is_some_and(|description| { + !description.contains("mcp__codex_apps__calendar_create_event(args:") + }), + "direct-model-only MCP namespace should not be available through exec: {body}" + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 87dc0ffce3..275458ffdc 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -65,6 +65,7 @@ mod image_rollout; mod items; mod json_result; mod live_cli; +mod mcp_tool_exposure; mod mcp_turn_metadata; mod model_overrides; mod model_runtime_selectors; diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index f59b89e9a8..0acb9b7d3c 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -12,6 +12,11 @@ pub struct CodeModeConfigToml { /// Exact tool namespaces to omit from the code-mode nested tool surface. #[serde(skip_serializing_if = "Option::is_none")] pub excluded_tool_namespaces: Option>, + /// Exact tool namespaces to expose only as direct model tools. + /// These tools bypass deferral, remain top-level in code-mode-only sessions, and are omitted + /// from the nested code-mode tool surface. + #[serde(skip_serializing_if = "Option::is_none")] + pub direct_only_tool_namespaces: Option>, } impl FeatureConfig for CodeModeConfigToml { From e12dd73b7d5a2aa2b8d0933a2053e7eb5eba6fbb Mon Sep 17 00:00:00 2001 From: charlesgong-openai Date: Wed, 17 Jun 2026 21:33:53 -0700 Subject: [PATCH 020/112] [codex] Support plugin manifest path lists (#28790) ## Summary Allow plugin manifests to declare `skills` as either a single path string or an array of path strings in the core plugin loader. ## Why Some plugin packages need to expose skills from more than one directory. Before this change, `plugin.json` only accepted a single string for `skills`, so manifests like this were ignored as an invalid `skills` shape: ```json { "skills": ["./skills/abc", "./skills/edk"] } ``` This keeps the existing single-string form working while adding support for the list form. The final scope is intentionally limited to the core plugin manifest/load path for `skills`; `apps`, file-backed `mcpServers`, and the bundled plugin-creator assets are unchanged in this PR. ## What changed - Parse `skills` as either a string or an array of strings in `plugin.json`. - Store resolved skill paths as a list in `PluginManifestPaths`. - Load manifest-declared skill roots in addition to the default `./skills` root. - Deduplicate exact duplicate skill roots before loading. - Rely on existing skill-loader dedupe by canonical `SKILL.md` path for overlapping roots such as `./skills` plus `./skills/abc`. - Update plugin manifest tests to cover: - single string `skills` - list of string `skills` - duplicate skill roots - `./skills` as a manifest path - explicit child roots like `./skills/abc` and `./skills/edk` - overlapping-root dedupe ## Validation - `just test -p codex-plugin` - `just test -p codex-core-plugins` - `just test -p codex-mcp-extension` - `git diff --check` --- codex-rs/core-plugins/src/loader.rs | 9 +- codex-rs/core-plugins/src/manager_tests.rs | 242 ++++++++++++------ codex-rs/core-plugins/src/manifest.rs | 32 ++- .../mcp/src/executor_plugin/provider_tests.rs | 2 +- codex-rs/plugin/src/manifest.rs | 7 +- codex-rs/plugin/src/provider_tests.rs | 6 +- 6 files changed, 202 insertions(+), 96 deletions(-) diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index fb542f045f..846487aba6 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -819,10 +819,11 @@ fn plugin_skill_roots( plugin_root: &AbsolutePathBuf, manifest_paths: &PluginManifestPaths, ) -> Vec { - let mut paths = default_skill_roots(plugin_root); - if let Some(path) = &manifest_paths.skills { - paths.push(path.clone()); - } + let mut paths = if manifest_paths.skills.is_empty() { + default_skill_roots(plugin_root) + } else { + manifest_paths.skills.clone() + }; paths.sort_unstable(); paths.dedup(); paths diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index c2917211d2..b5d22911f9 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -4,6 +4,7 @@ use crate::OPENAI_API_CURATED_MARKETPLACE_NAME; use crate::OPENAI_CURATED_MARKETPLACE_NAME; use crate::PluginLoadOutcome; use crate::installed_marketplaces::marketplace_install_root; +use crate::loader::load_plugin_skills; use crate::loader::load_plugins_from_layer_stack; use crate::loader::refresh_non_curated_plugin_cache; use crate::loader::refresh_non_curated_plugin_cache_force_reinstall; @@ -33,10 +34,13 @@ use codex_config::McpServerConfig; use codex_config::McpServerOAuthConfig; use codex_config::McpServerToolConfig; use codex_config::types::McpServerTransportConfig; +use codex_core_skills::config_rules::SkillConfigRules; use codex_login::CodexAuth; use codex_plugin::AppDeclaration; +use codex_plugin::PluginId; use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::Product; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::test_support::PathBufExt; use pretty_assertions::assert_eq; use std::fs; @@ -1282,32 +1286,62 @@ async fn capability_summary_truncates_overlong_plugin_descriptions() { #[tokio::test] async fn load_plugins_uses_manifest_configured_component_paths() { - let codex_home = TempDir::new().unwrap(); - let plugin_root = codex_home - .path() - .join("plugins/cache") - .join("test/sample/local"); + for (skills_json, expected_skill_dirs) in [ + (r#""./custom-skills/""#, &["custom-skills"][..]), + ( + r#"["./custom-skills/", "./extra-skills/"]"#, + &["custom-skills", "extra-skills"][..], + ), + ( + r#"["./custom-skills/", "./custom-skills/"]"#, + &["custom-skills"][..], + ), + (r#""./skills/""#, &["skills"][..]), + ( + r#"["./skills/abc/", "./skills/edk/"]"#, + &["skills/abc", "skills/edk"][..], + ), + ] { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); - write_file( - &plugin_root.join(".codex-plugin/plugin.json"), - r#"{ + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + &format!( + r#"{{ "name": "sample", - "skills": "./custom-skills/", + "skills": {skills_json}, "mcpServers": "./config/custom.mcp.json", "apps": "./config/custom.app.json" -}"#, - ); - write_file( - &plugin_root.join("skills/default-skill/SKILL.md"), - "---\nname: default-skill\ndescription: default skill\n---\n", - ); - write_file( - &plugin_root.join("custom-skills/custom-skill/SKILL.md"), - "---\nname: custom-skill\ndescription: custom skill\n---\n", - ); - write_file( - &plugin_root.join(".mcp.json"), - r#"{ +}}"# + ), + ); + write_file( + &plugin_root.join("skills/default-skill/SKILL.md"), + "---\nname: default-skill\ndescription: default skill\n---\n", + ); + write_file( + &plugin_root.join("skills/abc/SKILL.md"), + "---\nname: abc\ndescription: abc skill\n---\n", + ); + write_file( + &plugin_root.join("skills/edk/SKILL.md"), + "---\nname: edk\ndescription: edk skill\n---\n", + ); + write_file( + &plugin_root.join("custom-skills/custom-skill/SKILL.md"), + "---\nname: custom-skill\ndescription: custom skill\n---\n", + ); + write_file( + &plugin_root.join("extra-skills/extra-skill/SKILL.md"), + "---\nname: extra-skill\ndescription: extra skill\n---\n", + ); + write_file( + &plugin_root.join(".mcp.json"), + r#"{ "mcpServers": { "default": { "type": "http", @@ -1315,10 +1349,10 @@ async fn load_plugins_uses_manifest_configured_component_paths() { } } }"#, - ); - write_file( - &plugin_root.join("config/custom.mcp.json"), - r#"{ + ); + write_file( + &plugin_root.join("config/custom.mcp.json"), + r#"{ "mcpServers": { "custom": { "type": "http", @@ -1326,74 +1360,132 @@ async fn load_plugins_uses_manifest_configured_component_paths() { } } }"#, - ); - write_file( - &plugin_root.join(".app.json"), - r#"{ + ); + write_file( + &plugin_root.join(".app.json"), + r#"{ "apps": { "default-app": { "id": "connector_default" } } }"#, - ); - write_file( - &plugin_root.join("config/custom.app.json"), - r#"{ + ); + write_file( + &plugin_root.join("config/custom.app.json"), + r#"{ "apps": { "custom-app": { "id": "connector_custom" } } }"#, - ); + ); + let outcome = load_plugins_from_config( + &plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true), + codex_home.path(), + Some(AuthMode::Chatgpt), + ) + .await; + let mut expected_skill_roots = expected_skill_dirs + .iter() + .map(|dir| plugin_root.join(dir).abs()) + .collect::>(); + expected_skill_roots.sort_unstable(); + expected_skill_roots.dedup(); - let outcome = load_plugins_from_config( - &plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true), - codex_home.path(), - Some(AuthMode::Chatgpt), + assert_eq!(outcome.plugins()[0].skill_roots, expected_skill_roots); + assert_eq!( + outcome.plugins()[0].mcp_servers, + HashMap::from([( + "custom".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://custom.example/mcp".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + environment_id: "local".to_string(), + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth: None, + oauth_resource: None, + tools: HashMap::new(), + }, + )]) + ); + assert_eq!( + outcome.plugins()[0].apps, + vec![app_declaration("custom-app", "connector_custom")] + ); + } +} + +#[tokio::test] +async fn load_plugin_skills_dedupes_overlapping_manifest_roots() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local") + .abs(); + write_file( + &plugin_root.join("skills/abc/SKILL.md"), + "---\nname: abc\ndescription: abc skill\n---\n", + ); + write_file( + &plugin_root.join("skills/edk/SKILL.md"), + "---\nname: edk\ndescription: edk skill\n---\n", + ); + let manifest_paths = crate::manifest::PluginManifestPaths { + skills: vec![ + plugin_root.join("skills"), + plugin_root.join("skills/abc"), + plugin_root.join("skills/edk"), + plugin_root.join("skills/abc"), + ], + mcp_servers: None, + apps: None, + hooks: None, + }; + let plugin_id = PluginId::parse("sample@test").expect("plugin id should parse"); + + let resolved = load_plugin_skills( + &plugin_root, + &plugin_id, + &manifest_paths, + /*restriction_product*/ None, + &SkillConfigRules::default(), ) .await; + let skill_paths = resolved + .skills + .iter() + .map(|skill| skill.path_to_skills_md.clone()) + .collect::>(); + let canonical_skill_path = |path| { + AbsolutePathBuf::from_absolute_path_checked( + fs::canonicalize(plugin_root.join(path)).expect("canonical skill path"), + ) + .expect("absolute skill path") + }; assert_eq!( - outcome.plugins()[0].skill_roots, + skill_paths, vec![ - plugin_root.join("custom-skills").abs(), - plugin_root.join("skills").abs() + canonical_skill_path("skills/abc/SKILL.md"), + canonical_skill_path("skills/edk/SKILL.md") ] ); - assert_eq!( - outcome.plugins()[0].mcp_servers, - HashMap::from([( - "custom".to_string(), - McpServerConfig { - transport: McpServerTransportConfig::StreamableHttp { - url: "https://custom.example/mcp".to_string(), - bearer_token_env_var: None, - http_headers: None, - env_http_headers: None, - }, - environment_id: "local".to_string(), - enabled: true, - required: false, - supports_parallel_tool_calls: false, - disabled_reason: None, - startup_timeout_sec: None, - tool_timeout_sec: None, - default_tools_approval_mode: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - oauth: None, - oauth_resource: None, - tools: HashMap::new(), - }, - )]) - ); - assert_eq!( - outcome.plugins()[0].apps, - vec![app_declaration("custom-app", "connector_custom")] - ); } #[tokio::test] @@ -1521,7 +1613,7 @@ async fn load_plugins_ignores_invalid_manifest_skills_shape() { &plugin_root.join(".codex-plugin/plugin.json"), r#"{ "name": "sample", - "skills": ["./custom-skills/"] + "skills": { "path": "./custom-skills/" } }"#, ); write_file( diff --git a/codex-rs/core-plugins/src/manifest.rs b/codex-rs/core-plugins/src/manifest.rs index fe0bb25644..37630a8436 100644 --- a/codex-rs/core-plugins/src/manifest.rs +++ b/codex-rs/core-plugins/src/manifest.rs @@ -30,7 +30,7 @@ struct RawPluginManifest { // Keep manifest paths as raw strings so we can validate the required `./...` syntax before // resolving them under the plugin root. #[serde(default)] - skills: Option, + skills: Option, #[serde(default)] mcp_servers: Option, #[serde(default)] @@ -94,8 +94,9 @@ enum RawPluginManifestDefaultPromptEntry { #[derive(Debug, Deserialize)] #[serde(untagged)] -enum RawPluginManifestPath { +enum RawPluginManifestPaths { Path(String), + Paths(Vec), Invalid(JsonValue), } @@ -230,7 +231,7 @@ pub(crate) fn parse_plugin_manifest( description, keywords, paths: PluginManifestPaths { - skills: resolve_manifest_path_value(plugin_root, "skills", skills.as_ref()), + skills: resolve_manifest_paths(plugin_root, "skills", skills.as_ref()), mcp_servers: resolve_manifest_mcp_servers(plugin_root, mcp_servers), apps: resolve_manifest_path(plugin_root, "apps", apps.as_deref()), hooks: resolve_manifest_hooks(plugin_root, hooks), @@ -395,20 +396,29 @@ fn json_value_type(value: &JsonValue) -> &'static str { } } -fn resolve_manifest_path_value( +fn resolve_manifest_paths( plugin_root: &Path, field: &'static str, - path: Option<&RawPluginManifestPath>, -) -> Option { - match path? { - RawPluginManifestPath::Path(path) => resolve_manifest_path(plugin_root, field, Some(path)), - RawPluginManifestPath::Invalid(value) => { + paths: Option<&RawPluginManifestPaths>, +) -> Vec { + match paths { + Some(RawPluginManifestPaths::Path(path)) => { + resolve_manifest_path(plugin_root, field, Some(path)) + .map(|path| vec![path]) + .unwrap_or_default() + } + Some(RawPluginManifestPaths::Paths(paths)) => paths + .iter() + .filter_map(|path| resolve_manifest_path(plugin_root, field, Some(path))) + .collect(), + Some(RawPluginManifestPaths::Invalid(value)) => { tracing::warn!( - "ignoring {field}: expected a string; found {}", + "ignoring {field}: expected a string or string array; found {}", json_value_type(value) ); - None + Vec::new() } + None => Vec::new(), } } diff --git a/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs b/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs index dc6a0403d0..b154b060b2 100644 --- a/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs +++ b/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs @@ -316,7 +316,7 @@ fn resolved_plugin( description: None, keywords: Vec::new(), paths: PluginManifestPaths { - skills: None, + skills: Vec::new(), mcp_servers, apps: None, hooks: None, diff --git a/codex-rs/plugin/src/manifest.rs b/codex-rs/plugin/src/manifest.rs index 4aeec49be8..599f4ee407 100644 --- a/codex-rs/plugin/src/manifest.rs +++ b/codex-rs/plugin/src/manifest.rs @@ -17,7 +17,7 @@ pub struct PluginManifest { /// Component resources declared by a plugin manifest. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PluginManifestPaths { - pub skills: Option, + pub skills: Vec, pub mcp_servers: Option>, pub apps: Option, pub hooks: Option>, @@ -172,7 +172,10 @@ impl PluginManifest { description, keywords, paths: PluginManifestPaths { - skills: skills.map(&mut map).transpose()?, + skills: skills + .into_iter() + .map(&mut map) + .collect::, _>>()?, mcp_servers, apps: apps.map(&mut map).transpose()?, hooks, diff --git a/codex-rs/plugin/src/provider_tests.rs b/codex-rs/plugin/src/provider_tests.rs index 776e63b1c3..99bc3c2089 100644 --- a/codex-rs/plugin/src/provider_tests.rs +++ b/codex-rs/plugin/src/provider_tests.rs @@ -37,7 +37,7 @@ fn environment_descriptor_binds_every_manifest_resource() { description: None, keywords: Vec::new(), paths: PluginManifestPaths { - skills: Some(skills.clone()), + skills: vec![skills.clone()], mcp_servers: Some(PluginManifestMcpServers::Path(mcp_servers.clone())), apps: Some(apps.clone()), hooks: Some(PluginManifestHooks::Paths(vec![hooks.clone()])), @@ -71,7 +71,7 @@ fn environment_descriptor_binds_every_manifest_resource() { description: None, keywords: Vec::new(), paths: PluginManifestPaths { - skills: Some(resource("executor-1", skills)), + skills: vec![resource("executor-1", skills)], mcp_servers: Some(PluginManifestMcpServers::Path(resource( "executor-1", mcp_servers, @@ -103,7 +103,7 @@ fn environment_descriptor_rejects_resources_outside_package_root() { description: None, keywords: Vec::new(), paths: PluginManifestPaths { - skills: None, + skills: Vec::new(), mcp_servers: Some(PluginManifestMcpServers::Path(outside.clone())), apps: None, hooks: None, From 285eff6c3ed4bd08a0235bab294737c65784b973 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Wed, 17 Jun 2026 21:59:42 -0700 Subject: [PATCH 021/112] Record more path migration guidance for codex. (#28851) Some common themes pulled out of both human and automated reviews from the last couple of days' migrations to `PathUri` and `LegacyAppPathString`. --- .codex/skills/path-types/SKILL.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.codex/skills/path-types/SKILL.md b/.codex/skills/path-types/SKILL.md index 96caa98058..0062730013 100644 --- a/.codex/skills/path-types/SKILL.md +++ b/.codex/skills/path-types/SKILL.md @@ -31,7 +31,9 @@ Keep these requirements in mind while migrating code to conform with the above g * path reasoning must work before the related environment has come online * URIs cannot explicitly encode the executor’s path convention or operating system * users must not configure the environment’s OS/path convention explicitly -* URIs should not yet be stored in rollouts +* URIs should not yet be stored in rollouts, databases, or other persistent storage +* path conversion errors: fail-closed for security-relevant paths, fail-open for UI/diagnostics +* prefer small focused methods on `PathUri` or `LegacyAppPathString` over local helpers It is OK if the conversion between paths and URIs is somewhat lossy as long as it will do the right thing for real users. From 3931bc2bde3e89876da5f96335629c71d635bd72 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Wed, 17 Jun 2026 22:00:04 -0700 Subject: [PATCH 022/112] unified-exec: retain PathUri in command events (#28780) ## Why App-server must report command events containing foreign-platform paths without changing existing client or rollout path-string formats. ## What changed - retain `PathUri` through exec command begin/end events - convert cwd values to `LegacyAppPathString` at the app-server compatibility boundary - drop command actions with foreign paths and log them - serialize rollout-trace cwd values using their inferred native path representation - restore Wine coverage for retained Windows cwd values and successful completion --- .../analytics/src/analytics_client_tests.rs | 2 +- ...CommandExecutionRequestApprovalParams.json | 2 +- .../schema/json/ServerNotification.json | 2 +- .../schema/json/ServerRequest.json | 2 +- .../codex_app_server_protocol.schemas.json | 4 +- .../codex_app_server_protocol.v2.schemas.json | 2 +- .../json/v2/ItemCompletedNotification.json | 5 +- .../json/v2/ItemStartedNotification.json | 5 +- .../schema/json/v2/ReviewStartResponse.json | 5 +- .../schema/json/v2/ThreadForkResponse.json | 5 +- .../schema/json/v2/ThreadListResponse.json | 5 +- .../json/v2/ThreadMetadataUpdateResponse.json | 5 +- .../schema/json/v2/ThreadReadResponse.json | 5 +- .../schema/json/v2/ThreadResumeResponse.json | 5 +- .../json/v2/ThreadRollbackResponse.json | 5 +- .../schema/json/v2/ThreadStartResponse.json | 5 +- .../json/v2/ThreadStartedNotification.json | 5 +- .../json/v2/ThreadUnarchiveResponse.json | 5 +- .../json/v2/TurnCompletedNotification.json | 5 +- .../schema/json/v2/TurnStartResponse.json | 5 +- .../json/v2/TurnStartedNotification.json | 5 +- .../CommandExecutionRequestApprovalParams.ts | 4 +- .../schema/typescript/v2/ThreadItem.ts | 3 +- .../src/protocol/item_builders.rs | 75 ++++++++--- .../src/protocol/item_builders_tests.rs | 41 ++++++ .../src/protocol/thread_history.rs | 18 +-- .../src/protocol/v2/item.rs | 5 +- codex-rs/app-server-test-client/src/lib.rs | 2 +- .../app-server/src/bespoke_event_handling.rs | 11 +- codex-rs/app-server/src/request_processors.rs | 3 +- codex-rs/app-server/src/transport_tests.rs | 4 +- .../app-server/tests/suite/v2/turn_start.rs | 2 +- .../tests/suite/v2/turn_start_zsh_fork.rs | 2 +- codex-rs/core/src/tasks/user_shell.rs | 8 +- codex-rs/core/src/tools/events.rs | 16 +-- .../remote_env_windows_test.rs | 13 +- codex-rs/core/tests/suite/unified_exec.rs | 10 +- .../src/event_processor_with_human_output.rs | 3 +- .../tests/event_processor_with_json_output.rs | 8 +- codex-rs/protocol/src/protocol.rs | 4 +- codex-rs/rollout-trace/src/protocol_event.rs | 122 +++++++++++++++++- .../rollout-trace/src/protocol_event_tests.rs | 83 ++++++++++++ .../tui/src/app/agent_status_feed_tests.rs | 4 +- .../tui/src/app/pending_interactive_replay.rs | 2 +- codex-rs/tui/src/app/tests.rs | 2 +- codex-rs/tui/src/app/thread_events.rs | 2 +- codex-rs/tui/src/chatwidget.rs | 8 +- codex-rs/tui/src/chatwidget/interrupts.rs | 2 +- .../tui/src/chatwidget/tests/app_server.rs | 4 +- .../src/chatwidget/tests/approval_requests.rs | 4 +- .../tui/src/chatwidget/tests/exec_flow.rs | 4 +- codex-rs/tui/src/chatwidget/tests/helpers.rs | 4 +- .../src/chatwidget/tests/status_and_layout.rs | 4 +- .../utils/path-uri/src/api_path_string.rs | 57 +++++++- .../path-uri/src/api_path_string_tests.rs | 63 ++++++++- codex-rs/utils/path-uri/src/tests.rs | 5 + 56 files changed, 566 insertions(+), 125 deletions(-) create mode 100644 codex-rs/app-server-protocol/src/protocol/item_builders_tests.rs diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d6792587f2..55467c06b6 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -831,7 +831,7 @@ fn sample_command_execution_item_with_id( ThreadItem::CommandExecution { id: id.to_string(), command: "echo hi".to_string(), - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), process_id: Some("pid-1".to_string()), source: CommandExecutionSource::Agent, status, diff --git a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json index 4e096cbe04..91f74376fd 100644 --- a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json @@ -547,7 +547,7 @@ "cwd": { "anyOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, { "type": "null" diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index a0d49cc548..bfafd5483b 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3972,7 +3972,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 97934dd5bd..31ca31de2c 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -371,7 +371,7 @@ "cwd": { "anyOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, { "type": "null" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 9a13e86523..c75ae416fa 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -2378,7 +2378,7 @@ "cwd": { "anyOf": [ { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "$ref": "#/definitions/v2/LegacyAppPathString" }, { "type": "null" @@ -17392,7 +17392,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "$ref": "#/definitions/v2/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 0f6084a993..a4a0267dc5 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -15192,7 +15192,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index 233ff8dc98..027edd0367 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -294,6 +294,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -687,7 +690,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index a55d9e7766..64152e9cf4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -294,6 +294,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -687,7 +690,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index e27c911b36..61f61e74dc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -431,6 +431,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -831,7 +834,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 2b7caeca7e..43a76f145a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -536,6 +536,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1323,7 +1326,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 0caa0daf5d..1e1504b738 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -457,6 +457,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1138,7 +1141,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index a98d3586ec..6e62eae14b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -457,6 +457,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1138,7 +1141,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 51e56f14b8..ff70a017b4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -457,6 +457,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1138,7 +1141,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index e8c309507e..f9a65cdd8b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -536,6 +536,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1323,7 +1326,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index 52cba55edb..f455f6dfff 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -457,6 +457,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1138,7 +1141,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 245cc946d4..0c3eac4de5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -536,6 +536,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1323,7 +1326,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index bf497ecfa2..0e44cfb258 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -457,6 +457,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1138,7 +1141,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 14f25cfcdc..f88a1fae59 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -457,6 +457,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1138,7 +1141,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 24d1dccf47..7e6cef2c37 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -431,6 +431,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -831,7 +834,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index da88eacd48..083767a6cf 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -431,6 +431,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -831,7 +834,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index edb3fa636a..3a43d7d3fb 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -431,6 +431,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -831,7 +834,7 @@ "cwd": { "allOf": [ { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" } ], "description": "The command's working directory." diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts index 230e5b7864..4f02c92cae 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts @@ -1,7 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { LegacyAppPathString } from "../LegacyAppPathString"; import type { CommandAction } from "./CommandAction"; import type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; import type { NetworkApprovalContext } from "./NetworkApprovalContext"; @@ -34,7 +34,7 @@ networkApprovalContext?: NetworkApprovalContext | null, /** command?: string | null, /** * The command's working directory. */ -cwd?: AbsolutePathBuf | null, /** +cwd?: LegacyAppPathString | null, /** * Best-effort parsed command actions for friendly display. */ commandActions?: Array | null, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts index 4ccab77b32..1cdac99c64 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts @@ -2,6 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { LegacyAppPathString } from "../LegacyAppPathString"; import type { MessagePhase } from "../MessagePhase"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { JsonValue } from "../serde_json/JsonValue"; @@ -32,7 +33,7 @@ command: string, /** * The command's working directory. */ -cwd: AbsolutePathBuf, +cwd: LegacyAppPathString, /** * Identifier for the underlying PTY process (when available). */ diff --git a/codex-rs/app-server-protocol/src/protocol/item_builders.rs b/codex-rs/app-server-protocol/src/protocol/item_builders.rs index 17e0f9aef4..66019980a3 100644 --- a/codex-rs/app-server-protocol/src/protocol/item_builders.rs +++ b/codex-rs/app-server-protocol/src/protocol/item_builders.rs @@ -23,6 +23,7 @@ use crate::protocol::v2::PatchApplyStatus; use crate::protocol::v2::PatchChangeKind; use crate::protocol::v2::ThreadItem; use codex_protocol::ThreadId; +use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; use codex_protocol::protocol::ExecApprovalRequestEvent; use codex_protocol::protocol::ExecCommandBeginEvent; @@ -34,8 +35,11 @@ use codex_protocol::protocol::PatchApplyBeginEvent; use codex_protocol::protocol::PatchApplyEndEvent; use codex_shell_command::parse_command::parse_command; use codex_shell_command::parse_command::shlex_join; +use codex_utils_path_uri::PathConvention; +use codex_utils_path_uri::PathUri; use std::collections::HashMap; use std::path::PathBuf; +use tracing::warn; pub fn build_file_change_approval_request_item( payload: &ApplyPatchApprovalRequestEvent, @@ -69,7 +73,7 @@ pub fn build_command_execution_approval_request_item( ThreadItem::CommandExecution { id: payload.call_id.clone(), command: shlex_join(&payload.command), - cwd: payload.cwd.clone(), + cwd: payload.cwd.clone().into(), process_id: None, source: CommandExecutionSource::Agent, status: CommandExecutionStatus::InProgress, @@ -86,19 +90,15 @@ pub fn build_command_execution_approval_request_item( } pub fn build_command_execution_begin_item(payload: &ExecCommandBeginEvent) -> ThreadItem { + let command_actions = command_actions_for_path_uri(&payload.parsed_cmd, &payload.cwd); ThreadItem::CommandExecution { id: payload.call_id.clone(), command: shlex_join(&payload.command), - cwd: payload.cwd.clone(), + cwd: payload.cwd.clone().into(), process_id: payload.process_id.clone(), source: payload.source.into(), status: CommandExecutionStatus::InProgress, - command_actions: payload - .parsed_cmd - .iter() - .cloned() - .map(|parsed| CommandAction::from_core_with_cwd(parsed, &payload.cwd)) - .collect(), + command_actions, aggregated_output: None, exit_code: None, duration_ms: None, @@ -112,26 +112,63 @@ pub fn build_command_execution_end_item(payload: &ExecCommandEndEvent) -> Thread Some(payload.aggregated_output.clone()) }; let duration_ms = i64::try_from(payload.duration.as_millis()).unwrap_or(i64::MAX); + let command_actions = command_actions_for_path_uri(&payload.parsed_cmd, &payload.cwd); ThreadItem::CommandExecution { id: payload.call_id.clone(), command: shlex_join(&payload.command), - cwd: payload.cwd.clone(), + cwd: payload.cwd.clone().into(), process_id: payload.process_id.clone(), source: payload.source.into(), status: (&payload.status).into(), - command_actions: payload - .parsed_cmd - .iter() - .cloned() - .map(|parsed| CommandAction::from_core_with_cwd(parsed, &payload.cwd)) - .collect(), + command_actions, aggregated_output, exit_code: Some(payload.exit_code), duration_ms: Some(duration_ms), } } +fn command_actions_for_path_uri(parsed_cmd: &[ParsedCommand], cwd: &PathUri) -> Vec { + // TODO(anp): Carry PathUri into CommandAction so foreign Read actions retain resolved paths. + // Until then, omit those actions rather than project a foreign cwd onto the host. + let native_cwd = if cwd.infer_path_convention() == Some(PathConvention::native()) { + cwd.to_abs_path().ok() + } else { + None + }; + + parsed_cmd + .iter() + .cloned() + .filter_map(|parsed| match parsed { + ParsedCommand::Read { cmd, name, path } => match native_cwd.as_ref() { + Some(native_cwd) => Some(CommandAction::Read { + command: cmd, + name, + path: native_cwd.join(path), + }), + None => { + warn!( + command = cmd, + %cwd, + "omitting read command action whose path cannot be resolved against a foreign cwd" + ); + None + } + }, + ParsedCommand::ListFiles { cmd, path } => { + Some(CommandAction::ListFiles { command: cmd, path }) + } + ParsedCommand::Search { cmd, query, path } => Some(CommandAction::Search { + command: cmd, + query, + path, + }), + ParsedCommand::Unknown { cmd } => Some(CommandAction::Unknown { command: cmd }), + }) + .collect() +} + /// Build a guardian-derived [`ThreadItem`]. /// /// Currently this only synthesizes [`ThreadItem::CommandExecution`] for @@ -150,7 +187,7 @@ pub fn build_item_from_guardian_event( Some(ThreadItem::CommandExecution { id: id.clone(), command, - cwd: cwd.clone(), + cwd: cwd.clone().into(), process_id: None, source: CommandExecutionSource::Agent, status, @@ -186,7 +223,7 @@ pub fn build_item_from_guardian_event( Some(ThreadItem::CommandExecution { id: id.clone(), command, - cwd: cwd.clone(), + cwd: cwd.clone().into(), process_id: None, source: CommandExecutionSource::Agent, status, @@ -315,3 +352,7 @@ fn format_file_change_diff(change: &FileChange) -> String { } } } + +#[cfg(test)] +#[path = "item_builders_tests.rs"] +mod tests; diff --git a/codex-rs/app-server-protocol/src/protocol/item_builders_tests.rs b/codex-rs/app-server-protocol/src/protocol/item_builders_tests.rs new file mode 100644 index 0000000000..b892fcf505 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/item_builders_tests.rs @@ -0,0 +1,41 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn foreign_read_is_omitted_without_dropping_other_command_actions() { + #[cfg(windows)] + let cwd = PathUri::parse("file:///usr/local/src").expect("valid foreign POSIX cwd"); + #[cfg(not(windows))] + let cwd = PathUri::parse("file:///C:/src").expect("valid foreign Windows cwd"); + let parsed_cmd = vec![ + ParsedCommand::Read { + cmd: "cat file.txt".to_string(), + name: "file.txt".to_string(), + path: PathBuf::from("file.txt"), + }, + ParsedCommand::ListFiles { + cmd: "ls".to_string(), + path: Some("subdir".to_string()), + }, + ParsedCommand::Search { + cmd: "rg needle".to_string(), + query: Some("needle".to_string()), + path: Some("src".to_string()), + }, + ]; + + assert_eq!( + command_actions_for_path_uri(&parsed_cmd, &cwd), + vec![ + CommandAction::ListFiles { + command: "ls".to_string(), + path: Some("subdir".to_string()), + }, + CommandAction::Search { + command: "rg needle".to_string(), + query: Some("needle".to_string()), + path: Some("src".to_string()), + }, + ] + ); +} diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 90a6f36ad5..001fa7cf93 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -2376,7 +2376,7 @@ mod tests { turn_id: "turn-1".into(), completed_at_ms: 0, command: vec!["echo".into(), "hello world".into()], - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), parsed_cmd: vec![ParsedCommand::Unknown { cmd: "echo hello world".into(), }], @@ -2427,7 +2427,7 @@ mod tests { ThreadItem::CommandExecution { id: "exec-1".into(), command: "echo 'hello world'".into(), - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), process_id: Some("pid-1".into()), source: CommandExecutionSource::Agent, status: CommandExecutionStatus::Completed, @@ -2616,7 +2616,7 @@ mod tests { turn_id: "turn-1".into(), completed_at_ms: 0, command: vec!["ls".into()], - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), parsed_cmd: vec![ParsedCommand::Unknown { cmd: "ls".into() }], source: ExecCommandSource::Agent, interaction_input: None, @@ -2658,7 +2658,7 @@ mod tests { ThreadItem::CommandExecution { id: "exec-declined".into(), command: "ls".into(), - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), process_id: Some("pid-2".into()), source: CommandExecutionSource::Agent, status: CommandExecutionStatus::Declined, @@ -2756,7 +2756,7 @@ mod tests { ThreadItem::CommandExecution { id: "guardian-exec".into(), command: "rm -rf /tmp/guardian".into(), - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), process_id: None, source: CommandExecutionSource::Agent, status: CommandExecutionStatus::Declined, @@ -2822,7 +2822,7 @@ mod tests { ThreadItem::CommandExecution { id: "guardian-execve".into(), command: "/bin/rm -f /tmp/file.sqlite".into(), - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), process_id: None, source: CommandExecutionSource::Agent, status: CommandExecutionStatus::InProgress, @@ -2882,7 +2882,7 @@ mod tests { turn_id: "turn-a".into(), completed_at_ms: 0, command: vec!["echo".into(), "done".into()], - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), parsed_cmd: vec![ParsedCommand::Unknown { cmd: "echo done".into(), }], @@ -2920,7 +2920,7 @@ mod tests { ThreadItem::CommandExecution { id: "exec-late".into(), command: "echo done".into(), - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), process_id: Some("pid-42".into()), source: CommandExecutionSource::Agent, status: CommandExecutionStatus::Completed, @@ -2980,7 +2980,7 @@ mod tests { turn_id: "turn-missing".into(), completed_at_ms: 0, command: vec!["echo".into(), "done".into()], - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), parsed_cmd: vec![ParsedCommand::Unknown { cmd: "echo done".into(), }], diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs index 5638e3bd41..34cfe8e136 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -31,6 +31,7 @@ use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; use codex_protocol::protocol::ReviewDecision as CoreReviewDecision; use codex_protocol::protocol::SubAgentActivityKind as CoreSubAgentActivityKind; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::LegacyAppPathString; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; @@ -256,7 +257,7 @@ pub enum ThreadItem { /// The command to be executed. command: String, /// The command's working directory. - cwd: AbsolutePathBuf, + cwd: LegacyAppPathString, /// Identifier for the underlying PTY process (when available). process_id: Option, #[serde(default)] @@ -1339,7 +1340,7 @@ pub struct CommandExecutionRequestApprovalParams { /// The command's working directory. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable)] - pub cwd: Option, + pub cwd: Option, /// Best-effort parsed command actions for friendly display. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable)] diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 3b5e7d8a3f..0be6e68bb2 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -2092,7 +2092,7 @@ impl CodexClient { println!("< command: {command}"); } if let Some(cwd) = cwd.as_ref() { - println!("< cwd: {}", cwd.display()); + println!("< cwd: {cwd}"); } if let Some(command_actions) = command_actions.as_ref() && !command_actions.is_empty() diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index ed135271a9..a77b0f4664 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -113,6 +113,7 @@ use codex_protocol::request_user_input::RequestUserInputResponse as CoreRequestU use codex_sandboxing::policy_transforms::intersect_permission_profiles; use codex_shell_command::parse_command::shlex_join; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::LegacyAppPathString; use std::collections::HashMap; use std::sync::Arc; use std::time::SystemTime; @@ -129,7 +130,7 @@ enum CommandExecutionApprovalPresentation { #[derive(Debug, PartialEq)] struct CommandExecutionCompletionItem { command: String, - cwd: AbsolutePathBuf, + cwd: LegacyAppPathString, command_actions: Vec, } @@ -575,7 +576,7 @@ pub(crate) async fn apply_bespoke_event_handling( let command_string = shlex_join(&command); let completion_item = CommandExecutionCompletionItem { command: command_string, - cwd: cwd.clone(), + cwd: cwd.clone().into(), command_actions: command_actions.clone(), }; CommandExecutionApprovalPresentation::Command(completion_item) @@ -1354,7 +1355,7 @@ async fn start_command_execution_item( turn_id: String, item_id: String, command: String, - cwd: AbsolutePathBuf, + cwd: LegacyAppPathString, command_actions: Vec, source: CommandExecutionSource, outgoing: &ThreadScopedOutgoingMessageSender, @@ -1398,7 +1399,7 @@ async fn complete_command_execution_item( turn_id: String, item_id: String, command: String, - cwd: AbsolutePathBuf, + cwd: LegacyAppPathString, process_id: Option, source: CommandExecutionSource, command_actions: Vec, @@ -2323,7 +2324,7 @@ mod tests { fn command_execution_completion_item(command: &str) -> CommandExecutionCompletionItem { CommandExecutionCompletionItem { command: command.to_string(), - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), command_actions: vec![V2ParsedCommand::Unknown { command: command.to_string(), }], diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index b5295b9b74..8c202a806d 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -553,8 +553,7 @@ fn resolve_turn_environment_selections( let environment_id = environment.environment_id; let cwd = environment .cwd - .infer_absolute_path_convention() - .and_then(|convention| environment.cwd.to_path_uri(convention).ok()) + .to_inferred_path_uri() .ok_or_else(|| { invalid_request(format!( "invalid cwd for environment `{environment_id}`: path `{}` does not use absolute POSIX or Windows path syntax", diff --git a/codex-rs/app-server/src/transport_tests.rs b/codex-rs/app-server/src/transport_tests.rs index 97372d6ae1..439284a176 100644 --- a/codex-rs/app-server/src/transport_tests.rs +++ b/codex-rs/app-server/src/transport_tests.rs @@ -254,7 +254,7 @@ async fn command_execution_request_approval_strips_additional_permissions_withou reason: Some("Need extra read access".to_string()), network_approval_context: None, command: Some("cat file".to_string()), - cwd: Some(absolute_path("/tmp")), + cwd: Some(absolute_path("/tmp").into()), command_actions: None, additional_permissions: Some( codex_app_server_protocol::AdditionalPermissionProfile { @@ -320,7 +320,7 @@ async fn command_execution_request_approval_keeps_additional_permissions_with_ca reason: Some("Need extra read access".to_string()), network_approval_context: None, command: Some("cat file".to_string()), - cwd: Some(absolute_path("/tmp")), + cwd: Some(absolute_path("/tmp").into()), command_actions: None, additional_permissions: Some( codex_app_server_protocol::AdditionalPermissionProfile { diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index dca58867c8..f577a01fd2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -2440,7 +2440,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { else { unreachable!("loop ensures we break on command execution items"); }; - assert_eq!(cwd.as_path(), second_cwd.as_path()); + assert_eq!(cwd.as_str(), second_cwd.to_string_lossy().as_ref()); let expected_command = format_with_current_shell_display("echo second turn"); assert_eq!(command, expected_command); assert_eq!(status, CommandExecutionStatus::InProgress); diff --git a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs index 15072c1ccf..c6f8f0e198 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs @@ -167,7 +167,7 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { assert!(command.contains("/bin/sh -c")); assert!(command.contains("sleep 0.01")); assert!(command.contains(&release_marker.display().to_string())); - assert_eq!(cwd.as_path(), workspace.as_path()); + assert_eq!(cwd.as_str(), workspace.to_string_lossy().as_ref()); mcp.interrupt_turn_and_wait_for_aborted(thread.id, turn.id, DEFAULT_READ_TIMEOUT) .await?; diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 057718b9f7..aea7500247 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -188,7 +188,7 @@ pub(crate) async fn execute_user_shell_command( turn_id: turn_context.sub_id.clone(), started_at_ms: now_unix_timestamp_ms(), command: display_command.clone(), - cwd: cwd.clone(), + cwd: cwd.clone().into(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, @@ -262,7 +262,7 @@ pub(crate) async fn execute_user_shell_command( turn_id: turn_context.sub_id.clone(), completed_at_ms: now_unix_timestamp_ms(), command: display_command.clone(), - cwd: cwd.clone(), + cwd: cwd.clone().into(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, @@ -287,7 +287,7 @@ pub(crate) async fn execute_user_shell_command( turn_id: turn_context.sub_id.clone(), completed_at_ms: now_unix_timestamp_ms(), command: display_command.clone(), - cwd: cwd.clone(), + cwd: cwd.clone().into(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, @@ -332,7 +332,7 @@ pub(crate) async fn execute_user_shell_command( turn_id: turn_context.sub_id.clone(), completed_at_ms: now_unix_timestamp_ms(), command: display_command, - cwd, + cwd: cwd.into(), parsed_cmd, source: ExecCommandSource::UserShell, interaction_input: None, diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index bb385f37b5..8034dfd627 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -96,7 +96,7 @@ fn tracker_update_for_known_delta<'a>( pub(crate) async fn emit_exec_command_begin( ctx: ToolEventCtx<'_>, command: &[String], - cwd: &AbsolutePathBuf, + cwd: &PathUri, parsed_cmd: &[ParsedCommand], source: ExecCommandSource, interaction_input: Option, @@ -123,7 +123,7 @@ pub(crate) async fn emit_exec_command_begin( pub(crate) enum ToolEmitter { Shell { command: Vec, - cwd: AbsolutePathBuf, + cwd: PathUri, source: ExecCommandSource, parsed_cmd: Vec, }, @@ -146,7 +146,7 @@ impl ToolEmitter { let parsed_cmd = parse_command(&command); Self::Shell { command, - cwd, + cwd: PathUri::from_abs_path(&cwd), source, parsed_cmd, } @@ -321,15 +321,11 @@ impl ToolEmitter { }, stage, ) => { - // TODO(anp): Migrate exec command protocol events to PathUri. - let Ok(cwd) = cwd.to_abs_path() else { - return; - }; emit_exec_stage( ctx, ExecCommandInput::new( command, - &cwd, + cwd, parsed_cmd, *source, /*interaction_input*/ None, @@ -437,7 +433,7 @@ impl ToolEmitter { struct ExecCommandInput<'a> { command: &'a [String], - cwd: &'a AbsolutePathBuf, + cwd: &'a PathUri, parsed_cmd: &'a [ParsedCommand], source: ExecCommandSource, interaction_input: Option<&'a str>, @@ -447,7 +443,7 @@ struct ExecCommandInput<'a> { impl<'a> ExecCommandInput<'a> { fn new( command: &'a [String], - cwd: &'a AbsolutePathBuf, + cwd: &'a PathUri, parsed_cmd: &'a [ParsedCommand], source: ExecCommandSource, interaction_input: Option<&'a str>, diff --git a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs index 593dd7ea05..ae3c9406a9 100644 --- a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs +++ b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs @@ -20,6 +20,7 @@ use codex_features::Feature; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecCommandStatus; use codex_protocol::protocol::Op; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::protocol::TurnEnvironmentSelections; @@ -127,10 +128,6 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { }) .await?; - // TODO(anp): Re-enable these event assertions once exec command events retain a - // PathUri cwd. Today the host-native event conversion drops begin/end events for a - // foreign cwd. - /* let mut begin = None; let mut end = None; let mut turn_complete = false; @@ -161,13 +158,9 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { assert_eq!(&begin.command[1..], ["-NoProfile", "-Command", COMMAND]); let end = end.context("exec_command should emit an end event")?; + let expected_cwd = PathUri::parse("file:///C:/windows")?; + assert_eq!((&begin.cwd, &end.cwd), (&expected_cwd, &expected_cwd)); assert_eq!((end.exit_code, end.status), (0, ExecCommandStatus::Completed)); - */ - - wait_for_event(&test.codex, |event| { - matches!(event, EventMsg::TurnComplete(_)) - }) - .await; let request = response_mock .last_request() diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 7ac0774316..8187fc0cb1 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -437,7 +437,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { assert_command(&begin_event.command, "-lc", "/bin/echo hello unified exec"); - assert_eq!(begin_event.cwd.as_path(), cwd.as_path()); + assert_eq!(begin_event.cwd, PathUri::from_path(&cwd)?); wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -507,8 +507,8 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { .await; assert_eq!( - begin_event.cwd.as_path(), - workdir.as_path(), + begin_event.cwd, + PathUri::from_path(&workdir)?, "exec_command cwd should resolve relative workdir against turn cwd", ); @@ -569,8 +569,8 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { .await; assert_eq!( - begin_event.cwd.as_path(), - workdir.as_path(), + begin_event.cwd, + PathUri::from_path(&workdir)?, "exec_command cwd should reflect the requested workdir override" ); diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 60667bb5df..a667cdd53d 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -68,10 +68,9 @@ impl EventProcessorWithHumanOutput { match item { ThreadItem::CommandExecution { command, cwd, .. } => { eprintln!( - "{}\n{} in {}", + "{}\n{} in {cwd}", "exec".style(self.italic).style(self.magenta), command.style(self.bold), - cwd.display() ); } ThreadItem::McpToolCall { server, tool, .. } => { diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 2cee9b0e83..0f88670940 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -170,7 +170,7 @@ fn command_execution_started_and_completed_translate_to_thread_events() { let command_item = ThreadItem::CommandExecution { id: "cmd-1".to_string(), command: "ls".to_string(), - cwd: test_path_buf("/tmp/project").abs(), + cwd: test_path_buf("/tmp/project").abs().into(), process_id: Some("123".to_string()), source: CommandExecutionSource::UserShell, status: ApiCommandExecutionStatus::InProgress, @@ -210,7 +210,7 @@ fn command_execution_started_and_completed_translate_to_thread_events() { item: ThreadItem::CommandExecution { id: "cmd-1".to_string(), command: "ls".to_string(), - cwd: test_path_buf("/tmp/project").abs(), + cwd: test_path_buf("/tmp/project").abs().into(), process_id: Some("123".to_string()), source: CommandExecutionSource::UserShell, status: ApiCommandExecutionStatus::Completed, @@ -1321,7 +1321,7 @@ fn turn_completion_reconciles_started_items_from_turn_items() { item: ThreadItem::CommandExecution { id: "cmd-1".to_string(), command: "ls".to_string(), - cwd: test_path_buf("/tmp/project").abs(), + cwd: test_path_buf("/tmp/project").abs().into(), process_id: Some("123".to_string()), source: CommandExecutionSource::UserShell, status: ApiCommandExecutionStatus::InProgress, @@ -1361,7 +1361,7 @@ fn turn_completion_reconciles_started_items_from_turn_items() { items: vec![ThreadItem::CommandExecution { id: "cmd-1".to_string(), command: "ls".to_string(), - cwd: test_path_buf("/tmp/project").abs(), + cwd: test_path_buf("/tmp/project").abs().into(), process_id: Some("123".to_string()), source: CommandExecutionSource::UserShell, status: ApiCommandExecutionStatus::Completed, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index e77590c5e4..d51a30addf 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -3246,7 +3246,7 @@ pub struct ExecCommandBeginEvent { /// The command to be executed. pub command: Vec, /// The command's working directory if not the default cwd for the agent. - pub cwd: AbsolutePathBuf, + pub cwd: PathUri, pub parsed_cmd: Vec, /// Where the command originated. Defaults to Agent for backward compatibility. #[serde(default)] @@ -3272,7 +3272,7 @@ pub struct ExecCommandEndEvent { /// The command that was executed. pub command: Vec, /// The command's working directory if not the default cwd for the agent. - pub cwd: AbsolutePathBuf, + pub cwd: PathUri, pub parsed_cmd: Vec, /// Where the command originated. Defaults to Agent for backward compatibility. #[serde(default)] diff --git a/codex-rs/rollout-trace/src/protocol_event.rs b/codex-rs/rollout-trace/src/protocol_event.rs index bbba245307..a998c98fc0 100644 --- a/codex-rs/rollout-trace/src/protocol_event.rs +++ b/codex-rs/rollout-trace/src/protocol_event.rs @@ -24,6 +24,7 @@ use codex_protocol::protocol::PatchApplyStatus; use codex_protocol::protocol::SubAgentActivityEvent; use codex_protocol::protocol::TurnAbortReason; use serde::Serialize; +use std::time::Duration; use crate::AgentThreadId; use crate::CodexTurnId; @@ -120,8 +121,12 @@ impl Serialize for ToolRuntimePayload<'_> { S: serde::Serializer, { match self { - ToolRuntimePayload::ExecCommandBegin(event) => event.serialize(serializer), - ToolRuntimePayload::ExecCommandEnd(event) => event.serialize(serializer), + ToolRuntimePayload::ExecCommandBegin(event) => { + ExecCommandBeginTracePayload::from(*event).serialize(serializer) + } + ToolRuntimePayload::ExecCommandEnd(event) => { + ExecCommandEndTracePayload::from(*event).serialize(serializer) + } ToolRuntimePayload::PatchApplyBegin(event) => event.serialize(serializer), ToolRuntimePayload::PatchApplyEnd(event) => event.serialize(serializer), ToolRuntimePayload::McpToolCallBegin(event) => event.serialize(serializer), @@ -139,6 +144,119 @@ impl Serialize for ToolRuntimePayload<'_> { } } +/// Rollout-trace representation of an exec begin event. +/// +/// Rollout traces share the rollout compatibility requirement that paths remain path-flavored +/// strings on disk, even though live events carry `PathUri` internally. +#[derive(Serialize)] +struct ExecCommandBeginTracePayload<'a> { + call_id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + process_id: Option<&'a str>, + turn_id: &'a str, + started_at_ms: i64, + command: &'a [String], + cwd: String, + parsed_cmd: &'a [codex_protocol::parse_command::ParsedCommand], + source: ExecCommandSource, + #[serde(skip_serializing_if = "Option::is_none")] + interaction_input: Option<&'a str>, +} + +impl<'a> From<&'a ExecCommandBeginEvent> for ExecCommandBeginTracePayload<'a> { + fn from(event: &'a ExecCommandBeginEvent) -> Self { + let ExecCommandBeginEvent { + call_id, + process_id, + turn_id, + started_at_ms, + command, + cwd, + parsed_cmd, + source, + interaction_input, + } = event; + Self { + call_id, + process_id: process_id.as_deref(), + turn_id, + started_at_ms: *started_at_ms, + command, + cwd: cwd.inferred_native_path_string(), + parsed_cmd, + source: *source, + interaction_input: interaction_input.as_deref(), + } + } +} + +/// Rollout-trace representation of an exec end event. +/// +/// Like [`ExecCommandBeginTracePayload`], this renders `cwd` as an inferred native path to preserve +/// the on-disk format rather than serializing the internal `PathUri`. +#[derive(Serialize)] +struct ExecCommandEndTracePayload<'a> { + call_id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + process_id: Option<&'a str>, + turn_id: &'a str, + completed_at_ms: i64, + command: &'a [String], + cwd: String, + parsed_cmd: &'a [codex_protocol::parse_command::ParsedCommand], + source: ExecCommandSource, + #[serde(skip_serializing_if = "Option::is_none")] + interaction_input: Option<&'a str>, + stdout: &'a str, + stderr: &'a str, + aggregated_output: &'a str, + exit_code: i32, + duration: Duration, + formatted_output: &'a str, + status: &'a ExecCommandStatus, +} + +impl<'a> From<&'a ExecCommandEndEvent> for ExecCommandEndTracePayload<'a> { + fn from(event: &'a ExecCommandEndEvent) -> Self { + let ExecCommandEndEvent { + call_id, + process_id, + turn_id, + completed_at_ms, + command, + cwd, + parsed_cmd, + source, + interaction_input, + stdout, + stderr, + aggregated_output, + exit_code, + duration, + formatted_output, + status, + } = event; + Self { + call_id, + process_id: process_id.as_deref(), + turn_id, + completed_at_ms: *completed_at_ms, + command, + cwd: cwd.inferred_native_path_string(), + parsed_cmd, + source: *source, + interaction_input: interaction_input.as_deref(), + stdout, + stderr, + aggregated_output, + exit_code: *exit_code, + duration: *duration, + formatted_output, + status, + } + } +} + pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option> { match event { EventMsg::ExecCommandBegin(event) if event.source != ExecCommandSource::UserShell => { diff --git a/codex-rs/rollout-trace/src/protocol_event_tests.rs b/codex-rs/rollout-trace/src/protocol_event_tests.rs index b18c7200ac..200c35d94d 100644 --- a/codex-rs/rollout-trace/src/protocol_event_tests.rs +++ b/codex-rs/rollout-trace/src/protocol_event_tests.rs @@ -1,10 +1,15 @@ use codex_protocol::AgentPath; use codex_protocol::ThreadId; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecCommandBeginEvent; +use codex_protocol::protocol::ExecCommandEndEvent; +use codex_protocol::protocol::ExecCommandSource; +use codex_protocol::protocol::ExecCommandStatus; use codex_protocol::protocol::SubAgentActivityEvent; use codex_protocol::protocol::SubAgentActivityKind; use pretty_assertions::assert_eq; use serde_json::json; +use std::time::Duration; use super::ToolRuntimeTraceEvent; use super::tool_runtime_trace_event; @@ -44,3 +49,81 @@ fn sub_agent_activity_is_a_terminal_tool_runtime_event() -> anyhow::Result<()> { ); Ok(()) } + +#[test] +fn exec_command_trace_payloads_use_inferred_native_cwd() -> anyhow::Result<()> { + // Convention inference depends on the URI spelling, not the test host, so exercise both + // Windows and POSIX paths on every platform. + let begin = EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: "call-begin".to_string(), + process_id: Some("process-1".to_string()), + turn_id: "turn-1".to_string(), + started_at_ms: 1234, + command: vec!["pwd".to_string()], + cwd: "file:///C:/windows".parse()?, + parsed_cmd: Vec::new(), + source: ExecCommandSource::Agent, + interaction_input: None, + }); + let end = EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "call-end".to_string(), + process_id: None, + turn_id: "turn-1".to_string(), + completed_at_ms: 2345, + command: vec!["pwd".to_string()], + cwd: "file:///workspace/project".parse()?, + parsed_cmd: Vec::new(), + source: ExecCommandSource::UnifiedExecInteraction, + interaction_input: Some("input".to_string()), + stdout: "output".to_string(), + stderr: String::new(), + aggregated_output: "output".to_string(), + exit_code: 0, + duration: Duration::from_millis(250), + formatted_output: "output".to_string(), + status: ExecCommandStatus::Completed, + }); + + let Some(ToolRuntimeTraceEvent::Started { payload, .. }) = tool_runtime_trace_event(&begin) + else { + panic!("expected started tool runtime event"); + }; + assert_eq!( + serde_json::to_value(payload)?, + json!({ + "call_id": "call-begin", + "process_id": "process-1", + "turn_id": "turn-1", + "started_at_ms": 1234, + "command": ["pwd"], + "cwd": r"C:\windows", + "parsed_cmd": [], + "source": "agent" + }) + ); + + let Some(ToolRuntimeTraceEvent::Ended { payload, .. }) = tool_runtime_trace_event(&end) else { + panic!("expected ended tool runtime event"); + }; + assert_eq!( + serde_json::to_value(payload)?, + json!({ + "call_id": "call-end", + "turn_id": "turn-1", + "completed_at_ms": 2345, + "command": ["pwd"], + "cwd": "/workspace/project", + "parsed_cmd": [], + "source": "unified_exec_interaction", + "interaction_input": "input", + "stdout": "output", + "stderr": "", + "aggregated_output": "output", + "exit_code": 0, + "duration": {"secs": 0, "nanos": 250000000}, + "formatted_output": "output", + "status": "completed" + }) + ); + Ok(()) +} diff --git a/codex-rs/tui/src/app/agent_status_feed_tests.rs b/codex-rs/tui/src/app/agent_status_feed_tests.rs index 5b8bc4b59b..d7583d2b44 100644 --- a/codex-rs/tui/src/app/agent_status_feed_tests.rs +++ b/codex-rs/tui/src/app/agent_status_feed_tests.rs @@ -12,7 +12,9 @@ fn agent_status_uses_bounded_buffered_activity() { item: ThreadItem::CommandExecution { id: "command-1".to_string(), command: "cargo test -p codex-tui".to_string(), - cwd: AbsolutePathBuf::try_from("/workspace").expect("absolute path"), + cwd: AbsolutePathBuf::try_from("/workspace") + .expect("absolute path") + .into(), process_id: None, source: CommandExecutionSource::Agent, status: CommandExecutionStatus::Completed, diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 406cd687ff..d204105356 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -619,7 +619,7 @@ mod tests { reason: None, network_approval_context: None, command: Some("echo hi".to_string()), - cwd: Some(test_path_buf("/tmp").abs()), + cwd: Some(test_path_buf("/tmp").abs().into()), command_actions: None, additional_permissions: None, proposed_execpolicy_amendment: None, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 0d4ef9ee3d..51151206fa 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -4697,7 +4697,7 @@ fn exec_approval_request( reason: Some("needs approval".to_string()), network_approval_context: None, command: Some("echo hello".to_string()), - cwd: Some(test_path_buf("/tmp/project").abs()), + cwd: Some(test_path_buf("/tmp/project").abs().into()), command_actions: None, additional_permissions: None, proposed_execpolicy_amendment: None, diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index 6b76a65186..00b7fcdedb 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -492,7 +492,7 @@ mod tests { reason: Some("needs approval".to_string()), network_approval_context: None, command: Some("echo hello".to_string()), - cwd: Some(test_path_buf("/tmp/project").abs()), + cwd: Some(test_path_buf("/tmp/project").abs().into()), command_actions: None, additional_permissions: None, proposed_execpolicy_amendment: None, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ab6d493d49..94cdd9e03d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -840,6 +840,12 @@ fn exec_approval_request_from_params( params: CommandExecutionRequestApprovalParams, fallback_cwd: &AbsolutePathBuf, ) -> ExecApprovalRequestEvent { + // TODO(anp): Keep this as PathUri once `tui::approval_events::ExecApprovalRequestEvent` and + // approval rendering support foreign paths. + let cwd = params + .cwd + .and_then(|cwd| cwd.to_inferred_abs_path()) + .unwrap_or_else(|| fallback_cwd.clone()); ExecApprovalRequestEvent { call_id: params.item_id, command: params @@ -847,7 +853,7 @@ fn exec_approval_request_from_params( .as_deref() .map(split_command_string) .unwrap_or_default(), - cwd: params.cwd.unwrap_or_else(|| fallback_cwd.clone()), + cwd, reason: params.reason, network_approval_context: params.network_approval_context, additional_permissions: params.additional_permissions, diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs index f8ecb3d0aa..7b4c23e273 100644 --- a/codex-rs/tui/src/chatwidget/interrupts.rs +++ b/codex-rs/tui/src/chatwidget/interrupts.rs @@ -177,7 +177,7 @@ mod tests { ThreadItem::CommandExecution { id: call_id.to_string(), command: "true".to_string(), - cwd: AbsolutePathBuf::current_dir().expect("current dir"), + cwd: AbsolutePathBuf::current_dir().expect("current dir").into(), process_id: None, source: CommandExecutionSource::Agent, status: CommandExecutionStatus::InProgress, diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index f4fcc85e43..383ab43053 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -521,7 +521,7 @@ async fn live_app_server_command_execution_strips_shell_wrapper() { item: AppServerThreadItem::CommandExecution { id: "cmd-1".to_string(), command: command.clone(), - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), process_id: None, source: AppServerCommandExecutionSource::UserShell, status: AppServerCommandExecutionStatus::InProgress, @@ -543,7 +543,7 @@ async fn live_app_server_command_execution_strips_shell_wrapper() { item: AppServerThreadItem::CommandExecution { id: "cmd-1".to_string(), command, - cwd: test_path_buf("/tmp").abs(), + cwd: test_path_buf("/tmp").abs().into(), process_id: None, source: AppServerCommandExecutionSource::UserShell, status: AppServerCommandExecutionStatus::Completed, diff --git a/codex-rs/tui/src/chatwidget/tests/approval_requests.rs b/codex-rs/tui/src/chatwidget/tests/approval_requests.rs index 5c9b92b517..1ec9a9c381 100644 --- a/codex-rs/tui/src/chatwidget/tests/approval_requests.rs +++ b/codex-rs/tui/src/chatwidget/tests/approval_requests.rs @@ -65,7 +65,7 @@ fn app_server_exec_approval_request_splits_shell_wrapped_command() { shlex::try_join(["/bin/zsh", "-lc", script]) .expect("round-trippable shell wrapper"), ), - cwd: Some(test_path_buf("/tmp").abs()), + cwd: Some(test_path_buf("/tmp").abs().into()), command_actions: None, additional_permissions: None, proposed_execpolicy_amendment: None, @@ -107,7 +107,7 @@ fn app_server_exec_approval_request_preserves_permissions_context() { protocol: codex_app_server_protocol::NetworkApprovalProtocol::Socks5Tcp, }), command: Some("ls".to_string()), - cwd: Some(test_path_buf("/tmp").abs()), + cwd: Some(test_path_buf("/tmp").abs().into()), command_actions: None, additional_permissions: Some(AppServerAdditionalPermissionProfile { network: Some(AppServerAdditionalNetworkPermissions { diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 60eb2d891d..1b8a7beddb 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -64,7 +64,7 @@ fn app_server_exec_approval_request_splits_shell_wrapped_command() { shlex::try_join(["/bin/zsh", "-lc", script]) .expect("round-trippable shell wrapper"), ), - cwd: Some(test_path_buf("/tmp").abs()), + cwd: Some(test_path_buf("/tmp").abs().into()), command_actions: None, additional_permissions: None, proposed_execpolicy_amendment: None, @@ -363,7 +363,7 @@ async fn exec_end_without_begin_uses_event_command() { AppServerThreadItem::CommandExecution { id: "call-orphan".to_string(), command: codex_shell_command::parse_command::shlex_join(&command), - cwd, + cwd: cwd.into(), process_id: None, source: ExecCommandSource::Agent, status: AppServerCommandExecutionStatus::Completed, diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index dc32e8c7ba..9a50b37832 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -821,7 +821,7 @@ pub(super) fn begin_exec_with_source( let item = AppServerThreadItem::CommandExecution { id: call_id.to_string(), command: codex_shell_command::parse_command::shlex_join(&command), - cwd: chat.config.cwd.clone(), + cwd: chat.config.cwd.clone().into(), process_id: None, source, status: AppServerCommandExecutionStatus::InProgress, @@ -844,7 +844,7 @@ pub(super) fn begin_unified_exec_startup( let item = AppServerThreadItem::CommandExecution { id: call_id.to_string(), command: codex_shell_command::parse_command::shlex_join(&command), - cwd: chat.config.cwd.clone(), + cwd: chat.config.cwd.clone().into(), process_id: Some(process_id.to_string()), source: ExecCommandSource::UnifiedExecStartup, status: AppServerCommandExecutionStatus::InProgress, diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 1667501335..d8c79f56b1 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -3614,7 +3614,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { AppServerThreadItem::CommandExecution { id: "c1".into(), command: codex_shell_command::parse_command::shlex_join(&command), - cwd: cwd.clone(), + cwd: cwd.clone().into(), process_id: None, source: ExecCommandSource::Agent, status: AppServerCommandExecutionStatus::InProgress, @@ -3629,7 +3629,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { AppServerThreadItem::CommandExecution { id: "c1".into(), command: codex_shell_command::parse_command::shlex_join(&command), - cwd, + cwd: cwd.into(), process_id: None, source: ExecCommandSource::Agent, status: AppServerCommandExecutionStatus::Completed, diff --git a/codex-rs/utils/path-uri/src/api_path_string.rs b/codex-rs/utils/path-uri/src/api_path_string.rs index 5fc204b178..3e33f316ca 100644 --- a/codex-rs/utils/path-uri/src/api_path_string.rs +++ b/codex-rs/utils/path-uri/src/api_path_string.rs @@ -70,11 +70,21 @@ impl LegacyAppPathString { PathUri::from_absolute_native_path(&self.0, convention).ok_or_else(|| { LegacyAppPathStringError::InvalidNativePath { path: self.0.clone(), - convention, + convention: Some(convention), } }) } + /// Parses this API string as an absolute path using the convention inferred from its spelling. + pub fn to_inferred_path_uri(&self) -> Option { + PathUri::try_from(self.clone()).ok() + } + + /// Parses this API string as a host-native absolute path. + pub fn to_inferred_abs_path(&self) -> Option { + AbsolutePathBuf::try_from(self.clone()).ok() + } + /// Infers the path convention of an absolute API path from its spelling. /// /// Relative paths and ambiguous spellings return `None`. In particular, @@ -111,6 +121,44 @@ impl From for LegacyAppPathString { } } +impl From for LegacyAppPathString { + fn from(path: PathUri) -> Self { + Self(path.inferred_native_path_string()) + } +} + +impl TryFrom for PathUri { + type Error = LegacyAppPathStringError; + + fn try_from(path: LegacyAppPathString) -> Result { + let Some(convention) = path.infer_absolute_path_convention() else { + return Err(LegacyAppPathStringError::InvalidNativePath { + path: path.0, + convention: None, + }); + }; + PathUri::from_absolute_native_path(path.as_str(), convention).ok_or({ + LegacyAppPathStringError::InvalidNativePath { + path: path.0, + convention: Some(convention), + } + }) + } +} + +impl TryFrom for AbsolutePathBuf { + type Error = LegacyAppPathStringError; + + fn try_from(path: LegacyAppPathString) -> Result { + AbsolutePathBuf::from_absolute_path_checked(path.as_str()).map_err(|_| { + LegacyAppPathStringError::InvalidNativePath { + path: path.0, + convention: None, + } + }) + } +} + fn render_opaque_fallback( path: &PathUri, path_bytes: &[u8], @@ -279,10 +327,13 @@ pub enum LegacyAppPathStringError { path: String, convention: PathConvention, }, - #[error("path `{path}` is not absolute using {convention} path syntax")] + #[error( + "path `{path}` is not absolute{convention}", + convention = .convention.map(|convention| format!(" using {convention} path syntax")).unwrap_or_default() + )] InvalidNativePath { path: String, - convention: PathConvention, + convention: Option, }, } diff --git a/codex-rs/utils/path-uri/src/api_path_string_tests.rs b/codex-rs/utils/path-uri/src/api_path_string_tests.rs index 42829a783e..3439ca11ea 100644 --- a/codex-rs/utils/path-uri/src/api_path_string_tests.rs +++ b/codex-rs/utils/path-uri/src/api_path_string_tests.rs @@ -369,7 +369,21 @@ fn relative_api_path_is_invalid_when_converted_to_a_path_uri() { path.to_path_uri(PathConvention::Posix), Err(LegacyAppPathStringError::InvalidNativePath { path: raw_path.to_string(), - convention: PathConvention::Posix, + convention: Some(PathConvention::Posix), + }) + ); + assert_eq!( + PathUri::try_from(path.clone()), + Err(LegacyAppPathStringError::InvalidNativePath { + path: raw_path.to_string(), + convention: None, + }) + ); + assert_eq!( + AbsolutePathBuf::try_from(path), + Err(LegacyAppPathStringError::InvalidNativePath { + path: raw_path.to_string(), + convention: None, }) ); } @@ -388,7 +402,7 @@ fn other_non_absolute_api_paths_cannot_be_converted_to_path_uris() { path.to_path_uri(convention), Err(LegacyAppPathStringError::InvalidNativePath { path: raw_path.to_string(), - convention, + convention: Some(convention), }) ); } @@ -423,6 +437,51 @@ fn infers_absolute_path_conventions_from_api_text() { } } +#[test] +fn converts_absolute_api_paths_using_the_inferred_convention() { + for (raw_path, convention, expected_uri) in [ + ( + r"C:\workspace\file.rs", + PathConvention::Windows, + "file:///C:/workspace/file.rs", + ), + ( + "/workspace/file.rs", + PathConvention::Posix, + "file:///workspace/file.rs", + ), + ] { + let path = serde_json::from_value::(serde_json::json!(raw_path)) + .expect("absolute API path should deserialize"); + + assert_eq!( + path.to_inferred_path_uri(), + Some(PathUri::parse(expected_uri).expect("expected URI should parse")), + ); + assert_eq!( + PathUri::try_from(path.clone()), + path.to_path_uri(convention) + ); + } +} + +#[test] +fn converts_native_api_path_to_inferred_absolute_path() { + #[cfg(windows)] + let raw_path = r"C:\workspace\file.rs"; + #[cfg(not(windows))] + let raw_path = "/workspace/file.rs"; + let path = serde_json::from_value::(serde_json::json!(raw_path)) + .expect("absolute API path should deserialize"); + let expected = AbsolutePathBuf::try_from(raw_path).expect("native absolute path should parse"); + + assert_eq!( + AbsolutePathBuf::try_from(path.clone()), + Ok(expected.clone()) + ); + assert_eq!(path.to_inferred_abs_path(), Some(expected)); +} + #[test] fn foreign_absolute_syntax_deserializes_without_host_interpretation() { for (raw_path, convention) in [ diff --git a/codex-rs/utils/path-uri/src/tests.rs b/codex-rs/utils/path-uri/src/tests.rs index 6e7dc7294d..4ccb9e9c81 100644 --- a/codex-rs/utils/path-uri/src/tests.rs +++ b/codex-rs/utils/path-uri/src/tests.rs @@ -119,6 +119,11 @@ fn inferred_native_path_string_uses_the_inferred_convention() { expected, "rendering {uri}" ); + assert_eq!( + LegacyAppPathString::from(path).as_str(), + expected, + "rendering typed API path {uri}" + ); } } From 2c7802e7cf3ad53733ca9fb603f270debcca280f Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Wed, 17 Jun 2026 22:45:10 -0700 Subject: [PATCH 023/112] [codex] Split plugin and skill warmup tracing (#28605) ## What changed - promote plugin config loading to an info-level `plugins_for_config` span - promote skill config loading to an info-level `skills_for_config` span - attach stable OpenTelemetry names to both spans ## Why `session_init.plugin_skill_warmup` currently combines plugin loading and skill loading, which makes cold-start traces unable to identify which phase dominates. These child spans preserve the existing aggregate while making the two costs independently visible. Context: https://openai.slack.com/archives/C0ARA9GF5D4/p1781639496496439?thread_ts=1781202444.891669&cid=C0ARA9GF5D4 ## Impact This is observability-only. It does not change plugin or skill loading behavior. ## Validation - `just test -p codex-core-skills -p codex-core-plugins` (347 passed) - `just fmt` --- codex-rs/core-plugins/src/manager.rs | 9 +++++++-- codex-rs/core-skills/src/service.rs | 7 ++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index fb004f4943..04f2ff6132 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -466,9 +466,14 @@ impl PluginsManager { } #[instrument( - level = "trace", + name = "plugins_for_config", + level = "info", skip_all, - fields(force_reload, plugins_enabled = config.plugins_enabled) + fields( + otel.name = "plugins_for_config", + force_reload, + plugins_enabled = config.plugins_enabled + ) )] pub(crate) async fn plugins_for_config_with_force_reload( &self, diff --git a/codex-rs/core-skills/src/service.rs b/codex-rs/core-skills/src/service.rs index 29d34c3809..d20f5e39e4 100644 --- a/codex-rs/core-skills/src/service.rs +++ b/codex-rs/core-skills/src/service.rs @@ -105,7 +105,12 @@ impl SkillsService { /// This path uses a cache keyed by the effective skill-relevant config state rather than just /// cwd so role-local and session-local skill overrides cannot bleed across sessions that happen /// to share a directory. - #[instrument(level = "trace", skip_all)] + #[instrument( + name = "skills_for_config", + level = "info", + skip_all, + fields(otel.name = "skills_for_config") + )] pub async fn snapshot_for_config( &self, input: &SkillsLoadInput, From c73296a0f095e72dbb646909c613ae09c9459c3a Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Thu, 18 Jun 2026 00:16:46 -0700 Subject: [PATCH 024/112] [codex] Pass plugin namespace into skill loading (#28608) ## What changed - retain the parsed plugin manifest namespace on loaded plugins - carry that namespace through `PluginSkillRoot` and `SkillRoot` - use the provided namespace when qualifying plugin skill names - include the namespace in the skills cache key ## Why Plugin loading has already parsed `plugin.json`, but skill parsing currently walks every `SKILL.md` ancestor and probes/reads the manifest again to reconstruct the same namespace. Passing the parsed namespace removes those repeated filesystem calls, which are particularly costly on remote filesystems. Context: https://openai.slack.com/archives/C0ARA9GF5D4/p1781639496496439?thread_ts=1781202444.891669&cid=C0ARA9GF5D4 ## Impact Plugin skill names remain unchanged. A regression test uses a deliberately different on-disk manifest name to verify that plugin roots use the provided parsed namespace. ## Validation - `just test -p codex-core-skills -p codex-core-plugins -p codex-plugin -p codex-utils-plugins` (352 passed) - `just fix -p codex-core-skills -p codex-core-plugins -p codex-plugin -p codex-utils-plugins` - `just fmt` --- codex-rs/core-plugins/src/loader.rs | 10 +++-- codex-rs/core-plugins/src/manager.rs | 2 +- codex-rs/core-plugins/src/manager_tests.rs | 37 +++++++++++++------ codex-rs/core-skills/src/loader.rs | 29 ++++++++++++++- codex-rs/core-skills/src/loader_tests.rs | 14 ++++++- codex-rs/core-skills/src/service.rs | 9 ++++- codex-rs/core-skills/src/service_tests.rs | 10 ++++- codex-rs/ext/skills/src/provider/executor.rs | 1 + .../tests/executor_file_system_authority.rs | 1 + codex-rs/plugin/src/load_outcome.rs | 12 ++++++ codex-rs/utils/plugins/src/lib.rs | 1 + 11 files changed, 103 insertions(+), 23 deletions(-) diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index 846487aba6..029be960b6 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -1,6 +1,7 @@ use crate::app_mcp_routing::apply_app_mcp_routing_policy; use crate::app_mcp_routing::apps_route_available; use crate::is_openai_curated_marketplace_name; +use crate::manifest::PluginManifest; use crate::manifest::PluginManifestHooks; use crate::manifest::PluginManifestMcpServers; use crate::manifest::PluginManifestPaths; @@ -664,6 +665,7 @@ async fn load_plugin( let mut loaded_plugin = LoadedPlugin { config_name, manifest_name: None, + plugin_namespace: None, manifest_description: None, root, enabled: plugin.enabled, @@ -706,6 +708,7 @@ async fn load_plugin( }; let manifest_paths = &manifest.paths; + loaded_plugin.plugin_namespace = Some(manifest.name.clone()); match scope { PluginLoadScope::AllCapabilities { restriction_product, @@ -717,7 +720,7 @@ async fn load_plugin( let resolved_skills = load_plugin_skills( &plugin_root, &loaded_plugin_id, - manifest_paths, + &manifest, *restriction_product, skill_config_rules, ) @@ -785,17 +788,18 @@ impl ResolvedPluginSkills { pub async fn load_plugin_skills( plugin_root: &AbsolutePathBuf, plugin_id: &PluginId, - manifest_paths: &PluginManifestPaths, + manifest: &PluginManifest, restriction_product: Option, skill_config_rules: &SkillConfigRules, ) -> ResolvedPluginSkills { - let roots = plugin_skill_roots(plugin_root, manifest_paths) + let roots = plugin_skill_roots(plugin_root, &manifest.paths) .into_iter() .map(|path| SkillRoot { path, scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: Some(plugin_id.as_key()), + plugin_namespace: Some(manifest.name.clone()), plugin_root: Some(plugin_root.clone()), }) .collect::>(); diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 04f2ff6132..b1bb508eeb 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -1591,7 +1591,7 @@ impl PluginsManager { let resolved_skills = load_plugin_skills( &source_path, &plugin_id, - &manifest.paths, + &manifest, self.restriction_product, &codex_core_skills::config_rules::skill_config_rules_from_stack( &config.config_layer_stack, diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index b5d22911f9..f5073f68b0 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -623,6 +623,7 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() { vec![LoadedPlugin { config_name: "sample@test".to_string(), manifest_name: Some("sample".to_string()), + plugin_namespace: Some("sample".to_string()), manifest_description: Some( "Plugin that includes the sample MCP server and Skills".to_string(), ), @@ -1446,23 +1447,30 @@ async fn load_plugin_skills_dedupes_overlapping_manifest_roots() { &plugin_root.join("skills/edk/SKILL.md"), "---\nname: edk\ndescription: edk skill\n---\n", ); - let manifest_paths = crate::manifest::PluginManifestPaths { - skills: vec![ - plugin_root.join("skills"), - plugin_root.join("skills/abc"), - plugin_root.join("skills/edk"), - plugin_root.join("skills/abc"), - ], - mcp_servers: None, - apps: None, - hooks: None, + let manifest = crate::manifest::PluginManifest { + name: "sample".to_string(), + version: None, + description: None, + keywords: Vec::new(), + paths: crate::manifest::PluginManifestPaths { + skills: vec![ + plugin_root.join("skills"), + plugin_root.join("skills/abc"), + plugin_root.join("skills/edk"), + plugin_root.join("skills/abc"), + ], + mcp_servers: None, + apps: None, + hooks: None, + }, + interface: None, }; let plugin_id = PluginId::parse("sample@test").expect("plugin id should parse"); let resolved = load_plugin_skills( &plugin_root, &plugin_id, - &manifest_paths, + &manifest, /*restriction_product*/ None, &SkillConfigRules::default(), ) @@ -1677,6 +1685,7 @@ async fn load_plugins_preserves_disabled_plugins_without_effective_contributions vec![LoadedPlugin { config_name: "sample@test".to_string(), manifest_name: None, + plugin_namespace: None, manifest_description: None, root: AbsolutePathBuf::try_from(plugin_root).unwrap(), enabled: false, @@ -1845,6 +1854,12 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() { let plugin = |config_name: &str, dir_name: &str, manifest_name: &str| LoadedPlugin { config_name: config_name.to_string(), manifest_name: Some(manifest_name.to_string()), + plugin_namespace: Some( + config_name + .split_once('@') + .map_or(config_name, |(name, _)| name) + .to_string(), + ), manifest_description: None, root: AbsolutePathBuf::try_from(codex_home.path().join(dir_name)).unwrap(), enabled: true, diff --git a/codex-rs/core-skills/src/loader.rs b/codex-rs/core-skills/src/loader.rs index e2877349db..64861c45eb 100644 --- a/codex-rs/core-skills/src/loader.rs +++ b/codex-rs/core-skills/src/loader.rs @@ -157,6 +157,7 @@ pub struct SkillRoot { pub scope: SkillScope, pub file_system: Arc, pub plugin_id: Option, + pub plugin_namespace: Option, pub plugin_root: Option, } @@ -178,6 +179,7 @@ where &root_path, root.scope, root.plugin_id.as_deref(), + root.plugin_namespace.as_deref(), root.plugin_root.as_ref(), &mut outcome, ) @@ -266,6 +268,7 @@ async fn skill_roots_with_home_dir( scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: Some(root.plugin_id), + plugin_namespace: Some(root.plugin_namespace), plugin_root: Some(root.plugin_root), })); roots.extend(extra_skill_roots.into_iter().map(|path| SkillRoot { @@ -273,6 +276,7 @@ async fn skill_roots_with_home_dir( scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_namespace: None, plugin_root: None, })); roots.extend(repo_agents_skill_roots(fs, config_layer_stack, cwd).await); @@ -303,6 +307,7 @@ fn skill_roots_from_layer_stack_inner( scope: SkillScope::Repo, file_system: Arc::clone(repo_fs), plugin_id: None, + plugin_namespace: None, plugin_root: None, }); } @@ -315,6 +320,7 @@ fn skill_roots_from_layer_stack_inner( scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_namespace: None, plugin_root: None, }); @@ -325,6 +331,7 @@ fn skill_roots_from_layer_stack_inner( scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_namespace: None, plugin_root: None, }); } @@ -336,6 +343,7 @@ fn skill_roots_from_layer_stack_inner( scope: SkillScope::System, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_namespace: None, plugin_root: None, }); } @@ -347,6 +355,7 @@ fn skill_roots_from_layer_stack_inner( scope: SkillScope::Admin, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_namespace: None, plugin_root: None, }); } @@ -382,6 +391,7 @@ async fn repo_agents_skill_roots( scope: SkillScope::Repo, file_system: Arc::clone(&fs), plugin_id: None, + plugin_namespace: None, plugin_root: None, }), Ok(_) => {} @@ -490,6 +500,7 @@ async fn discover_skills_under_root( root: &AbsolutePathBuf, scope: SkillScope, plugin_id: Option<&str>, + plugin_namespace: Option<&str>, plugin_root: Option<&AbsolutePathBuf>, outcome: &mut SkillLoadOutcome, ) { @@ -610,7 +621,16 @@ async fn discover_skills_under_root( } if metadata.is_file && file_name == SKILLS_FILENAME { - match parse_skill_file(fs, &path, scope, plugin_id, plugin_root.as_ref()).await { + match parse_skill_file( + fs, + &path, + scope, + plugin_id, + plugin_namespace, + plugin_root.as_ref(), + ) + .await + { Ok(skill) => { outcome.skills.push(skill); } @@ -641,6 +661,7 @@ async fn parse_skill_file( path: &AbsolutePathBuf, scope: SkillScope, plugin_id: Option<&str>, + plugin_namespace: Option<&str>, plugin_root: Option<&AbsolutePathBuf>, ) -> Result { let path_uri = PathUri::from_abs_path(path); @@ -671,7 +692,7 @@ async fn parse_skill_file( .map(sanitize_single_line) .filter(|value| !value.is_empty()) .unwrap_or_else(|| default_skill_name(path)); - let name = namespaced_skill_name(fs, path, &base_name).await; + let name = namespaced_skill_name(fs, path, &base_name, plugin_namespace).await; let description = parsed .description .as_deref() @@ -731,7 +752,11 @@ async fn namespaced_skill_name( fs: &dyn ExecutorFileSystem, path: &AbsolutePathBuf, base_name: &str, + plugin_namespace: Option<&str>, ) -> String { + if let Some(plugin_namespace) = plugin_namespace { + return format!("{plugin_namespace}:{base_name}"); + } plugin_namespace_for_skill_path(fs, path) .await .map(|namespace| format!("{namespace}:{base_name}")) diff --git a/codex-rs/core-skills/src/loader_tests.rs b/codex-rs/core-skills/src/loader_tests.rs index cc1d8c8e04..3a6ae9b1c4 100644 --- a/codex-rs/core-skills/src/loader_tests.rs +++ b/codex-rs/core-skills/src/loader_tests.rs @@ -850,6 +850,7 @@ interface: scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: Some("twilio-developer-kit@test".to_string()), + plugin_namespace: None, plugin_root: Some(plugin_root_abs.clone()), }]) .await; @@ -907,6 +908,7 @@ interface: scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: Some("twilio-developer-kit@test".to_string()), + plugin_namespace: None, plugin_root: Some(plugin_root.abs()), }]) .await; @@ -1053,6 +1055,7 @@ async fn loads_skills_via_symlinked_subdir_for_admin_scope() { scope: SkillScope::Admin, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_namespace: None, plugin_root: None, }]) .await; @@ -1135,6 +1138,7 @@ async fn system_scope_ignores_symlinked_subdir() { scope: SkillScope::System, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_namespace: None, plugin_root: None, }]) .await; @@ -1169,6 +1173,7 @@ async fn respects_max_scan_depth_for_user_scope() { scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_namespace: None, plugin_root: None, }]) .await; @@ -1256,7 +1261,7 @@ async fn falls_back_to_directory_name_when_skill_name_is_missing() { } #[tokio::test] -async fn namespaces_plugin_skills_using_plugin_name() { +async fn namespaces_plugin_skills_using_provided_namespace() { let root = tempfile::tempdir().expect("tempdir"); let plugin_root = root.path().join("plugins/sample"); let skill_path = write_raw_skill_at( @@ -1267,7 +1272,7 @@ async fn namespaces_plugin_skills_using_plugin_name() { fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); fs::write( plugin_root.join(".codex-plugin/plugin.json"), - r#"{"name":"sample"}"#, + r#"{"name":"should-not-be-read"}"#, ) .unwrap(); @@ -1276,6 +1281,7 @@ async fn namespaces_plugin_skills_using_plugin_name() { scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: Some("sample@test".to_string()), + plugin_namespace: Some("sample".to_string()), plugin_root: Some(plugin_root.abs()), }]) .await; @@ -1321,6 +1327,7 @@ async fn plugin_skill_name_length_limit_allows_max_qualified_name() { scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: Some("sample@test".to_string()), + plugin_namespace: Some(plugin_name.clone()), plugin_root: Some(plugin_root.abs()), }]) .await; @@ -1366,6 +1373,7 @@ async fn plugin_skill_name_length_limit_rejects_overlong_qualified_name() { scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: Some("sample@test".to_string()), + plugin_namespace: Some(plugin_name.clone()), plugin_root: Some(plugin_root.abs()), }]) .await; @@ -1805,6 +1813,7 @@ async fn deduplicates_by_path_preferring_first_root() { scope: SkillScope::Repo, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_namespace: None, plugin_root: None, }, SkillRoot { @@ -1812,6 +1821,7 @@ async fn deduplicates_by_path_preferring_first_root() { scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_namespace: None, plugin_root: None, }, ]) diff --git a/codex-rs/core-skills/src/service.rs b/codex-rs/core-skills/src/service.rs index d20f5e39e4..a6a1e30f57 100644 --- a/codex-rs/core-skills/src/service.rs +++ b/codex-rs/core-skills/src/service.rs @@ -256,7 +256,7 @@ impl SkillsService { #[derive(Debug, Clone, PartialEq, Eq, Hash)] struct ConfigSkillsCacheKey { - roots: Vec<(AbsolutePathBuf, u8, Option)>, + roots: Vec<(AbsolutePathBuf, u8, Option, Option)>, skill_config_rules: SkillConfigRules, } @@ -296,7 +296,12 @@ fn config_skills_cache_key( SkillScope::System => 2, SkillScope::Admin => 3, }; - (root.path.clone(), scope_rank, root.plugin_id.clone()) + ( + root.path.clone(), + scope_rank, + root.plugin_id.clone(), + root.plugin_namespace.clone(), + ) }) .collect(), skill_config_rules: skill_config_rules.clone(), diff --git a/codex-rs/core-skills/src/service_tests.rs b/codex-rs/core-skills/src/service_tests.rs index bc05ebf350..23a15f7955 100644 --- a/codex-rs/core-skills/src/service_tests.rs +++ b/codex-rs/core-skills/src/service_tests.rs @@ -55,7 +55,11 @@ fn write_plugin_skill( skill_path } -fn plugin_skill_root_for_skill_path(skill_path: &Path, plugin_id: &str) -> PluginSkillRoot { +fn plugin_skill_root_for_skill_path( + skill_path: &Path, + plugin_id: &str, + plugin_namespace: &str, +) -> PluginSkillRoot { let skills_root = skill_path .parent() .and_then(Path::parent) @@ -66,6 +70,7 @@ fn plugin_skill_root_for_skill_path(skill_path: &Path, plugin_id: &str) -> Plugi PluginSkillRoot { path: skills_root.abs(), plugin_id: plugin_id.to_string(), + plugin_namespace: plugin_namespace.to_string(), plugin_root: plugin_root.abs(), } } @@ -364,7 +369,8 @@ async fn skills_for_config_disables_plugin_skills_by_name() { &codex_home, &name_toggle_config("sample:sample-search", /*enabled*/ false), ); - let plugin_skill_root = plugin_skill_root_for_skill_path(&skill_path, "test-plugin@test"); + let plugin_skill_root = + plugin_skill_root_for_skill_path(&skill_path, "test-plugin@test", "sample"); let skills_service = SkillsService::new( codex_home.path().abs(), /*bundled_skills_enabled*/ true, diff --git a/codex-rs/ext/skills/src/provider/executor.rs b/codex-rs/ext/skills/src/provider/executor.rs index 2d26ae8bed..8da0265d09 100644 --- a/codex-rs/ext/skills/src/provider/executor.rs +++ b/codex-rs/ext/skills/src/provider/executor.rs @@ -81,6 +81,7 @@ impl SkillProvider for ExecutorSkillProvider { scope: SkillScope::User, file_system: Arc::clone(&file_system), plugin_id: None, + plugin_namespace: None, plugin_root: None, }]) .await, diff --git a/codex-rs/ext/skills/tests/executor_file_system_authority.rs b/codex-rs/ext/skills/tests/executor_file_system_authority.rs index 1eff53c3b9..bc2a87a740 100644 --- a/codex-rs/ext/skills/tests/executor_file_system_authority.rs +++ b/codex-rs/ext/skills/tests/executor_file_system_authority.rs @@ -196,6 +196,7 @@ async fn skill_loading_and_reads_use_the_supplied_executor_file_system() { canonical_root: canonical_root.clone(), }), plugin_id: None, + plugin_namespace: None, plugin_root: None, }]) .await; diff --git a/codex-rs/plugin/src/load_outcome.rs b/codex-rs/plugin/src/load_outcome.rs index 95b98d863a..ad83655463 100644 --- a/codex-rs/plugin/src/load_outcome.rs +++ b/codex-rs/plugin/src/load_outcome.rs @@ -17,6 +17,7 @@ const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024; pub struct LoadedPlugin { pub config_name: String, pub manifest_name: Option, + pub plugin_namespace: Option, pub manifest_description: Option, pub root: AbsolutePathBuf, pub enabled: bool, @@ -126,11 +127,15 @@ impl PluginLoadOutcome { let mut skill_roots = Vec::new(); let mut seen_paths = HashSet::new(); for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) { + let Some(plugin_namespace) = &plugin.plugin_namespace else { + continue; + }; for path in &plugin.skill_roots { if seen_paths.insert(path.clone()) { skill_roots.push(PluginSkillRoot { path: path.clone(), plugin_id: plugin.config_name.clone(), + plugin_namespace: plugin_namespace.clone(), plugin_root: plugin.root.clone(), }); } @@ -218,6 +223,12 @@ mod tests { LoadedPlugin { config_name: config_name.to_string(), manifest_name: None, + plugin_namespace: Some( + config_name + .split_once('@') + .map_or(config_name, |(name, _)| name) + .to_string(), + ), manifest_description: None, root: test_path(config_name), enabled: true, @@ -245,6 +256,7 @@ mod tests { vec![PluginSkillRoot { path: shared_root, plugin_id: "zeta@test".to_string(), + plugin_namespace: "zeta".to_string(), plugin_root: test_path("zeta@test"), }] ); diff --git a/codex-rs/utils/plugins/src/lib.rs b/codex-rs/utils/plugins/src/lib.rs index fe178e679c..203b1f22e4 100644 --- a/codex-rs/utils/plugins/src/lib.rs +++ b/codex-rs/utils/plugins/src/lib.rs @@ -15,5 +15,6 @@ pub use plugin_namespace::plugin_namespace_for_skill_path; pub struct PluginSkillRoot { pub path: AbsolutePathBuf, pub plugin_id: String, + pub plugin_namespace: String, pub plugin_root: AbsolutePathBuf, } From ecc4c30e281a9dff77ab45e0365c73a2a526a520 Mon Sep 17 00:00:00 2001 From: rka-oai Date: Thu, 18 Jun 2026 04:29:47 -0700 Subject: [PATCH 025/112] [codex] add rollout token budget configuration (varlength 1/N) (#28746) ## What This PR defines the structured configuration contract for shared rollout token budgets (across ALL agent threads under 1 rollout). ```toml [features.rollout_budget] enabled = true limit_tokens = 100000 reminder_interval_tokens = 10000 sampling_token_weight = 1.0 prefill_token_weight = 0.1 ``` The reminder interval defaults to 10% of the rollout limit. Sampling and prefill weights default to `1.0`. ## Scope This PR only defines and validates configuration. It does not track usage, inject reminders, or stop a rollout. Accounting and reminders are implemented in the stacked follow-up #28494. The existing `token_budget` feature remains unchanged. `rollout_budget` has its own feature key and configuration type. ## Tests The config test verifies that the structured fields resolve into `RolloutBudgetConfig` and do not enable the existing `token_budget` feature. Local checks: - `just write-config-schema` - `just test -p codex-core load_config_resolves_rollout_budget` - `cargo check -p codex-thread-manager-sample` - `git diff --check` The full workspace test suite was not run locally. --- codex-rs/config/src/schema.rs | 9 +++ codex-rs/core/config.schema.json | 45 ++++++++++++++ codex-rs/core/src/config/config_tests.rs | 61 ++++++++++++++++++ codex-rs/core/src/config/mod.rs | 72 ++++++++++++++++++++++ codex-rs/core/src/session/config_lock.rs | 30 +++++++++ codex-rs/features/src/feature_configs.rs | 29 +++++++++ codex-rs/features/src/lib.rs | 17 +++++ codex-rs/thread-manager-sample/src/main.rs | 1 + 8 files changed, 264 insertions(+) diff --git a/codex-rs/config/src/schema.rs b/codex-rs/config/src/schema.rs index c641f1703d..6d3f901bf4 100644 --- a/codex-rs/config/src/schema.rs +++ b/codex-rs/config/src/schema.rs @@ -44,6 +44,15 @@ pub fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema { ); continue; } + if feature.id == codex_features::Feature::RolloutBudget { + validation.properties.insert( + feature.key.to_string(), + schema_gen.subschema_for::>(), + ); + continue; + } if feature.id == codex_features::Feature::AppsMcpPathOverride { validation.properties.insert( feature.key.to_string(), diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 148901a704..852f6de39a 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -611,6 +611,9 @@ "responses_websockets_v2": { "type": "boolean" }, + "rollout_budget": { + "$ref": "#/definitions/FeatureToml_for_RolloutBudgetConfigToml" + }, "runtime_metrics": { "type": "boolean" }, @@ -908,6 +911,16 @@ } ] }, + "FeatureToml_for_RolloutBudgetConfigToml": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/RolloutBudgetConfigToml" + } + ] + }, "FeedbackConfigToml": { "additionalProperties": false, "properties": { @@ -2572,6 +2585,35 @@ } ] }, + "RolloutBudgetConfigToml": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "limit_tokens": { + "format": "int64", + "minimum": 1.0, + "type": "integer" + }, + "prefill_token_weight": { + "format": "double", + "minimum": 0.0, + "type": "number" + }, + "reminder_interval_tokens": { + "format": "int64", + "minimum": 1.0, + "type": "integer" + }, + "sampling_token_weight": { + "format": "double", + "minimum": 0.0, + "type": "number" + } + }, + "type": "object" + }, "SandboxMode": { "enum": [ "read-only", @@ -4772,6 +4814,9 @@ "responses_websockets_v2": { "type": "boolean" }, + "rollout_budget": { + "$ref": "#/definitions/FeatureToml_for_RolloutBudgetConfigToml" + }, "runtime_metrics": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index dd6169fbba..2244e3e1b7 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -461,6 +461,67 @@ direct_only_tool_namespaces = ["mcp__history", "mcp__notes"] Ok(()) } +#[tokio::test] +async fn load_config_resolves_rollout_budget() -> std::io::Result<()> { + let codex_home = tempdir()?; + let config_toml: ConfigToml = toml::from_str( + r#" +[features.rollout_budget] +enabled = true +limit_tokens = 100000 +reminder_interval_tokens = 10000 +sampling_token_weight = 1.0 +prefill_token_weight = 0.1 +"#, + ) + .expect("TOML deserialization should succeed"); + let config = Config::load_from_base_config_with_overrides( + config_toml, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert!(config.features.enabled(Feature::RolloutBudget)); + assert!(!config.features.enabled(Feature::TokenBudget)); + assert_eq!( + config.rollout_budget, + Some(RolloutBudgetConfig { + limit_tokens: 100_000, + reminder_interval_tokens: 10_000, + sampling_token_weight: 1.0, + prefill_token_weight: 0.1, + }) + ); + Ok(()) +} + +#[tokio::test] +async fn load_config_rejects_enabled_rollout_budget_without_limit() -> std::io::Result<()> { + for config_toml in [ + "[features]\nrollout_budget = true\n", + "[features.rollout_budget]\nenabled = true\n", + ] { + let codex_home = tempdir()?; + let config_toml: ConfigToml = + toml::from_str(config_toml).expect("TOML deserialization should succeed"); + let err = Config::load_from_base_config_with_overrides( + config_toml, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await + .expect_err("enabled rollout budget without limit_tokens should be rejected"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "features.rollout_budget.limit_tokens is required when rollout_budget is enabled" + ); + } + Ok(()) +} + #[test] fn rejects_provider_auth_with_env_key() { let err = toml::from_str::( diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index a54137b58c..0427613361 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1020,6 +1020,9 @@ pub struct Config { /// Settings specific to the task-path-based multi-agent tool surface. pub multi_agent_v2: MultiAgentV2Config, + /// Shared token budget for the root thread and its sub-agents. + pub rollout_budget: Option, + /// Centralized feature flags; source of truth for feature gating. pub features: ManagedFeatures, @@ -1064,6 +1067,14 @@ pub struct CodeModeConfig { pub direct_only_tool_namespaces: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Serialize)] +pub struct RolloutBudgetConfig { + pub limit_tokens: i64, + pub reminder_interval_tokens: i64, + pub sampling_token_weight: f64, + pub prefill_token_weight: f64, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct MultiAgentV2Config { pub max_concurrent_threads_per_session: usize, @@ -2469,6 +2480,65 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config } } +fn resolve_rollout_budget_config( + config_toml: &ConfigToml, + features: &ManagedFeatures, +) -> std::io::Result> { + if !features.enabled(Feature::RolloutBudget) { + return Ok(None); + } + let missing_limit_error = || { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "features.rollout_budget.limit_tokens is required when rollout_budget is enabled", + ) + }; + let Some(FeatureToml::Config(config)) = config_toml + .features + .as_ref() + .and_then(|features| features.rollout_budget.as_ref()) + else { + return Err(missing_limit_error()); + }; + let Some(limit_tokens) = config.limit_tokens else { + return Err(missing_limit_error()); + }; + if limit_tokens <= 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "features.rollout_budget.limit_tokens must be positive", + )); + } + let reminder_interval_tokens = config + .reminder_interval_tokens + .unwrap_or_else(|| (limit_tokens / 10).max(1)); + if reminder_interval_tokens <= 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "features.rollout_budget.reminder_interval_tokens must be positive", + )); + } + let sampling_token_weight = config.sampling_token_weight.unwrap_or(1.0); + let prefill_token_weight = config.prefill_token_weight.unwrap_or(1.0); + for (field, weight) in [ + ("sampling_token_weight", sampling_token_weight), + ("prefill_token_weight", prefill_token_weight), + ] { + if !weight.is_finite() || weight < 0.0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("features.rollout_budget.{field} must be finite and non-negative"), + )); + } + } + Ok(Some(RolloutBudgetConfig { + limit_tokens, + reminder_interval_tokens, + sampling_token_weight, + prefill_token_weight, + })) +} + fn resolve_terminal_resize_reflow_config(config_toml: &ConfigToml) -> TerminalResizeReflowConfig { let Some(tui) = config_toml.tui.as_ref() else { return TerminalResizeReflowConfig::default(); @@ -3146,6 +3216,7 @@ impl Config { resolve_experimental_request_user_input_enabled(&cfg); let code_mode = resolve_code_mode_config(&cfg); let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg); + let rollout_budget = resolve_rollout_budget_config(&cfg, &features)?; let terminal_resize_reflow = resolve_terminal_resize_reflow_config(&cfg); let agent_roles = @@ -3686,6 +3757,7 @@ impl Config { background_terminal_max_timeout, ghost_snapshot, multi_agent_v2, + rollout_budget, features, suppress_unstable_features_warning: cfg .suppress_unstable_features_warning diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index ec2056581c..b5f1689ad2 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -6,6 +6,7 @@ use codex_features::Feature; use codex_features::FeatureToml; use codex_features::FeaturesToml; use codex_features::MultiAgentV2ConfigToml; +use codex_features::RolloutBudgetConfigToml; use codex_protocol::ThreadId; use crate::config::Config; @@ -148,6 +149,12 @@ fn save_config_resolved_fields( resolved_config_to_toml(&config.multi_agent_v2, "features.multi_agent_v2")?; multi_agent_v2.enabled = Some(config.features.enabled(Feature::MultiAgentV2)); features.multi_agent_v2 = Some(FeatureToml::Config(multi_agent_v2)); + if let Some(rollout_budget) = config.rollout_budget.as_ref() { + let mut rollout_budget: RolloutBudgetConfigToml = + resolved_config_to_toml(rollout_budget, "features.rollout_budget")?; + rollout_budget.enabled = Some(config.features.enabled(Feature::RolloutBudget)); + features.rollout_budget = Some(FeatureToml::Config(rollout_budget)); + } lock_config.memories = Some(resolved_config_to_toml::( &config.memories, "memories", @@ -209,6 +216,18 @@ mod tests { #[tokio::test] async fn lock_contains_prompts_and_materializes_features() { let mut sc = crate::session::tests::make_session_configuration_for_tests().await; + let mut config = (*sc.original_config_do_not_use).clone(); + config.rollout_budget = Some(crate::config::RolloutBudgetConfig { + limit_tokens: 100_000, + reminder_interval_tokens: 10_000, + sampling_token_weight: 1.0, + prefill_token_weight: 0.25, + }); + config + .features + .enable(Feature::RolloutBudget) + .expect("rollout_budget should be enableable in tests"); + sc.original_config_do_not_use = Arc::new(config); sc.base_instructions = "resolved instructions".to_string(); sc.developer_instructions = Some("resolved developer instructions".to_string()); sc.compact_prompt = Some("resolved compact prompt".to_string()); @@ -273,6 +292,17 @@ mod tests { }) )); + assert_eq!( + features.rollout_budget, + Some(FeatureToml::Config(RolloutBudgetConfigToml { + enabled: Some(true), + limit_tokens: Some(100_000), + reminder_interval_tokens: Some(10_000), + sampling_token_weight: Some(1.0), + prefill_token_weight: Some(0.25), + })) + ); + assert_eq!(lockfile.version, crate::config_lock::CONFIG_LOCK_VERSION); } diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index 0acb9b7d3c..ad4b6da63c 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -73,6 +73,35 @@ impl FeatureConfig for MultiAgentV2ConfigToml { } } +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RolloutBudgetConfigToml { + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1))] + pub limit_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1))] + pub reminder_interval_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0.0))] + pub sampling_token_weight: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0.0))] + pub prefill_token_weight: Option, +} + +impl FeatureConfig for RolloutBudgetConfigToml { + fn enabled(&self) -> Option { + self.enabled + } + + fn set_enabled(&mut self, enabled: bool) { + self.enabled = Some(enabled); + } +} + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct RemovedAppsMcpPathOverrideConfigToml { diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index b887237a18..731ea20a10 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -23,6 +23,7 @@ pub use feature_configs::NetworkProxyDomainPermissionToml; pub use feature_configs::NetworkProxyModeToml; pub use feature_configs::NetworkProxyUnixSocketPermissionToml; use feature_configs::RemovedAppsMcpPathOverrideConfigToml; +pub use feature_configs::RolloutBudgetConfigToml; use legacy::LegacyFeatureToggles; pub use legacy::legacy_feature_keys; @@ -203,6 +204,8 @@ pub enum Feature { Goals, /// Add current context-window metadata to model-visible context. TokenBudget, + /// Track and report a shared token budget across a session's agent threads. + RolloutBudget, /// Expose an input-interruptible sleep tool. SleepTool, /// Route MCP tool approval prompts through the MCP elicitation request path. @@ -618,6 +621,8 @@ pub struct FeaturesToml { pub code_mode: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub multi_agent_v2: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rollout_budget: Option>, #[serde(default, rename = "apps_mcp_path_override", skip_serializing)] #[schemars(skip)] removed_apps_mcp_path_override: Option>, @@ -650,6 +655,9 @@ impl FeaturesToml { if let Some(enabled) = self.multi_agent_v2.as_ref().and_then(FeatureToml::enabled) { entries.insert(Feature::MultiAgentV2.key().to_string(), enabled); } + if let Some(enabled) = self.rollout_budget.as_ref().and_then(FeatureToml::enabled) { + entries.insert(Feature::RolloutBudget.key().to_string(), enabled); + } if let Some(enabled) = self.network_proxy.as_ref().and_then(FeatureToml::enabled) { entries.insert(Feature::NetworkProxy.key().to_string(), enabled); } @@ -661,6 +669,7 @@ impl FeaturesToml { let Self { code_mode, multi_agent_v2, + rollout_budget, removed_apps_mcp_path_override: _, network_proxy, entries, @@ -674,6 +683,8 @@ impl FeaturesToml { materialize_resolved_feature_enabled(code_mode, enabled); } else if spec.id == Feature::MultiAgentV2 { materialize_resolved_feature_enabled(multi_agent_v2, enabled); + } else if spec.id == Feature::RolloutBudget { + materialize_resolved_feature_enabled(rollout_budget, enabled); } else if spec.id == Feature::NetworkProxy { materialize_resolved_feature_enabled(network_proxy, enabled); } else { @@ -1167,6 +1178,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::RolloutBudget, + key: "rollout_budget", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::SleepTool, key: "sleep_tool", diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index e2d803ce76..ca11ff9072 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -277,6 +277,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R background_terminal_max_timeout: 300_000, ghost_snapshot: GhostSnapshotConfig::default(), multi_agent_v2: MultiAgentV2Config::default(), + rollout_budget: None, features: Default::default(), suppress_unstable_features_warning: false, active_project: ProjectConfig { trust_level: None }, From 0369b24d54fe43d9dbe5b8544c4c44f26277d5c0 Mon Sep 17 00:00:00 2001 From: jif Date: Thu, 18 Jun 2026 13:09:38 +0100 Subject: [PATCH 026/112] Add network environment ID plumbing (#28766) ## Why Prepare network approval scoping to distinguish execution environments without changing behavior yet. ## What changed - Add optional environment IDs to network policy requests. - Add optional network environment IDs to exec and sandbox request structs. - Thread default None values through existing construction points. - Fix stale constructor call sites that caused the CI compile failures. ## Not included - Per-environment proxy listeners. - Network approval cache or prompt behavior changes. - Ambiguous request attribution handling. Those behavior changes moved to stacked follow-up #28899. ## Validation - just fmt - CI will run tests and clippy --- codex-rs/app-server/src/command_exec.rs | 3 +++ .../src/request_processors/command_exec_processor.rs | 1 + codex-rs/core/src/exec.rs | 7 +++++++ codex-rs/core/src/exec_tests.rs | 7 +++++++ codex-rs/core/src/sandboxing/mod.rs | 5 +++++ codex-rs/core/src/tasks/user_shell.rs | 1 + codex-rs/core/src/tools/handlers/shell/shell_command.rs | 1 + .../core/src/tools/runtimes/shell/unix_escalation.rs | 3 +++ codex-rs/core/src/tools/sandboxing.rs | 1 + codex-rs/core/src/unified_exec/mod_tests.rs | 1 + codex-rs/core/src/unified_exec/process_manager_tests.rs | 1 + codex-rs/core/tests/suite/exec.rs | 1 + codex-rs/core/tests/suite/windows_sandbox.rs | 2 ++ codex-rs/exec-server/src/fs_sandbox.rs | 1 + codex-rs/exec/tests/suite/sandbox.rs | 1 + codex-rs/linux-sandbox/tests/suite/landlock.rs | 2 ++ codex-rs/network-proxy/src/http_proxy.rs | 2 ++ codex-rs/network-proxy/src/network_policy.rs | 9 +++++++++ codex-rs/network-proxy/src/socks5.rs | 2 ++ codex-rs/sandboxing/src/manager.rs | 4 ++++ codex-rs/sandboxing/src/manager_tests.rs | 5 +++++ 21 files changed, 60 insertions(+) diff --git a/codex-rs/app-server/src/command_exec.rs b/codex-rs/app-server/src/command_exec.rs index f66bd95794..d7f0b90d5c 100644 --- a/codex-rs/app-server/src/command_exec.rs +++ b/codex-rs/app-server/src/command_exec.rs @@ -704,6 +704,7 @@ mod tests { cwd.clone(), HashMap::new(), /*network*/ None, + /*network_environment_id*/ None, ExecExpiration::DefaultTimeout, codex_core::exec::ExecCapturePolicy::ShellTool, SandboxType::WindowsRestrictedToken, @@ -821,6 +822,7 @@ mod tests { cwd.clone(), HashMap::new(), /*network*/ None, + /*network_environment_id*/ None, ExecExpiration::Cancellation(CancellationToken::new()), codex_core::exec::ExecCapturePolicy::ShellTool, SandboxType::None, @@ -908,6 +910,7 @@ mod tests { cwd.clone(), HashMap::new(), /*network*/ None, + /*network_environment_id*/ None, ExecExpiration::TimeoutOrCancellation { timeout: Duration::from_secs(30), cancellation, diff --git a/codex-rs/app-server/src/request_processors/command_exec_processor.rs b/codex-rs/app-server/src/request_processors/command_exec_processor.rs index 5b14f0b0c0..dfe7555d58 100644 --- a/codex-rs/app-server/src/request_processors/command_exec_processor.rs +++ b/codex-rs/app-server/src/request_processors/command_exec_processor.rs @@ -297,6 +297,7 @@ impl CommandExecRequestProcessor { network: started_network_proxy .as_ref() .map(codex_core::config::StartedNetworkProxy::proxy), + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level, windows_sandbox_private_desktop: self diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 4983e0a806..0142f995f5 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -95,6 +95,7 @@ pub struct ExecParams { pub capture_policy: ExecCapturePolicy, pub env: HashMap, pub network: Option, + pub network_environment_id: Option, pub sandbox_permissions: SandboxPermissions, pub windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel, pub windows_sandbox_private_desktop: bool, @@ -321,6 +322,7 @@ pub fn build_exec_request( expiration, capture_policy, network, + network_environment_id, windows_sandbox_level, windows_sandbox_private_desktop, @@ -372,6 +374,7 @@ pub fn build_exec_request( permissions: permission_profile, sandbox: sandbox_type, enforce_managed_network, + environment_id: network_environment_id.as_deref(), network: network.as_ref(), sandbox_policy_cwd: &sandbox_policy_cwd_uri, codex_linux_sandbox_exe: codex_linux_sandbox_exe.as_deref(), @@ -437,6 +440,7 @@ pub(crate) async fn execute_exec_request( file_system_sandbox_policy: _, network_sandbox_policy, windows_sandbox_filesystem_overrides, + network_environment_id, arg0, } = exec_request; @@ -456,6 +460,7 @@ pub(crate) async fn execute_exec_request( capture_policy, env, network: network.clone(), + network_environment_id, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level, windows_sandbox_private_desktop, @@ -593,6 +598,7 @@ async fn exec_windows_sandbox( cwd, mut env, network, + network_environment_id: _, expiration, capture_policy, windows_sandbox_level, @@ -941,6 +947,7 @@ async fn exec( cwd, mut env, network, + network_environment_id: _, arg0, expiration, capture_policy, diff --git a/codex-rs/core/src/exec_tests.rs b/codex-rs/core/src/exec_tests.rs index 84dfaba5d7..1a4477599d 100644 --- a/codex-rs/core/src/exec_tests.rs +++ b/codex-rs/core/src/exec_tests.rs @@ -273,6 +273,7 @@ async fn exec_full_buffer_capture_ignores_expiration() -> Result<()> { capture_policy: ExecCapturePolicy::FullBuffer, env, network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, @@ -309,6 +310,7 @@ async fn exec_full_buffer_capture_keeps_io_drain_timeout_when_descendant_holds_p capture_policy: ExecCapturePolicy::FullBuffer, env: std::env::vars().collect(), network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, @@ -356,6 +358,7 @@ async fn process_exec_tool_call_preserves_full_buffer_capture_policy() -> Result capture_policy: ExecCapturePolicy::FullBuffer, env: std::env::vars().collect(), network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, @@ -998,6 +1001,7 @@ fn build_exec_request_preserves_windows_workspace_roots() -> Result<()> { capture_policy: ExecCapturePolicy::ShellTool, env: HashMap::new(), network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, @@ -1052,6 +1056,7 @@ async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()> capture_policy: ExecCapturePolicy::ShellTool, env, network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, @@ -1107,6 +1112,7 @@ async fn process_exec_tool_call_respects_cancellation_token() -> Result<()> { capture_policy: ExecCapturePolicy::ShellTool, env, network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, @@ -1190,6 +1196,7 @@ while :; do sleep 1; done"# capture_policy: ExecCapturePolicy::ShellTool, env, network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index fa069afd4a..c156b0d70e 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -47,6 +47,7 @@ pub struct ExecRequest { pub env: HashMap, pub(crate) exec_server_env_config: Option, pub network: Option, + pub network_environment_id: Option, pub expiration: ExecExpiration, pub capture_policy: ExecCapturePolicy, pub sandbox: SandboxType, @@ -68,6 +69,7 @@ impl ExecRequest { cwd: AbsolutePathBuf, env: HashMap, network: Option, + network_environment_id: Option, expiration: ExecExpiration, capture_policy: ExecCapturePolicy, sandbox: SandboxType, @@ -87,6 +89,7 @@ impl ExecRequest { env, exec_server_env_config: None, network, + network_environment_id, expiration, capture_policy, sandbox, @@ -113,6 +116,7 @@ impl ExecRequest { sandbox_policy_cwd: windows_sandbox_policy_cwd, mut env, network, + network_environment_id, sandbox, windows_sandbox_level, windows_sandbox_private_desktop, @@ -141,6 +145,7 @@ impl ExecRequest { env, exec_server_env_config: None, network, + network_environment_id, expiration, capture_policy, sandbox, diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index aea7500247..7cb9841db7 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -205,6 +205,7 @@ pub(crate) async fn execute_user_shell_command( // `/shell` is the explicit full-access escape hatch, so it must not // inherit a managed proxy from the surrounding session or turn. network: None, + network_environment_id: None, // TODO(zhao-oai): Now that we have ExecExpiration::Cancellation, we // should use that instead of an "arbitrarily large" timeout here. expiration: USER_SHELL_TIMEOUT_MS.into(), diff --git a/codex-rs/core/src/tools/handlers/shell/shell_command.rs b/codex-rs/core/src/tools/handlers/shell/shell_command.rs index f1c44f9d05..6176a07d62 100644 --- a/codex-rs/core/src/tools/handlers/shell/shell_command.rs +++ b/codex-rs/core/src/tools/handlers/shell/shell_command.rs @@ -105,6 +105,7 @@ impl ShellCommandHandler { Some(thread_id), ), network: turn_context.network.clone(), + network_environment_id: None, sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), windows_sandbox_level: turn_context.windows_sandbox_level, windows_sandbox_private_desktop: turn_context diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index b8e417f856..2acbf37781 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -151,6 +151,7 @@ pub(super) async fn try_run_zsh_fork( env: sandbox_env, exec_server_env_config: _, network: sandbox_network, + network_environment_id: _, expiration: _sandbox_expiration, capture_policy: _capture_policy, sandbox, @@ -879,6 +880,7 @@ impl CoreShellCommandExecutor { env: exec_env, exec_server_env_config: None, network: self.network.clone(), + network_environment_id: None, expiration: ExecExpiration::Cancellation(cancel_rx), capture_policy: ExecCapturePolicy::ShellTool, sandbox: self.sandbox, @@ -1010,6 +1012,7 @@ impl CoreShellCommandExecutor { permissions: permission_profile, sandbox, enforce_managed_network: self.network.is_some(), + environment_id: None, network: self.network.as_ref(), sandbox_policy_cwd: &sandbox_policy_cwd, codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.as_deref(), diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index cc7a7913dd..e80cbbba1a 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -434,6 +434,7 @@ impl<'a> SandboxAttempt<'a> { permissions: self.permissions, sandbox: self.sandbox, enforce_managed_network: self.enforce_managed_network, + environment_id: None, network, sandbox_policy_cwd: self.sandbox_cwd, codex_linux_sandbox_exe: self diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index 0d4e21d93c..8e1b03d118 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -77,6 +77,7 @@ fn test_exec_request( cwd, env, network, + /*network_environment_id*/ None, ExecExpiration::DefaultTimeout, ExecCapturePolicy::ShellTool, SandboxType::None, diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index c399b9c988..5abd926c62 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -98,6 +98,7 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { ]), }), network: None, + network_environment_id: None, expiration: crate::exec::ExecExpiration::DefaultTimeout, capture_policy: crate::exec::ExecCapturePolicy::ShellTool, sandbox: codex_sandboxing::SandboxType::None, diff --git a/codex-rs/core/tests/suite/exec.rs b/codex-rs/core/tests/suite/exec.rs index 84d7408b81..355711e345 100644 --- a/codex-rs/core/tests/suite/exec.rs +++ b/codex-rs/core/tests/suite/exec.rs @@ -41,6 +41,7 @@ where capture_policy: ExecCapturePolicy::ShellTool, env: HashMap::new(), network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, diff --git a/codex-rs/core/tests/suite/windows_sandbox.rs b/codex-rs/core/tests/suite/windows_sandbox.rs index 74d7f6a23b..b8895f49e4 100644 --- a/codex-rs/core/tests/suite/windows_sandbox.rs +++ b/codex-rs/core/tests/suite/windows_sandbox.rs @@ -173,6 +173,7 @@ async fn windows_restricted_token_rejects_exact_and_glob_deny_read_policy() -> a capture_policy: ExecCapturePolicy::ShellTool, env: HashMap::new(), network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::RestrictedToken, windows_sandbox_private_desktop: false, @@ -262,6 +263,7 @@ async fn windows_elevated_enforces_deny_read_and_protects_setup_marker() -> anyh capture_policy: ExecCapturePolicy::ShellTool, env: HashMap::new(), network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Elevated, windows_sandbox_private_desktop: false, diff --git a/codex-rs/exec-server/src/fs_sandbox.rs b/codex-rs/exec-server/src/fs_sandbox.rs index ea420c9ea2..3dbcf73c14 100644 --- a/codex-rs/exec-server/src/fs_sandbox.rs +++ b/codex-rs/exec-server/src/fs_sandbox.rs @@ -135,6 +135,7 @@ impl FileSystemSandboxRunner { permissions: permission_profile, sandbox, enforce_managed_network: false, + environment_id: None, network: None, sandbox_policy_cwd: &cwd.uri, codex_linux_sandbox_exe: self.runtime_paths.codex_linux_sandbox_exe.as_deref(), diff --git a/codex-rs/exec/tests/suite/sandbox.rs b/codex-rs/exec/tests/suite/sandbox.rs index 3171a445a1..03976a8546 100644 --- a/codex-rs/exec/tests/suite/sandbox.rs +++ b/codex-rs/exec/tests/suite/sandbox.rs @@ -36,6 +36,7 @@ async fn spawn_command_under_sandbox( capture_policy: ExecCapturePolicy::ShellTool, env, network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index 3e6d821110..5843e03115 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -175,6 +175,7 @@ async fn run_cmd_result_with_permission_profile_for_cwd( capture_policy: ExecCapturePolicy::ShellTool, env: create_env_from_core_vars(), network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, @@ -432,6 +433,7 @@ async fn assert_network_blocked(cmd: &[&str]) { capture_policy: ExecCapturePolicy::ShellTool, env: create_env_from_core_vars(), network: None, + network_environment_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, diff --git a/codex-rs/network-proxy/src/http_proxy.rs b/codex-rs/network-proxy/src/http_proxy.rs index f20c01b906..c07ff17606 100644 --- a/codex-rs/network-proxy/src/http_proxy.rs +++ b/codex-rs/network-proxy/src/http_proxy.rs @@ -200,6 +200,7 @@ async fn http_connect_accept( protocol: NetworkProtocol::HttpsConnect, host: host.clone(), port: authority.port, + environment_id: None, client_addr: client.clone(), method: Some("CONNECT".to_string()), command: None, @@ -683,6 +684,7 @@ async fn http_plain_proxy( protocol: NetworkProtocol::Http, host: host.clone(), port, + environment_id: None, client_addr: client.clone(), method: Some(req.method().as_str().to_string()), command: None, diff --git a/codex-rs/network-proxy/src/network_policy.rs b/codex-rs/network-proxy/src/network_policy.rs index 18bca2c916..e5792a07c0 100644 --- a/codex-rs/network-proxy/src/network_policy.rs +++ b/codex-rs/network-proxy/src/network_policy.rs @@ -79,6 +79,7 @@ pub struct NetworkPolicyRequest { pub protocol: NetworkProtocol, pub host: String, pub port: u16, + pub environment_id: Option, pub client_addr: Option, pub method: Option, pub command: Option, @@ -89,6 +90,7 @@ pub struct NetworkPolicyRequestArgs { pub protocol: NetworkProtocol, pub host: String, pub port: u16, + pub environment_id: Option, pub client_addr: Option, pub method: Option, pub command: Option, @@ -101,6 +103,7 @@ impl NetworkPolicyRequest { protocol, host, port, + environment_id, client_addr, method, command, @@ -110,6 +113,7 @@ impl NetworkPolicyRequest { protocol, host, port, + environment_id, client_addr, method, command, @@ -625,6 +629,7 @@ mod tests { protocol: NetworkProtocol::Http, host: "example.com".to_string(), port: 80, + environment_id: None, client_addr: None, method: None, command: None, @@ -686,6 +691,7 @@ mod tests { protocol: NetworkProtocol::Http, host: "blocked.com".to_string(), port: 80, + environment_id: None, client_addr: Some("127.0.0.1:1234".to_string()), method: Some("GET".to_string()), command: None, @@ -729,6 +735,7 @@ mod tests { protocol: NetworkProtocol::Http, host: "example.com".to_string(), port: 80, + environment_id: None, client_addr: None, method: Some("GET".to_string()), command: None, @@ -779,6 +786,7 @@ mod tests { protocol: NetworkProtocol::Http, host: "example.com".to_string(), port: 80, + environment_id: None, client_addr: None, method: Some("GET".to_string()), command: None, @@ -865,6 +873,7 @@ mod tests { protocol: NetworkProtocol::Http, host: "127.0.0.1".to_string(), port: 80, + environment_id: None, client_addr: None, method: Some("GET".to_string()), command: None, diff --git a/codex-rs/network-proxy/src/socks5.rs b/codex-rs/network-proxy/src/socks5.rs index 6e600d2025..a4f36db562 100644 --- a/codex-rs/network-proxy/src/socks5.rs +++ b/codex-rs/network-proxy/src/socks5.rs @@ -268,6 +268,7 @@ async fn handle_socks5_tcp( protocol: NetworkProtocol::Socks5Tcp, host: host.clone(), port, + environment_id: None, client_addr: client.clone(), method: None, command: None, @@ -624,6 +625,7 @@ async fn inspect_socks5_udp( protocol: NetworkProtocol::Socks5Udp, host: host.clone(), port, + environment_id: None, client_addr: client.clone(), method: None, command: None, diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index 25032a8050..f00e056907 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -113,6 +113,7 @@ pub struct SandboxExecRequest { pub sandbox_policy_cwd: PathUri, pub env: HashMap, pub network: Option, + pub network_environment_id: Option, pub sandbox: SandboxType, pub windows_sandbox_level: WindowsSandboxLevel, pub windows_sandbox_private_desktop: bool, @@ -130,6 +131,7 @@ pub struct SandboxTransformRequest<'a> { pub permissions: &'a PermissionProfile, pub sandbox: SandboxType, pub enforce_managed_network: bool, + pub environment_id: Option<&'a str>, // TODO(viyatb): Evaluate switching this to Option> // to make shared ownership explicit across runtime/sandbox plumbing. pub network: Option<&'a NetworkProxy>, @@ -305,6 +307,7 @@ impl SandboxManager { permissions, sandbox, enforce_managed_network, + environment_id, network, sandbox_policy_cwd, codex_linux_sandbox_exe, @@ -422,6 +425,7 @@ impl SandboxManager { sandbox_policy_cwd: sandbox_policy_cwd.clone(), env: command.env, network: network.cloned(), + network_environment_id: environment_id.map(str::to_string), sandbox, windows_sandbox_level, windows_sandbox_private_desktop, diff --git a/codex-rs/sandboxing/src/manager_tests.rs b/codex-rs/sandboxing/src/manager_tests.rs index 344787f141..64fc87b6e4 100644 --- a/codex-rs/sandboxing/src/manager_tests.rs +++ b/codex-rs/sandboxing/src/manager_tests.rs @@ -97,6 +97,7 @@ fn unsandboxed_transform_preserves_foreign_cwd_and_unrestricted_file_system_poli permissions: &permissions, sandbox: SandboxType::None, enforce_managed_network: false, + environment_id: None, network: None, sandbox_policy_cwd: &cwd_uri, codex_linux_sandbox_exe: None, @@ -151,6 +152,7 @@ fn transform_additional_permissions_enable_network_for_external_sandbox() { permissions: &permissions, sandbox: SandboxType::None, enforce_managed_network: false, + environment_id: None, network: None, sandbox_policy_cwd: &cwd_uri, codex_linux_sandbox_exe: None, @@ -220,6 +222,7 @@ fn transform_additional_permissions_preserves_denied_entries() { permissions: &permissions, sandbox: SandboxType::None, enforce_managed_network: false, + environment_id: None, network: None, sandbox_policy_cwd: &cwd_uri, codex_linux_sandbox_exe: None, @@ -316,6 +319,7 @@ fn transform_linux_seccomp_request( permissions: &permissions, sandbox: SandboxType::LinuxSeccomp, enforce_managed_network: false, + environment_id: None, network: None, sandbox_policy_cwd: &cwd_uri, codex_linux_sandbox_exe: Some(codex_linux_sandbox_exe), @@ -505,6 +509,7 @@ fn transform_for_direct_spawn_windows_materializes_inner_helper() { permissions: &permissions, sandbox: SandboxType::WindowsRestrictedToken, enforce_managed_network: false, + environment_id: None, network: None, sandbox_policy_cwd: &cwd_uri, codex_linux_sandbox_exe: None, From 56703600091d25542b60597b85d0e027799ad063 Mon Sep 17 00:00:00 2001 From: jif Date: Thu, 18 Jun 2026 14:13:55 +0100 Subject: [PATCH 027/112] Avoid sandbox helper in apply_patch approval tests (#28915) ## Summary This keeps the apply_patch approval tests focused on approval behavior instead of macOS sandboxed filesystem helper startup. The changed cases still force patch approval with `UnlessTrusted`, but use `DangerFullAccess` after approval so the patch write is direct and cheap. Workspace-write and sandbox-helper behavior remain covered by the filesystem and apply_patch sandbox tests. --- codex-rs/core/tests/suite/approvals.rs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 422fd28260..f26b8e4b85 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -1380,7 +1380,7 @@ fn scenarios() -> Vec { ScenarioSpec { name: "apply_patch_shell_command_requires_patch_approval", approval_policy: UnlessTrusted, - sandbox_policy: workspace_write(false), + sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::ApplyPatchShell { target: TargetPath::Workspace("apply_patch_shell.txt"), content: "shell-apply-patch", @@ -1494,7 +1494,7 @@ fn scenarios() -> Vec { ScenarioSpec { name: "apply_patch_freeform_unless_trusted_requires_patch_approval", approval_policy: UnlessTrusted, - sandbox_policy: workspace_write(false), + sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::ApplyPatchFreeform { target: TargetPath::Workspace("apply_patch_freeform_unless_trusted.txt"), content: "freeform-patch-unless-trusted", @@ -2060,13 +2060,8 @@ async fn approving_apply_patch_for_session_skips_future_prompts_for_same_file() skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; + let approval_policy = AskForApproval::UnlessTrusted; + let sandbox_policy = SandboxPolicy::DangerFullAccess; let sandbox_policy_for_config = sandbox_policy.clone(); let mut builder = test_codex() From 07298a948cbac94c7b0b505e91279fc69786f78c Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 18 Jun 2026 08:48:03 -0700 Subject: [PATCH 028/112] Pause active goals before TUI interrupts (#28813) Fixes #28104. ## Summary Active `/goal` turns should leave the persisted goal paused whenever the TUI interrupts the running turn. The bug in #28104 showed this most visibly through `Esc`: some interrupt paths aborted the turn without updating the goal status, so the goal could remain active and continue automatically. This change makes `ChatWidget` pause an active goal before the TUI sends an interrupt from the status-row path, the pending-steer path, `Ctrl+C`, or a request-user-input overlay. The modal overlay now reports whether a key will interrupt the turn, which keeps modal `Esc` and `Ctrl+C` behavior aligned with the normal interrupt paths. ## Manual Testing Built the local CLI with `just codex --help`, then launched the local TUI with goals enabled. Started an active `/goal` turn and interrupted it with `Esc`, then resumed and repeated with `Ctrl+C`; both paths showed `Goal paused`, the interrupted-conversation message, and the `Goal paused (/goal resume)` footer. I also stopped the background terminal and exited the TUI cleanly after the run. I did not find a reliable standalone manual path to force the request-user-input overlay case, so that path is covered by the focused automated test. --- .../tui/src/bottom_pane/bottom_pane_view.rs | 5 ++ codex-rs/tui/src/bottom_pane/mod.rs | 36 +++++--- .../src/bottom_pane/request_user_input/mod.rs | 15 ++++ codex-rs/tui/src/chatwidget/interaction.rs | 34 ++++++-- ...sts__esc_interrupt_goal_paused_footer.snap | 11 +++ ..._interrupt_goal_paused_footer@windows.snap | 11 +++ .../src/chatwidget/tests/status_and_layout.rs | 84 ++++++++++++++++--- 7 files changed, 169 insertions(+), 27 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer@windows.snap diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index a246c05e57..551bacd25a 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -67,6 +67,11 @@ pub(crate) trait BottomPaneView: Renderable { false } + /// Return true when this key event will interrupt the active agent turn. + fn will_interrupt_turn_on_key_event(&self, _key_event: KeyEvent) -> bool { + false + } + /// Optional paste handler. Return true if the view modified its state and /// needs a redraw. fn handle_paste(&mut self, _pasted: String) -> bool { diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 951c310d1f..7fffdeb98e 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -616,21 +616,10 @@ impl BottomPane { self.request_redraw(); InputResult::None } else { - let is_agent_command = self - .composer_text() - .lines() - .next() - .and_then(parse_slash_name) - .is_some_and(|(name, _, _)| name == "agent"); - // If a task is running and a status line is visible, allow the // configured action to interrupt even while the composer has focus. // When a popup is active, prefer dismissing it over interrupting the task. - if self.keymap.chat.interrupt_turn.is_pressed(key_event) - && self.is_task_running - && !(is_agent_command && key_event.code == KeyCode::Esc) - && !self.composer.popup_active() - && !self.composer_should_handle_vim_insert_escape(key_event) + if self.should_interrupt_running_task(key_event) && let Some(status) = &self.status { // Send Op::Interrupt @@ -1306,6 +1295,22 @@ impl BottomPane { self.is_task_running } + pub(crate) fn should_interrupt_running_task(&self, key_event: KeyEvent) -> bool { + let is_agent_command = self + .composer_text() + .lines() + .next() + .and_then(parse_slash_name) + .is_some_and(|(name, _, _)| name == "agent"); + + self.keymap.chat.interrupt_turn.is_pressed(key_event) + && self.is_task_running + && !(is_agent_command && key_event.code == KeyCode::Esc) + && self.no_modal_or_popup_active() + && !self.composer_should_handle_vim_insert_escape(key_event) + && self.status.is_some() + } + pub(crate) fn terminal_title_requires_action(&self) -> bool { self.active_view() .is_some_and(bottom_pane_view::BottomPaneView::terminal_title_requires_action) @@ -1315,6 +1320,13 @@ impl BottomPane { !self.view_stack.is_empty() } + pub(crate) fn active_view_will_interrupt_turn_on_key_event(&self, key_event: KeyEvent) -> bool { + self.is_task_running + && self + .active_view() + .is_some_and(|view| view.will_interrupt_turn_on_key_event(key_event)) + } + #[cfg(test)] pub(crate) fn active_view_id(&self) -> Option<&'static str> { self.view_stack.last().and_then(|view| view.view_id()) diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index acbf615684..bf1fa5fa8a 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -1183,6 +1183,21 @@ impl BottomPaneView for RequestUserInputOverlay { true } + fn will_interrupt_turn_on_key_event(&self, key_event: KeyEvent) -> bool { + if KeyBinding::new(KeyCode::Char('c'), KeyModifiers::CONTROL).is_press(key_event) { + return self.confirm_unanswered_active() + || !self.focus_is_notes() + || self.composer.current_text_with_pending().is_empty(); + } + + key_event.kind != KeyEventKind::Release + && !self.confirm_unanswered_active() + && !(matches!(key_event.code, KeyCode::Esc) + && self.has_options() + && self.notes_ui_visible()) + && self.interrupt_turn_keys.is_pressed(key_event) + } + fn handle_key_event(&mut self, key_event: KeyEvent) { if key_event.kind == KeyEventKind::Release { return; diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 25e93088da..74fc3d5e52 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -17,7 +17,13 @@ impl ChatWidget { && !key_hint::ctrl(KeyCode::Char('r')).is_press(key_event) && !key_hint::ctrl(KeyCode::Char('u')).is_press(key_event) { + let should_pause_active_goal = self + .bottom_pane + .active_view_will_interrupt_turn_on_key_event(key_event); self.bottom_pane.handle_key_event(key_event); + if should_pause_active_goal { + self.pause_active_goal_for_interrupt(); + } if self.bottom_pane.no_modal_or_popup_active() { self.maybe_send_next_queued_input(); } @@ -133,7 +139,9 @@ impl ChatWidget { && !self.should_handle_vim_insert_escape(key_event) { self.input_queue.submit_pending_steers_after_interrupt = true; - if !self.submit_op(AppCommand::interrupt()) { + if self.submit_op(AppCommand::interrupt()) { + self.pause_active_goal_for_interrupt(); + } else { self.input_queue.submit_pending_steers_after_interrupt = false; } return; @@ -165,7 +173,12 @@ impl ChatWidget { } _ => { let had_modal_or_popup = !self.bottom_pane.no_modal_or_popup_active(); + let should_pause_active_goal = + self.bottom_pane.should_interrupt_running_task(key_event); let input_result = self.bottom_pane.handle_key_event(key_event); + if should_pause_active_goal { + self.pause_active_goal_for_interrupt(); + } self.handle_composer_input_result(input_result, had_modal_or_popup); } } @@ -360,6 +373,12 @@ impl ChatWidget { fn on_ctrl_c(&mut self) { let key = key_hint::ctrl(KeyCode::Char('c')); let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active(); + let should_pause_active_goal = self + .bottom_pane + .active_view_will_interrupt_turn_on_key_event(KeyEvent::new( + KeyCode::Char('c'), + KeyModifiers::CONTROL, + )); if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled { if DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { if modal_or_popup_active { @@ -370,6 +389,9 @@ impl ChatWidget { self.arm_quit_shortcut(key); } } + if should_pause_active_goal { + self.pause_active_goal_for_interrupt(); + } return; } @@ -378,8 +400,9 @@ impl ChatWidget { self.quit_shortcut_expires_at = None; self.quit_shortcut_key = None; self.bottom_pane.clear_quit_shortcut_hint(); - self.pause_active_goal_for_interrupt(); - self.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()); + if self.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()) { + self.pause_active_goal_for_interrupt(); + } } else { self.request_quit_without_confirmation(); } @@ -395,9 +418,10 @@ impl ChatWidget { self.arm_quit_shortcut(key); - if self.is_cancellable_work_active() { + if self.is_cancellable_work_active() + && self.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()) + { self.pause_active_goal_for_interrupt(); - self.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()); } } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer.snap new file mode 100644 index 0000000000..bebbb3ffd5 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: snapshot +--- +" " +"• Working (0s • esc to interrupt) " +" " +" " +"› Ask Codex to do anything " +" " +" gpt-5.5 default · /tmp/project Goal paused (/goal resume) " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer@windows.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer@windows.snap new file mode 100644 index 0000000000..1025ae3862 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__esc_interrupt_goal_paused_footer@windows.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: snapshot +--- +" " +"• Working (0s • esc to interrupt) " +" " +" " +"› Ask Codex to do anything " +" " +" gpt-5.5 default · /tmp/project Goal paused (/goal resume) " diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d8c79f56b1..583377bace 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1410,33 +1410,97 @@ async fn streaming_final_answer_keeps_task_running_state() { #[tokio::test] async fn ctrl_c_interrupt_pauses_active_goal_turn() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = start_active_goal_turn(&mut chat); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + + next_interrupt_op(&mut op_rx); + assert_goal_paused_event(&mut rx, thread_id); +} + +#[tokio::test] +async fn esc_interrupt_pauses_active_goal_turn() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.show_welcome_banner = false; + let thread_id = start_active_goal_turn(&mut chat); + + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt { .. }))); + assert_goal_paused_event(&mut rx, thread_id); + + update_thread_goal(&mut chat, thread_id, AppThreadGoalStatus::Paused); + let width = 80; + let height = chat.desired_height(width); + let mut terminal = ratatui::Terminal::new(TestBackend::new(width, height)).expect("terminal"); + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw goal paused footer"); + let snapshot = normalized_backend_snapshot(terminal.backend()); + #[cfg(target_os = "windows")] + insta::with_settings!({ snapshot_suffix => "windows" }, { + assert_chatwidget_snapshot!("esc_interrupt_goal_paused_footer", snapshot); + }); + #[cfg(not(target_os = "windows"))] + assert_chatwidget_snapshot!("esc_interrupt_goal_paused_footer", snapshot); +} + +#[tokio::test] +async fn request_user_input_interrupt_pauses_active_goal_turn() { + for key_event in [ + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ] { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = start_active_goal_turn(&mut chat); + chat.handle_request_user_input_now(ToolRequestUserInputParams { + thread_id: thread_id.to_string(), + item_id: "call-1".to_string(), + turn_id: "turn-1".to_string(), + questions: Vec::new(), + auto_resolution_ms: None, + }); + + chat.handle_key_event(key_event); + + assert_matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt { .. }))); + assert_goal_paused_event(&mut rx, thread_id); + } +} + +fn start_active_goal_turn(chat: &mut ChatWidget) -> ThreadId { let thread_id = ThreadId::new(); chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); chat.thread_id = Some(thread_id); + update_thread_goal(chat, thread_id, AppThreadGoalStatus::Active); + chat.on_task_started(); + thread_id +} + +fn update_thread_goal(chat: &mut ChatWidget, thread_id: ThreadId, status: AppThreadGoalStatus) { let mut goal = test_thread_goal( - codex_app_server_protocol::ThreadGoalStatus::Active, + status, /*token_budget*/ Some(50_000), /*tokens_used*/ 40_000, ); - goal.thread_id = thread_id.to_string(); + let thread_id = thread_id.to_string(); + goal.thread_id = thread_id.clone(); chat.handle_server_notification( ServerNotification::ThreadGoalUpdated( codex_app_server_protocol::ThreadGoalUpdatedNotification { - thread_id: thread_id.to_string(), + thread_id, turn_id: None, goal, }, ), /*replay_kind*/ None, ); - chat.on_task_started(); +} - chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); - - match op_rx.try_recv() { - Ok(Op::Interrupt { .. }) => {} - other => panic!("expected Op::Interrupt, got {other:?}"), - } +fn assert_goal_paused_event( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, + thread_id: ThreadId, +) { assert_matches!( rx.try_recv(), Ok(AppEvent::SetThreadGoalStatus { From 83e6a786a20403fd35991c8979523013595e71da Mon Sep 17 00:00:00 2001 From: jif Date: Thu, 18 Jun 2026 18:04:26 +0100 Subject: [PATCH 029/112] Recover exec process stdin writes (#28895) ## Summary Remote stdio MCP servers send tool calls by writing JSON-RPC bytes through `process/write`. When the exec-server websocket drops at the wrong time, the remote process can survive session recovery, but the stdin write can still fail back to RMCP as a transport send error. RMCP then closes the stdio MCP transport, so tools like `node_repl` are lost even though the process/session recovery path is working. This changes `process/write` to be safe to retry across exec-server recovery: - adds a required `writeId` to `process/write` - retries remote `Session::write` with the same `writeId` after reconnect - remembers accepted write ids per process so duplicate retries return `Accepted` without writing the same bytes to child stdin again - covers both the client retry path and server-side write id dedupe with tests In simple terms: ```text before: write to MCP stdin -> websocket closes -> write errors -> RMCP closes node_repl after: write to MCP stdin -> websocket closes -> reconnect -> retry same writeId server either writes once or recognizes it already did ``` --- codex-rs/exec-server/src/client.rs | 148 +++++++++++++++++++++- codex-rs/exec-server/src/local_process.rs | 81 +++++++++++- codex-rs/exec-server/src/protocol.rs | 1 + codex-rs/exec-server/tests/process.rs | 137 +++++++++++++++++++- 4 files changed, 361 insertions(+), 6 deletions(-) diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index ae77dc7c4f..325fb74420 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -155,6 +155,7 @@ pub(crate) struct SessionState { events: ExecProcessEventLog, ordered_events: StdMutex, recoverable: AtomicBool, + next_write_id: AtomicU64, } #[derive(Default)] @@ -421,12 +422,14 @@ impl ExecServerClient { &self, process_id: &ProcessId, chunk: Vec, + write_id: String, ) -> Result { self.call( EXEC_WRITE_METHOD, &WriteParams { process_id: process_id.clone(), chunk: chunk.into(), + write_id, }, ) .await @@ -730,6 +733,7 @@ impl SessionState { ), ordered_events: StdMutex::new(OrderedSessionEvents::default()), recoverable: AtomicBool::new(recoverable), + next_write_id: AtomicU64::new(1), } } @@ -829,6 +833,12 @@ impl SessionState { failure: Some(message), } } + + fn next_write_id(&self) -> String { + self.next_write_id + .fetch_add(1, Ordering::Relaxed) + .to_string() + } } impl Session { @@ -885,7 +895,22 @@ impl Session { } pub(crate) async fn write(&self, chunk: Vec) -> Result { - self.client.write(&self.process_id, chunk).await + let write_id = self.state.next_write_id(); + loop { + match self + .client + .write(&self.process_id, chunk.clone(), write_id.clone()) + .await + { + Ok(response) => return Ok(response), + Err(error) + if is_transport_closed_error(&error) && !self.client.inner.is_failed() => + { + continue; + } + Err(error) => return Err(error), + } + } } pub(crate) async fn signal(&self, signal: ProcessSignal) -> Result<(), ExecServerError> { @@ -1110,6 +1135,8 @@ mod tests { use crate::protocol::EXEC_CLOSED_METHOD; use crate::protocol::EXEC_EXITED_METHOD; use crate::protocol::EXEC_OUTPUT_DELTA_METHOD; + use crate::protocol::EXEC_READ_METHOD; + use crate::protocol::EXEC_WRITE_METHOD; use crate::protocol::ExecClosedNotification; use crate::protocol::ExecExitedNotification; use crate::protocol::ExecOutputDeltaNotification; @@ -1118,6 +1145,10 @@ mod tests { use crate::protocol::INITIALIZED_METHOD; use crate::protocol::InitializeResponse; use crate::protocol::ProcessOutputChunk; + use crate::protocol::ReadResponse; + use crate::protocol::WriteParams; + use crate::protocol::WriteResponse; + use crate::protocol::WriteStatus; async fn read_jsonrpc_line(lines: &mut tokio::io::Lines>) -> JSONRPCMessage where @@ -1685,6 +1716,121 @@ mod tests { server.await.expect("server task should finish"); } + #[tokio::test] + async fn session_write_retries_same_write_id_after_recovery() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let websocket_url = format!( + "ws://{}", + listener.local_addr().expect("listener should have address") + ); + let (finish_tx, finish_rx) = oneshot::channel(); + let server = tokio::spawn(async move { + let mut first = accept_websocket(&listener).await; + complete_websocket_initialize( + &mut first, + "session-1", + /*expected_resume_session_id*/ None, + ) + .await; + + let first_write = read_jsonrpc_websocket(&mut first).await; + let first_write = match first_write { + JSONRPCMessage::Request(request) if request.method == EXEC_WRITE_METHOD => request, + other => panic!("expected first process/write request, got {other:?}"), + }; + let first_write_params: WriteParams = + serde_json::from_value(first_write.params.expect("write params should exist")) + .expect("write params should deserialize"); + assert_eq!(first_write_params.process_id.as_str(), "proc-write"); + assert_eq!(first_write_params.chunk.into_inner(), b"hello\n".to_vec()); + let write_id = first_write_params.write_id; + assert!(!write_id.is_empty()); + drop(first); + + let mut resumed = accept_websocket(&listener).await; + complete_websocket_initialize( + &mut resumed, + "session-1", + /*expected_resume_session_id*/ Some("session-1"), + ) + .await; + + let recovery_read = read_jsonrpc_websocket(&mut resumed).await; + let recovery_read = match recovery_read { + JSONRPCMessage::Request(request) if request.method == EXEC_READ_METHOD => request, + other => panic!("expected recovery process/read request, got {other:?}"), + }; + write_jsonrpc_websocket( + &mut resumed, + JSONRPCMessage::Response(JSONRPCResponse { + id: recovery_read.id, + result: serde_json::to_value(ReadResponse { + chunks: Vec::new(), + next_seq: 1, + exited: false, + exit_code: None, + closed: false, + failure: None, + }) + .expect("read response should serialize"), + }), + ) + .await; + + let retried_write = read_jsonrpc_websocket(&mut resumed).await; + let retried_write = match retried_write { + JSONRPCMessage::Request(request) if request.method == EXEC_WRITE_METHOD => request, + other => panic!("expected retried process/write request, got {other:?}"), + }; + let retried_write_params: WriteParams = + serde_json::from_value(retried_write.params.expect("write params should exist")) + .expect("write params should deserialize"); + assert_eq!(retried_write_params.process_id.as_str(), "proc-write"); + assert_eq!(retried_write_params.chunk.into_inner(), b"hello\n".to_vec()); + assert_eq!(retried_write_params.write_id, write_id); + write_jsonrpc_websocket( + &mut resumed, + JSONRPCMessage::Response(JSONRPCResponse { + id: retried_write.id, + result: serde_json::to_value(WriteResponse { + status: WriteStatus::Accepted, + }) + .expect("write response should serialize"), + }), + ) + .await; + + finish_rx.await.expect("test should finish"); + }); + + let client = LazyRemoteExecServerClient::new(ExecServerTransportParams::WebSocketUrl { + websocket_url, + connect_timeout: Duration::from_secs(1), + initialize_timeout: Duration::from_secs(1), + }); + let stable_client = client.get().await.expect("client should connect"); + let session = stable_client + .register_session(&ProcessId::from("proc-write")) + .await + .expect("session should register"); + + let response = timeout(Duration::from_secs(2), session.write(b"hello\n".to_vec())) + .await + .expect("write should not time out") + .expect("write should recover"); + assert_eq!( + response, + WriteResponse { + status: WriteStatus::Accepted + } + ); + + finish_tx.send(()).expect("test should finish"); + server.await.expect("server task should finish"); + } + #[tokio::test] async fn explicit_resume_drains_notifications_before_initialize_response() { let listener = TcpListener::bind("127.0.0.1:0") diff --git a/codex-rs/exec-server/src/local_process.rs b/codex-rs/exec-server/src/local_process.rs index d8ee65160a..5e1b52b11c 100644 --- a/codex-rs/exec-server/src/local_process.rs +++ b/codex-rs/exec-server/src/local_process.rs @@ -1,7 +1,10 @@ use std::collections::HashMap; +use std::collections::HashSet; use std::collections::VecDeque; use std::collections::hash_map::Entry; use std::sync::Arc; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; use std::time::Duration; use codex_app_server_protocol::JSONRPCErrorError; @@ -54,6 +57,8 @@ use crate::rpc::invalid_request; const RETAINED_OUTPUT_BYTES_PER_PROCESS: usize = 1024 * 1024; const NOTIFICATION_CHANNEL_CAPACITY: usize = 256; const PROCESS_EVENT_CHANNEL_CAPACITY: usize = 256; +const RETAINED_STDIN_WRITE_IDS_PER_PROCESS: usize = 4096; +static NEXT_LOCAL_STDIN_WRITE_ID: AtomicU64 = AtomicU64::new(1); #[cfg(test)] const EXITED_PROCESS_RETENTION: Duration = Duration::from_millis(25); #[cfg(not(test))] @@ -70,6 +75,7 @@ struct RunningProcess { session: ExecCommandSession, tty: bool, pipe_stdin: bool, + accepted_stdin_write_ids: Arc>, output: VecDeque, retained_bytes: usize, next_seq: u64, @@ -81,6 +87,37 @@ struct RunningProcess { closed: bool, } +/// Bounded cache of stdin write ids that have already been accepted for one process. +/// +/// A remote client can retry `process/write` after reconnecting. Remembering accepted +/// ids lets the server acknowledge the retried request without writing the same bytes +/// to child stdin twice. +#[derive(Default)] +struct AcceptedStdinWriteIds { + ids: HashSet, + order: VecDeque, +} + +impl AcceptedStdinWriteIds { + fn contains(&self, write_id: &str) -> bool { + self.ids.contains(write_id) + } + + fn remember(&mut self, write_id: String) { + if !self.ids.insert(write_id.clone()) { + return; + } + + self.order.push_back(write_id); + while self.order.len() > RETAINED_STDIN_WRITE_IDS_PER_PROCESS { + let Some(evicted) = self.order.pop_front() else { + break; + }; + self.ids.remove(&evicted); + } + } +} + struct ProcessStart; enum ProcessEntry { @@ -247,6 +284,9 @@ impl LocalProcess { session: spawned.session, tty: params.tty, pipe_stdin: params.pipe_stdin, + accepted_stdin_write_ids: Arc::new( + Mutex::new(AcceptedStdinWriteIds::default()), + ), output: VecDeque::new(), retained_bytes: 0, next_seq: 1, @@ -383,7 +423,11 @@ impl LocalProcess { params: WriteParams, ) -> Result { let _input_bytes = params.chunk.0.len(); - let writer_tx = { + if params.write_id.is_empty() { + return Err(invalid_params("writeId must not be empty".to_string())); + } + + let (writer_tx, accepted_stdin_write_ids) = { let process_map = self.inner.processes.lock().await; let Some(process) = process_map.get(¶ms.process_id) else { return Ok(WriteResponse { @@ -400,13 +444,37 @@ impl LocalProcess { status: WriteStatus::StdinClosed, }); } - process.session.writer_sender() + ( + process.session.writer_sender(), + Arc::clone(&process.accepted_stdin_write_ids), + ) }; - writer_tx - .send(params.chunk.into_inner()) + if accepted_stdin_write_ids + .lock() + .await + .contains(¶ms.write_id) + { + return Ok(WriteResponse { + status: WriteStatus::Accepted, + }); + } + + let permit = writer_tx + .reserve() .await .map_err(|_| internal_error("failed to write to process stdin".to_string()))?; + let mut accepted_stdin_write_ids = accepted_stdin_write_ids.lock().await; + if accepted_stdin_write_ids.contains(¶ms.write_id) { + return Ok(WriteResponse { + status: WriteStatus::Accepted, + }); + } + + // After this synchronous send, record the write id before any further await. + // Otherwise a cancelled RPC handler could retry and write the same bytes again. + permit.send(params.chunk.into_inner()); + accepted_stdin_write_ids.remember(params.write_id); Ok(WriteResponse { status: WriteStatus::Accepted, @@ -601,6 +669,10 @@ impl LocalProcess { self.exec_write(WriteParams { process_id: process_id.clone(), chunk: chunk.into(), + write_id: format!( + "local-{}", + NEXT_LOCAL_STDIN_WRITE_ID.fetch_add(1, Ordering::Relaxed) + ), }) .await .map_err(map_handler_error) @@ -1023,6 +1095,7 @@ mod tests { session: dummy_session(), tty: false, pipe_stdin: false, + accepted_stdin_write_ids: Arc::new(Mutex::new(AcceptedStdinWriteIds::default())), output: VecDeque::new(), retained_bytes: 0, next_seq: 1, diff --git a/codex-rs/exec-server/src/protocol.rs b/codex-rs/exec-server/src/protocol.rs index 32271ed207..97ef86aad2 100644 --- a/codex-rs/exec-server/src/protocol.rs +++ b/codex-rs/exec-server/src/protocol.rs @@ -154,6 +154,7 @@ pub struct ReadResponse { pub struct WriteParams { pub process_id: ProcessId, pub chunk: ByteChunk, + pub write_id: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/codex-rs/exec-server/tests/process.rs b/codex-rs/exec-server/tests/process.rs index 334e449224..571fd427fa 100644 --- a/codex-rs/exec-server/tests/process.rs +++ b/codex-rs/exec-server/tests/process.rs @@ -144,7 +144,8 @@ async fn exec_server_defaults_omitted_pipe_stdin_to_closed_stdin() -> anyhow::Re "process/write", serde_json::json!({ "processId": "proc-default-stdin", - "chunk": "aWdub3JlZAo=" + "chunk": "aWdub3JlZAo=", + "writeId": "write-default-stdin" }), ) .await?; @@ -171,6 +172,140 @@ async fn exec_server_defaults_omitted_pipe_stdin_to_closed_stdin() -> anyhow::Re Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_server_dedupes_retried_process_write_ids() -> anyhow::Result<()> { + let mut server = exec_server().await?; + let initialize_id = server + .send_request( + "initialize", + serde_json::to_value(InitializeParams { + client_name: "exec-server-test".to_string(), + resume_session_id: None, + })?, + ) + .await?; + let _ = server + .wait_for_event(|event| { + matches!( + event, + JSONRPCMessage::Response(JSONRPCResponse { id, .. }) if id == &initialize_id + ) + }) + .await?; + + server + .send_notification("initialized", serde_json::json!({})) + .await?; + + let process_start_id = server + .send_request( + "process/start", + serde_json::json!({ + "processId": "proc-write-id", + "argv": [ + "/bin/sh", + "-c", + "IFS= read -r first; printf 'line:%s\\n' \"$first\"; IFS= read -r second; printf 'line:%s\\n' \"$second\"" + ], + "cwd": std::env::current_dir()?, + "env": {}, + "tty": false, + "pipeStdin": true, + "arg0": null + }), + ) + .await?; + let _ = server + .wait_for_event(|event| { + matches!( + event, + JSONRPCMessage::Response(JSONRPCResponse { id, .. }) if id == &process_start_id + ) + }) + .await?; + + for (write_id, chunk) in [ + ("write-1", "Zmlyc3QK"), + ("write-1", "Zmlyc3QK"), + ("write-2", "c2Vjb25kCg=="), + ] { + let request_id = server + .send_request( + "process/write", + serde_json::json!({ + "processId": "proc-write-id", + "chunk": chunk, + "writeId": write_id + }), + ) + .await?; + let response = server + .wait_for_event(|event| { + matches!( + event, + JSONRPCMessage::Response(JSONRPCResponse { id, .. }) if id == &request_id + ) + }) + .await?; + let JSONRPCMessage::Response(JSONRPCResponse { result, .. }) = response else { + panic!("expected process/write response"); + }; + let write_response: WriteResponse = serde_json::from_value(result)?; + assert_eq!( + write_response, + WriteResponse { + status: WriteStatus::Accepted + } + ); + } + + let mut after_seq = None; + let mut output = Vec::new(); + for _ in 0..5 { + let read_id = server + .send_request( + "process/read", + serde_json::json!({ + "processId": "proc-write-id", + "afterSeq": after_seq, + "maxBytes": null, + "waitMs": 1000 + }), + ) + .await?; + let response = server + .wait_for_event(|event| { + matches!( + event, + JSONRPCMessage::Response(JSONRPCResponse { id, .. }) if id == &read_id + ) + }) + .await?; + let JSONRPCMessage::Response(JSONRPCResponse { result, .. }) = response else { + panic!("expected process/read response"); + }; + let read_response: ReadResponse = serde_json::from_value(result)?; + output.extend( + read_response + .chunks + .into_iter() + .flat_map(|chunk| chunk.chunk.into_inner()), + ); + after_seq = Some(read_response.next_seq.saturating_sub(1)); + if read_response.closed || output.ends_with(b"line:second\n") { + break; + } + } + + assert_eq!( + String::from_utf8(output)?, + "line:first\nline:second\n".to_string() + ); + + server.shutdown().await?; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn exec_server_resumes_detached_session_without_killing_processes() -> anyhow::Result<()> { let mut server = exec_server().await?; From 47ab51470b5051f4eba1694000416be43e880cda Mon Sep 17 00:00:00 2001 From: rka-oai Date: Thu, 18 Jun 2026 10:16:51 -0700 Subject: [PATCH 030/112] Pin Windows argument lint to Windows 2022 (#28940) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Run the Windows argument-comment-lint job on the `windows-2022` hosted runner instead of the custom Windows runner pool. ## Why The custom pool recently moved from the Visual Studio 2022 Windows image to `windows-2025-vs2026`. Since that migration, the job fails while Bazel materializes LLVM external repository sources, before the argument lint itself runs. The same failure appears across unrelated PRs. This narrow change tests GitHub’s recommended mitigation for workloads that still require the Visual Studio 2022 image: https://github.com/actions/runner-images/issues/14017 ## How Use the standard `windows-2022` runner for only the Windows argument-comment-lint matrix entry. No product code or lint behavior changes. --- .github/workflows/rust-ci.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 4cf6d6e37a..59a7820151 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -173,11 +173,8 @@ jobs: runner: macos-15-xlarge timeout_minutes: 30 - name: Windows - runner: windows-x64 + runner: windows-2022 timeout_minutes: 30 - runs_on: - group: ${{ github.event.repository.name }}-runners - labels: ${{ github.event.repository.name }}-windows-x64 steps: - name: Check whether argument comment lint should run id: argument_comment_lint_gate From 790213ded0588d824e99f830f41f04f3d98196df Mon Sep 17 00:00:00 2001 From: jif Date: Thu, 18 Jun 2026 18:31:07 +0100 Subject: [PATCH 031/112] Scope MCP sandbox metadata to server environment (#28914) Scope MCP sandbox metadata to the MCP server's owning environment. Previously, `codex/sandbox-state-meta` always used the turn's primary cwd and rebuilt a legacy sandbox policy from that cwd. That can be wrong for MCP servers owned by a different execution environment. This now sends the owning environment cwd as a `file:` URI in `sandboxCwd`, keeps `permissionProfile` as the permission source of truth, and omits sandbox-state metadata when a non-default server environment is not selected for the turn. Local/default MCP servers keep the existing fallback cwd behavior. Tests: - `just fmt` - `just bazel-lock-update` - `just bazel-lock-check` - `just test -p codex-mcp` - `just test -p codex-core mcp_sandbox_cwd` - `cargo build -p codex-rmcp-client --bin test_stdio_server` - `just test -p codex-core stdio_mcp_tool_call_includes_sandbox_state_meta` --- codex-rs/Cargo.lock | 1 + codex-rs/codex-mcp/Cargo.toml | 1 + codex-rs/codex-mcp/src/connection_manager.rs | 6 +++ .../codex-mcp/src/connection_manager_tests.rs | 3 ++ codex-rs/codex-mcp/src/runtime.rs | 5 +-- codex-rs/codex-mcp/src/server.rs | 2 + codex-rs/core/src/mcp_tool_call.rs | 38 +++++++++++++++---- codex-rs/core/src/mcp_tool_call_tests.rs | 35 +++++++++++++++++ codex-rs/core/tests/suite/rmcp_client.rs | 11 ++---- 9 files changed, 83 insertions(+), 19 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 844a84ef73..1cc32ea681 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3289,6 +3289,7 @@ dependencies = [ "codex-plugin", "codex-protocol", "codex-rmcp-client", + "codex-utils-path-uri", "codex-utils-plugins", "futures", "pretty_assertions", diff --git a/codex-rs/codex-mcp/Cargo.toml b/codex-rs/codex-mcp/Cargo.toml index 433cbde8b8..15866e49e4 100644 --- a/codex-rs/codex-mcp/Cargo.toml +++ b/codex-rs/codex-mcp/Cargo.toml @@ -26,6 +26,7 @@ codex-otel = { workspace = true } codex-plugin = { workspace = true } codex-protocol = { workspace = true } codex-rmcp-client = { workspace = true } +codex-utils-path-uri = { workspace = true } codex-utils-plugins = { workspace = true } futures = { workspace = true } regex-lite = { workspace = true } diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 3596486568..e50532714a 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -370,6 +370,12 @@ impl McpConnectionManager { .map(super::server::McpServerOrigin::as_str) } + pub fn server_environment_id(&self, server_name: &str) -> Option<&str> { + self.server_metadata + .get(server_name) + .map(|metadata| metadata.environment_id.as_str()) + } + pub fn server_pollutes_memory(&self, server_name: &str) -> bool { self.server_metadata .get(server_name) diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index bfbc09c473..20bb8d43cc 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -1127,6 +1127,7 @@ async fn list_all_tools_adds_server_metadata_to_cached_tools() { manager.server_metadata.insert( server_name.to_string(), McpServerMetadata { + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), pollutes_memory: true, origin: Some(McpServerOrigin::StreamableHttp( "https://docs.example".to_string(), @@ -1162,6 +1163,7 @@ fn server_metadata_preserves_tool_approval_policy() { "https://docs.example", /*apps_mcp_product_sku*/ None, ); + config.environment_id = "remote".to_string(); config.default_tools_approval_mode = Some(AppToolApproval::Prompt); config.tools.insert( "search".to_string(), @@ -1171,6 +1173,7 @@ fn server_metadata_preserves_tool_approval_policy() { ); let metadata = McpServerMetadata::from(&EffectiveMcpServer::configured(config)); + assert_eq!(metadata.environment_id, "remote"); assert_eq!(metadata.tool_approval_mode("read"), AppToolApproval::Prompt); assert_eq!( metadata.tool_approval_mode("search"), diff --git a/codex-rs/codex-mcp/src/runtime.rs b/codex-rs/codex-mcp/src/runtime.rs index 5404bf1eb5..ff5c65db00 100644 --- a/codex-rs/codex-mcp/src/runtime.rs +++ b/codex-rs/codex-mcp/src/runtime.rs @@ -12,7 +12,7 @@ use std::time::Duration; use codex_exec_server::Environment; use codex_exec_server::EnvironmentManager; use codex_protocol::models::PermissionProfile; -use codex_protocol::protocol::SandboxPolicy; +use codex_utils_path_uri::PathUri; use serde::Deserialize; use serde::Serialize; @@ -22,9 +22,8 @@ use serde::Serialize; pub struct SandboxState { #[serde(default, skip_serializing_if = "Option::is_none")] pub permission_profile: Option, - pub sandbox_policy: SandboxPolicy, pub codex_linux_sandbox_exe: Option, - pub sandbox_cwd: PathBuf, + pub sandbox_cwd: PathUri, #[serde(default)] pub use_legacy_landlock: bool, } diff --git a/codex-rs/codex-mcp/src/server.rs b/codex-rs/codex-mcp/src/server.rs index 90952cbfaf..39287b2136 100644 --- a/codex-rs/codex-mcp/src/server.rs +++ b/codex-rs/codex-mcp/src/server.rs @@ -75,6 +75,7 @@ impl McpServerOrigin { /// Semantic metadata that must survive after the server is launched. #[derive(Debug, Clone)] pub(crate) struct McpServerMetadata { + pub environment_id: String, pub pollutes_memory: bool, pub origin: Option, pub supports_parallel_tool_calls: bool, @@ -96,6 +97,7 @@ impl From<&EffectiveMcpServer> for McpServerMetadata { fn from(server: &EffectiveMcpServer) -> Self { match server.launch() { McpServerLaunch::Configured(config) => Self { + environment_id: config.environment_id.clone(), pollutes_memory: true, origin: McpServerOrigin::from_transport(&config.transport), supports_parallel_tool_calls: config.supports_parallel_tool_calls, diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index e8cbb56c55..a89951542a 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -81,6 +81,7 @@ use codex_rollout::state_db; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::truncate_text; +use codex_utils_path_uri::PathUri; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; use rmcp::model::ToolAnnotations; use serde::Deserialize; @@ -713,10 +714,8 @@ async fn augment_mcp_tool_request_meta_with_sandbox_state( server: &str, mut meta: Option, ) -> anyhow::Result> { - let supports_sandbox_state_meta = sess - .services - .mcp_connection_manager - .load_full() + let mcp_connection_manager = sess.services.mcp_connection_manager.load_full(); + let supports_sandbox_state_meta = mcp_connection_manager .server_supports_sandbox_state_meta_capability(server) .await .unwrap_or(false); @@ -724,12 +723,17 @@ async fn augment_mcp_tool_request_meta_with_sandbox_state( return Ok(meta); } + let server_environment_id = mcp_connection_manager + .server_environment_id(server) + .unwrap_or(codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID); + let Some(sandbox_cwd) = sandbox_cwd_for_mcp_server(turn_context, server_environment_id) else { + return Ok(meta); + }; + let permission_profile = turn_context.permission_profile(); let sandbox_state = serde_json::to_value(SandboxState { - permission_profile: Some(turn_context.permission_profile()), - sandbox_policy: turn_context.sandbox_policy(), + permission_profile: Some(permission_profile), codex_linux_sandbox_exe: turn_context.config.codex_linux_sandbox_exe.clone(), - #[allow(deprecated)] - sandbox_cwd: turn_context.cwd.to_path_buf(), + sandbox_cwd, use_legacy_landlock: turn_context.config.features.use_legacy_landlock(), })?; @@ -754,6 +758,24 @@ async fn augment_mcp_tool_request_meta_with_sandbox_state( Ok(meta) } +fn sandbox_cwd_for_mcp_server(turn_context: &TurnContext, environment_id: &str) -> Option { + if let Some(environment) = turn_context + .environments + .turn_environments + .iter() + .find(|environment| environment.environment_id == environment_id) + { + return Some(environment.cwd().clone()); + } + + if environment_id == codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID { + #[allow(deprecated)] + return Some(PathUri::from_abs_path(&turn_context.cwd)); + } + + None +} + async fn maybe_mark_thread_memory_mode_polluted( sess: &Session, turn_context: &TurnContext, diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 56d1ff9b6c..4249c0d4b0 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -3,6 +3,7 @@ use crate::config::ConfigBuilder; use crate::config::ManagedFeatures; use crate::session::tests::make_session_and_context; use crate::session::tests::make_session_and_context_with_rx; +use crate::session::turn_context::TurnEnvironment; use crate::state::ActiveTurn; use crate::test_support::models_manager_with_provider; use crate::tools::hook_names::HookToolName; @@ -31,6 +32,7 @@ use codex_rollout_trace::ToolDispatchInvocation; use codex_rollout_trace::ToolDispatchPayload; use codex_rollout_trace::ToolDispatchRequester; use codex_rollout_trace::replay_bundle; +use codex_utils_path_uri::PathUri; use core_test_support::hooks::trusted_config_layer_stack; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -1091,6 +1093,39 @@ async fn mcp_tool_call_request_meta_includes_turn_started_at_unix_ms() { ); } +#[tokio::test] +async fn mcp_sandbox_cwd_uses_matching_server_environment_uri() -> anyhow::Result<()> { + let (_, mut turn_context) = make_session_and_context().await; + let secondary_cwd = PathUri::parse("file:///C:/remote/project")?; + let environment = turn_context.environments.turn_environments[0] + .environment + .clone(); + turn_context + .environments + .turn_environments + .push(TurnEnvironment::new( + "remote".to_string(), + environment, + secondary_cwd.clone(), + /*shell*/ None, + )); + + let sandbox_cwd = sandbox_cwd_for_mcp_server(&turn_context, "remote"); + + assert_eq!(sandbox_cwd, Some(secondary_cwd)); + Ok(()) +} + +#[tokio::test] +async fn mcp_sandbox_cwd_is_none_for_unselected_server_environment() -> anyhow::Result<()> { + let (_, turn_context) = make_session_and_context().await; + + let sandbox_cwd = sandbox_cwd_for_mcp_server(&turn_context, "remote"); + + assert_eq!(sandbox_cwd, None); + Ok(()) +} + #[tokio::test] async fn plugin_mcp_tool_call_request_meta_includes_plugin_id() { let (_, turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 2be014b3f5..656ca69317 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -815,16 +815,11 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()> let sandbox_meta = meta .get(MCP_SANDBOX_STATE_META_CAPABILITY) .expect("sandbox state metadata should be present"); - let (sandbox_policy, _) = - turn_permission_fields(PermissionProfile::read_only(), fixture.config.cwd.as_path()); - let expected_sandbox_policy = serde_json::to_value(&sandbox_policy)?; - assert_eq!( - sandbox_meta.get("sandboxPolicy"), - Some(&expected_sandbox_policy) - ); + assert_eq!(sandbox_meta.get("sandboxPolicy"), None); + let expected_sandbox_cwd = PathUri::from_abs_path(&fixture.config.cwd).to_string(); assert_eq!( sandbox_meta.get("sandboxCwd").and_then(Value::as_str), - fixture.config.cwd.as_path().to_str() + Some(expected_sandbox_cwd.as_str()) ); assert_eq!(sandbox_meta.get("useLegacyLandlock"), Some(&json!(false))); From 9684ec25be97707ea5a0cc4dc0a2d23d466c4b41 Mon Sep 17 00:00:00 2001 From: jif Date: Thu, 18 Jun 2026 18:40:28 +0100 Subject: [PATCH 032/112] Add turn-scoped context contributions (#28911) ## Summary - keep context injection on a single ContextContributor trait - split context injection into thread-scoped and turn-scoped contribution methods - wire turn-scoped fragments into initial context assembly so extensions can contribute context from turn-local state --- codex-rs/core/src/guardian/tests.rs | 2 +- codex-rs/core/src/session/mod.rs | 129 +++++++++++++++--- codex-rs/core/src/session/tests.rs | 92 ++++++++++++- .../examples/enabled_extensions.rs | 6 +- .../shared_state_extension.rs | 4 +- .../ext/extension-api/src/contributors.rs | 28 +++- .../extension-api/src/contributors/context.rs | 20 +++ codex-rs/ext/extension-api/src/lib.rs | 1 + codex-rs/ext/extension-api/tests/registry.rs | 44 +++++- codex-rs/ext/memories/src/extension.rs | 2 +- codex-rs/ext/memories/src/tests.rs | 2 +- codex-rs/ext/skills/src/extension.rs | 2 +- codex-rs/ext/skills/tests/skills_extension.rs | 6 +- codex-rs/protocol/src/protocol.rs | 2 +- 14 files changed, 307 insertions(+), 33 deletions(-) create mode 100644 codex-rs/ext/extension-api/src/contributors/context.rs diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index d6eb93dd14..15024990a2 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -107,7 +107,7 @@ impl codex_extension_api::ThreadLifecycleContributor for GuardianMemoryC } impl codex_extension_api::ContextContributor for GuardianMemoryContextProbe { - fn contribute<'a>( + fn contribute_thread_context<'a>( &'a self, _session_store: &'a codex_extension_api::ExtensionData, thread_store: &'a codex_extension_api::ExtensionData, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 0ecc706818..84e60a3d2d 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -58,7 +58,9 @@ use codex_exec_server::Environment; use codex_exec_server::EnvironmentManager; use codex_extension_api::ExtensionDataInit; use codex_extension_api::LoadedUserInstructions; +use codex_extension_api::PromptFragment; use codex_extension_api::PromptSlot; +use codex_extension_api::TurnContextContributionInput; use codex_features::FEATURES; use codex_features::Feature; use codex_features::unstable_features_warning_event; @@ -911,6 +913,25 @@ async fn thread_title_from_thread_store( (!title.is_empty() && thread.preview.trim() != title).then(|| title.to_string()) } +fn push_prompt_fragment( + fragment: PromptFragment, + developer_sections: &mut Vec, + contextual_user_sections: &mut Vec, + separate_developer_sections: &mut Vec, +) { + match fragment.slot() { + PromptSlot::DeveloperPolicy | PromptSlot::DeveloperCapabilities => { + developer_sections.push(fragment.text().to_string()); + } + PromptSlot::ContextualUser => { + contextual_user_sections.push(fragment.text().to_string()); + } + PromptSlot::SeparateDeveloper => { + separate_developer_sections.push(fragment.text().to_string()); + } + } +} + impl Session { pub(crate) async fn app_server_client_metadata(&self) -> AppServerClientMetadata { let state = self.state.lock().await; @@ -2860,6 +2881,57 @@ impl Session { } } + async fn build_turn_context_contribution_items( + &self, + turn_context: &TurnContext, + ) -> Vec { + let mut developer_sections = Vec::new(); + let mut contextual_user_sections = Vec::new(); + let mut separate_developer_sections = Vec::new(); + let context_contributors = self.services.extensions.context_contributors().to_vec(); + + for contributor in &context_contributors { + for fragment in contributor + .contribute_turn_context(TurnContextContributionInput { + thread_id: self.thread_id(), + turn_id: turn_context.sub_id.as_str(), + session_store: &self.services.session_extension_data, + thread_store: &self.services.thread_extension_data, + turn_store: turn_context.extension_data.as_ref(), + model_context_window: turn_context.model_context_window(), + }) + .await + { + push_prompt_fragment( + fragment, + &mut developer_sections, + &mut contextual_user_sections, + &mut separate_developer_sections, + ); + } + } + + let mut items = Vec::with_capacity(3); + if let Some(developer_message) = + crate::context_manager::updates::build_developer_update_item(developer_sections) + { + items.push(developer_message); + } + for section in separate_developer_sections { + if let Some(developer_message) = + crate::context_manager::updates::build_developer_update_item(vec![section]) + { + items.push(developer_message); + } + } + if let Some(contextual_user_message) = + crate::context_manager::updates::build_contextual_user_message(contextual_user_sections) + { + items.push(contextual_user_message); + } + items + } + pub(crate) async fn build_initial_context( &self, turn_context: &TurnContext, @@ -3026,25 +3098,40 @@ impl Session { developer_sections.push(plugin_instructions.render()); } let context_contributors = self.services.extensions.context_contributors().to_vec(); - for contributor in context_contributors { + for contributor in &context_contributors { for fragment in contributor - .contribute( + .contribute_thread_context( &self.services.session_extension_data, &self.services.thread_extension_data, ) .await { - match fragment.slot() { - PromptSlot::DeveloperPolicy | PromptSlot::DeveloperCapabilities => { - developer_sections.push(fragment.text().to_string()); - } - PromptSlot::ContextualUser => { - contextual_user_sections.push(fragment.text().to_string()); - } - PromptSlot::SeparateDeveloper => { - separate_developer_sections.push(fragment.text().to_string()); - } - } + push_prompt_fragment( + fragment, + &mut developer_sections, + &mut contextual_user_sections, + &mut separate_developer_sections, + ); + } + } + for contributor in &context_contributors { + for fragment in contributor + .contribute_turn_context(TurnContextContributionInput { + thread_id: self.thread_id(), + turn_id: turn_context.sub_id.as_str(), + session_store: &self.services.session_extension_data, + thread_store: &self.services.thread_extension_data, + turn_store: turn_context.extension_data.as_ref(), + model_context_window: turn_context.model_context_window(), + }) + .await + { + push_prompt_fragment( + fragment, + &mut developer_sections, + &mut contextual_user_sections, + &mut separate_developer_sections, + ); } } if let Some(user_instructions) = turn_context.user_instructions.as_deref() { @@ -3211,15 +3298,25 @@ impl Session { let state = self.state.lock().await; state.reference_context_item() }; + let turn_context_item = turn_context.to_turn_context_item(); + if reference_context_item.as_ref() == Some(&turn_context_item) { + return; + } let should_inject_full_context = reference_context_item.is_none(); - let context_items = if should_inject_full_context { + let mut context_items = if should_inject_full_context { self.build_initial_context(turn_context).await } else { - // Steady-state path: append only context diffs to minimize token overhead. + // Steady-state path: append only built-in context diffs here; turn-scoped extension + // context is added below. self.build_settings_update_items(reference_context_item.as_ref(), turn_context) .await }; - let turn_context_item = turn_context.to_turn_context_item(); + if !should_inject_full_context { + context_items.extend( + self.build_turn_context_contribution_items(turn_context) + .await, + ); + } if !context_items.is_empty() { self.record_conversation_items(turn_context, &context_items) .await; diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 724c35f2e8..63e8783cc6 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -7540,9 +7540,13 @@ async fn make_multi_agent_v2_usage_hint_test_session( struct PromptExtensionTestContributor; struct PromptExtensionTestState; +struct TurnContextExtensionTestContributor; +struct TurnContextExtensionTestState { + expected_model_context_window: Option, +} impl codex_extension_api::ContextContributor for PromptExtensionTestContributor { - fn contribute<'a>( + fn contribute_thread_context<'a>( &'a self, _session_store: &'a codex_extension_api::ExtensionData, thread_store: &'a codex_extension_api::ExtensionData, @@ -7571,6 +7575,31 @@ fn prompt_extension_test_registry() Arc::new(builder.build()) } +impl codex_extension_api::ContextContributor for TurnContextExtensionTestContributor { + fn contribute_turn_context<'a>( + &'a self, + input: codex_extension_api::TurnContextContributionInput<'a>, + ) -> std::pin::Pin< + Box> + Send + 'a>, + > { + Box::pin(async move { + let Some(state) = input.turn_store.get::() else { + return Vec::new(); + }; + (input.model_context_window == state.expected_model_context_window + && input.model_context_window.is_some() + && !input.turn_id.is_empty()) + .then(|| { + codex_extension_api::PromptFragment::developer_policy( + "turn context extension enabled", + ) + }) + .into_iter() + .collect() + }) + } +} + #[tokio::test] async fn build_initial_context_includes_prompt_fragments_from_extensions() { let (mut session, turn_context) = make_session_and_context().await; @@ -7592,6 +7621,67 @@ async fn build_initial_context_includes_prompt_fragments_from_extensions() { ); } +#[tokio::test] +async fn build_initial_context_includes_turn_context_fragments_from_extensions() { + let (mut session, mut turn_context) = make_session_and_context().await; + let mut builder = codex_extension_api::ExtensionRegistryBuilder::new(); + builder.prompt_contributor(Arc::new(TurnContextExtensionTestContributor)); + session.services.extensions = Arc::new(builder.build()); + turn_context.model_info.context_window = Some(100); + turn_context.model_info.effective_context_window_percent = 50; + turn_context + .extension_data + .insert(TurnContextExtensionTestState { + expected_model_context_window: Some(50), + }); + + let initial_context = session.build_initial_context(&turn_context).await; + let developer_messages = developer_message_texts(&initial_context); + + assert!( + developer_messages + .iter() + .flatten() + .any(|text| *text == "turn context extension enabled"), + "expected turn context extension developer text, got {developer_messages:?}" + ); +} + +#[tokio::test] +async fn record_context_updates_includes_turn_context_fragments_on_steady_state_turns() { + let (mut session, mut turn_context) = make_session_and_context().await; + let mut builder = codex_extension_api::ExtensionRegistryBuilder::new(); + builder.prompt_contributor(Arc::new(TurnContextExtensionTestContributor)); + session.services.extensions = Arc::new(builder.build()); + turn_context.model_info.context_window = Some(200); + turn_context.model_info.effective_context_window_percent = 25; + turn_context + .extension_data + .insert(TurnContextExtensionTestState { + expected_model_context_window: Some(50), + }); + let mut previous_context_item = turn_context.to_turn_context_item(); + previous_context_item.turn_id = Some("previous-turn-id".to_string()); + { + let mut state = session.state.lock().await; + state.set_reference_context_item(Some(previous_context_item)); + } + + session + .record_context_updates_and_set_reference_context_item(&turn_context) + .await; + + let history = session.clone_history().await; + let developer_messages = developer_message_texts(history.raw_items()); + assert!( + developer_messages + .iter() + .flatten() + .any(|text| *text == "turn context extension enabled"), + "expected steady-state turn context extension developer text, got {developer_messages:?}" + ); +} + #[tokio::test] async fn build_initial_context_omits_prompt_fragments_without_extension_state() { let (mut session, turn_context) = make_session_and_context().await; diff --git a/codex-rs/ext/extension-api/examples/enabled_extensions.rs b/codex-rs/ext/extension-api/examples/enabled_extensions.rs index 9027c28241..45b178bc81 100644 --- a/codex-rs/ext/extension-api/examples/enabled_extensions.rs +++ b/codex-rs/ext/extension-api/examples/enabled_extensions.rs @@ -74,7 +74,11 @@ async fn contribute_prompt( ) -> Vec { let mut fragments = Vec::new(); for contributor in registry.context_contributors() { - fragments.extend(contributor.contribute(session_store, thread_store).await); + fragments.extend( + contributor + .contribute_thread_context(session_store, thread_store) + .await, + ); } fragments } diff --git a/codex-rs/ext/extension-api/examples/enabled_extensions/shared_state_extension.rs b/codex-rs/ext/extension-api/examples/enabled_extensions/shared_state_extension.rs index 531f65b99e..414a67215b 100644 --- a/codex-rs/ext/extension-api/examples/enabled_extensions/shared_state_extension.rs +++ b/codex-rs/ext/extension-api/examples/enabled_extensions/shared_state_extension.rs @@ -17,7 +17,7 @@ pub fn install(registry: &mut ExtensionRegistryBuilder<()>) { struct StyleContributor; impl ContextContributor for StyleContributor { - fn contribute<'a>( + fn contribute_thread_context<'a>( &'a self, session_store: &'a ExtensionData, thread_store: &'a ExtensionData, @@ -37,7 +37,7 @@ impl ContextContributor for StyleContributor { struct UsageContributor; impl ContextContributor for UsageContributor { - fn contribute<'a>( + fn contribute_thread_context<'a>( &'a self, session_store: &'a ExtensionData, thread_store: &'a ExtensionData, diff --git a/codex-rs/ext/extension-api/src/contributors.rs b/codex-rs/ext/extension-api/src/contributors.rs index 3a0fee49a9..7d16efd3d4 100644 --- a/codex-rs/ext/extension-api/src/contributors.rs +++ b/codex-rs/ext/extension-api/src/contributors.rs @@ -11,6 +11,7 @@ use codex_tools::ToolExecutor; use crate::ExtensionData; +mod context; mod mcp; mod prompt; mod thread_lifecycle; @@ -18,6 +19,7 @@ mod tool_lifecycle; mod turn_input; mod turn_lifecycle; +pub use context::TurnContextContributionInput; pub use mcp::McpServerContribution; pub use mcp::McpServerContributionContext; pub use prompt::PromptFragment; @@ -62,12 +64,34 @@ pub trait McpServerContributor: Send + Sync { } /// Extension contribution that adds prompt fragments during prompt assembly. +/// +/// Implementations should use the method matching the scope needed by the +/// fragment: thread/session context for stable inputs, and turn context for +/// fragments that depend on turn-local host state. pub trait ContextContributor: Send + Sync { - fn contribute<'a>( + fn contribute_thread_context<'a>( &'a self, session_store: &'a ExtensionData, thread_store: &'a ExtensionData, - ) -> ExtensionFuture<'a, Vec>; + ) -> ExtensionFuture<'a, Vec> { + Box::pin(async move { + let _self = self; + let _session_store = session_store; + let _thread_store = thread_store; + Vec::new() + }) + } + + fn contribute_turn_context<'a>( + &'a self, + input: TurnContextContributionInput<'a>, + ) -> ExtensionFuture<'a, Vec> { + Box::pin(async move { + let _self = self; + let _input = input; + Vec::new() + }) + } } /// Contributor for host-owned thread lifecycle gates. diff --git a/codex-rs/ext/extension-api/src/contributors/context.rs b/codex-rs/ext/extension-api/src/contributors/context.rs new file mode 100644 index 0000000000..fb6239ec4b --- /dev/null +++ b/codex-rs/ext/extension-api/src/contributors/context.rs @@ -0,0 +1,20 @@ +use codex_protocol::ThreadId; + +use crate::ExtensionData; + +/// Host context available while extensions contribute turn-scoped context fragments. +#[derive(Clone, Copy)] +pub struct TurnContextContributionInput<'a> { + /// Stable host-owned thread identifier. + pub thread_id: ThreadId, + /// Stable host-owned turn identifier. + pub turn_id: &'a str, + /// Store scoped to the host session runtime. + pub session_store: &'a ExtensionData, + /// Store scoped to this thread runtime. + pub thread_store: &'a ExtensionData, + /// Store scoped to this turn. + pub turn_store: &'a ExtensionData, + /// Effective model context window for this turn, when known. + pub model_context_window: Option, +} diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index e646a53a52..de0e0c99f8 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -54,6 +54,7 @@ pub use contributors::ToolLifecycleContributor; pub use contributors::ToolLifecycleFuture; pub use contributors::ToolStartInput; pub use contributors::TurnAbortInput; +pub use contributors::TurnContextContributionInput; pub use contributors::TurnErrorInput; pub use contributors::TurnInputContext; pub use contributors::TurnInputContributor; diff --git a/codex-rs/ext/extension-api/tests/registry.rs b/codex-rs/ext/extension-api/tests/registry.rs index 79773a98cf..59b4f8c5e4 100644 --- a/codex-rs/ext/extension-api/tests/registry.rs +++ b/codex-rs/ext/extension-api/tests/registry.rs @@ -12,12 +12,14 @@ use codex_extension_api::ExtensionEventSink; use codex_extension_api::ExtensionFuture; use codex_extension_api::ExtensionRegistryBuilder; use codex_extension_api::PromptFragment; +use codex_extension_api::PromptSlot; use codex_extension_api::ThreadLifecycleContributor; use codex_extension_api::TokenUsageContributor; use codex_extension_api::ToolCall; use codex_extension_api::ToolContributor; use codex_extension_api::ToolExecutor; use codex_extension_api::ToolLifecycleContributor; +use codex_extension_api::TurnContextContributionInput; use codex_extension_api::TurnInputContext; use codex_extension_api::TurnInputContributor; use codex_extension_api::TurnItemContributor; @@ -34,7 +36,7 @@ use pretty_assertions::assert_eq; struct AllContributors; impl ContextContributor for AllContributors { - fn contribute<'a>( + fn contribute_thread_context<'a>( &'a self, _session_store: &'a ExtensionData, _thread_store: &'a ExtensionData, @@ -147,7 +149,7 @@ async fn build_round_trips_every_contributor_category() { struct NamedContextContributor(&'static str); impl ContextContributor for NamedContextContributor { - fn contribute<'a>( + fn contribute_thread_context<'a>( &'a self, _session_store: &'a ExtensionData, _thread_store: &'a ExtensionData, @@ -158,6 +160,20 @@ impl ContextContributor for NamedContextContributor { } } +struct NamedTurnContextContributor(&'static str); + +impl ContextContributor for NamedTurnContextContributor { + fn contribute_turn_context<'a>( + &'a self, + _input: TurnContextContributionInput<'a>, + ) -> ExtensionFuture<'a, Vec> { + Box::pin(std::future::ready(vec![PromptFragment::new( + PromptSlot::ContextualUser, + self.0, + )])) + } +} + struct RecordingTurnItemContributor { name: &'static str, calls: Arc>>, @@ -186,6 +202,8 @@ async fn contributors_preserve_registration_order() { let mut builder = ExtensionRegistryBuilder::<()>::new(); builder.prompt_contributor(Arc::new(NamedContextContributor("first"))); builder.prompt_contributor(Arc::new(NamedContextContributor("second"))); + builder.prompt_contributor(Arc::new(NamedTurnContextContributor("turn-first"))); + builder.prompt_contributor(Arc::new(NamedTurnContextContributor("turn-second"))); for name in ["first", "second"] { builder.turn_item_contributor(Arc::new(RecordingTurnItemContributor { name, @@ -199,7 +217,25 @@ async fn contributors_preserve_registration_order() { let mut fragments = Vec::new(); for contributor in registry.context_contributors() { - fragments.extend(contributor.contribute(&session_store, &thread_store).await); + fragments.extend( + contributor + .contribute_thread_context(&session_store, &thread_store) + .await, + ); + } + for contributor in registry.context_contributors() { + fragments.extend( + contributor + .contribute_turn_context(TurnContextContributionInput { + thread_id: codex_protocol::ThreadId::default(), + turn_id: turn_store.level_id(), + session_store: &session_store, + thread_store: &thread_store, + turn_store: &turn_store, + model_context_window: Some(123), + }) + .await, + ); } let mut item = TurnItem::HookPrompt(HookPromptItem { id: "item".to_string(), @@ -217,6 +253,8 @@ async fn contributors_preserve_registration_order() { vec![ PromptFragment::developer_policy("first"), PromptFragment::developer_policy("second"), + PromptFragment::new(PromptSlot::ContextualUser, "turn-first"), + PromptFragment::new(PromptSlot::ContextualUser, "turn-second"), ] ); assert_eq!( diff --git a/codex-rs/ext/memories/src/extension.rs b/codex-rs/ext/memories/src/extension.rs index 23c80a8779..464ce24576 100644 --- a/codex-rs/ext/memories/src/extension.rs +++ b/codex-rs/ext/memories/src/extension.rs @@ -48,7 +48,7 @@ impl MemoriesExtensionConfig { } impl ContextContributor for MemoriesExtension { - fn contribute<'a>( + fn contribute_thread_context<'a>( &'a self, _session_store: &'a ExtensionData, thread_store: &'a ExtensionData, diff --git a/codex-rs/ext/memories/src/tests.rs b/codex-rs/ext/memories/src/tests.rs index b78c20c9f1..9cc038b800 100644 --- a/codex-rs/ext/memories/src/tests.rs +++ b/codex-rs/ext/memories/src/tests.rs @@ -181,7 +181,7 @@ async fn prompt_contribution_uses_memory_summary_when_enabled() { }); let fragments = extension - .contribute(&ExtensionData::new("session"), &thread_store) + .contribute_thread_context(&ExtensionData::new("session"), &thread_store) .await; assert_eq!(fragments.len(), 1); diff --git a/codex-rs/ext/skills/src/extension.rs b/codex-rs/ext/skills/src/extension.rs index c82ce66423..0a3da0972a 100644 --- a/codex-rs/ext/skills/src/extension.rs +++ b/codex-rs/ext/skills/src/extension.rs @@ -103,7 +103,7 @@ impl ContextContributor for SkillsExtension where C: Send + Sync + 'static, { - fn contribute<'a>( + fn contribute_thread_context<'a>( &'a self, session_store: &'a ExtensionData, thread_store: &'a ExtensionData, diff --git a/codex-rs/ext/skills/tests/skills_extension.rs b/codex-rs/ext/skills/tests/skills_extension.rs index cd6f46d9c9..16d9bdd1a3 100644 --- a/codex-rs/ext/skills/tests/skills_extension.rs +++ b/codex-rs/ext/skills/tests/skills_extension.rs @@ -183,7 +183,7 @@ async fn selected_executor_catalog_is_context_and_selected_entrypoint_is_turn_in .await; let prompt_fragments = registry.context_contributors()[0] - .contribute(&session_store, &thread_store) + .contribute_thread_context(&session_store, &thread_store) .await; assert_eq!(1, prompt_fragments.len()); assert!( @@ -228,7 +228,7 @@ async fn selected_executor_catalog_is_context_and_selected_entrypoint_is_turn_in read_request_keys(&read_requests) ); let rebuilt_prompt_fragments = registry.context_contributors()[0] - .contribute(&session_store, &thread_store) + .contribute_thread_context(&session_store, &thread_store) .await; assert_eq!(1, rebuilt_prompt_fragments.len()); assert!(rebuilt_prompt_fragments[0].text().contains("lint-fix")); @@ -294,7 +294,7 @@ async fn orchestrator_catalog_snapshot_caches_failure() -> TestResult { .await; let initial_fragments = registry.context_contributors()[0] - .contribute(&session_store, &thread_store) + .contribute_thread_context(&session_store, &thread_store) .await; assert!(initial_fragments.is_empty()); let EventMsg::Warning(warning) = event_rx.try_recv()?.msg else { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index d51a30addf..a71eb64fe4 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2992,7 +2992,7 @@ pub struct TurnContextNetworkItem { /// context updates, and again after mid-turn compaction when replacement /// history re-establishes full context, so resume/fork replay can recover the /// latest durable baseline. -#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, TS)] pub struct TurnContextItem { #[serde(default, skip_serializing_if = "Option::is_none")] pub turn_id: Option, From e8dd1b45cbfe241abf85c6696f7ab07e85a0c5d5 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 18 Jun 2026 10:50:15 -0700 Subject: [PATCH 033/112] Fix goal-first live threads missing from thread/list (#28808) Fixes #28263. ## Why When a thread starts with `/goal`, the goal extension can update SQLite goal state before the thread has any user-turn rollout items. `thread/list` and `thread/search` rely on persisted listing metadata, so a goal-first live thread could be absent from app-server listings after restart even though the goal itself existed. This regressed when goal handling moved out of core: the core path wrote the goal update through the live thread rollout path, while the extension-backed app-server path only updated goal state and emitted the live notification. ## What - Add `GoalSetOutcome::thread_goal_updated_item()` so the goal extension owns the canonical `ThreadGoalUpdated` rollout item shape. - Expose a narrow `CodexThread::append_rollout_items()` helper that appends through the live thread and keeps derived SQLite metadata in sync. - When app-server sets a goal on an active live thread, persist the goal update through that live-thread path. - Add an app-server regression test that starts a live thread with `thread/goal/set` and verifies it appears in state-DB-only `thread/list`. ## Verification - `env -u CODEX_SQLITE_HOME just test -p codex-app-server goal_first_live_thread_appears_in_state_db_thread_list` --- .../thread_goal_processor.rs | 15 ++++ .../tests/suite/v2/thread_resume.rs | 90 +++++++++++++++++++ codex-rs/core/src/codex_thread.rs | 13 +++ codex-rs/ext/goal/src/api.rs | 11 +++ 4 files changed, 129 insertions(+) diff --git a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs index 2f40ce6602..5878047237 100644 --- a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs @@ -135,6 +135,21 @@ impl ThreadGoalRequestProcessor { .await .map_err(goal_service_error)?; let goal = ThreadGoal::from(outcome.goal.clone()); + + let persist_result = match self.thread_manager.get_thread(thread_id).await { + Ok(thread) => { + // Live goal-first threads can be listed before any user turn is written. + // Use the live path so JSONL and SQLite preview metadata stay in sync. + thread + .append_rollout_items(&[outcome.thread_goal_updated_item()]) + .await + } + Err(_) => Ok(()), + }; + if let Err(err) = persist_result { + warn!("failed to persist goal update for live thread {thread_id}: {err}"); + } + self.outgoing .send_response( request_id.clone(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 03e30e373e..c191263d1c 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -33,6 +33,7 @@ use codex_app_server_protocol::ThreadGoalClearResponse; use codex_app_server_protocol::ThreadGoalSetResponse; use codex_app_server_protocol::ThreadGoalStatus; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadMetadataGitInfoUpdateParams; use codex_app_server_protocol::ThreadMetadataUpdateParams; use codex_app_server_protocol::ThreadReadParams; @@ -464,6 +465,95 @@ async fn thread_goal_get_rejects_unmaterialized_thread() -> Result<()> { Ok(()) } +#[tokio::test] +async fn goal_first_live_thread_appears_in_state_db_thread_list() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + let codex_home_path = normalized_existing_path(codex_home.path())?; + create_config_toml(&codex_home_path, &server.uri())?; + let config_path = codex_home_path.join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config.replace("personality = true\n", "personality = true\ngoals = true\n"), + )?; + + let sqlite_home = codex_home_path + .as_path() + .to_str() + .expect("test codex home should be utf-8"); + let mut mcp = TestAppServer::new_without_managed_config_with_env( + &codex_home_path, + &[("CODEX_SQLITE_HOME", Some(sqlite_home))], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.2-codex".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, cwd, .. } = to_response::(start_resp)?; + + let goal_id = mcp + .send_raw_request( + "thread/goal/set", + Some(json!({ + "threadId": thread.id.clone(), + "objective": "keep the goal-first thread visible", + "status": "paused", + })), + ) + .await?; + let goal_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(goal_id)), + ) + .await??; + let _goal: ThreadGoalSetResponse = to_response(goal_resp)?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/goal/updated"), + ) + .await??; + + let list_id = mcp + .send_raw_request( + "thread/list", + Some(json!({ + "limit": 10, + "modelProviders": ["mock_provider"], + "sourceKinds": ["vscode"], + "archived": false, + "cwd": cwd.as_path().to_string_lossy().to_string(), + "useStateDbOnly": true, + })), + ) + .await?; + let list_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(list_id)), + ) + .await??; + let list: ThreadListResponse = to_response(list_resp)?; + assert_eq!( + list.data + .iter() + .map(|thread| &thread.id) + .collect::>(), + vec![&thread.id] + ); + + Ok(()) +} + #[tokio::test] async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 99e0391d3b..1df188fc31 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -24,6 +24,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::Op; +use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; @@ -535,6 +536,18 @@ impl CodexThread { live_thread.update_metadata(patch, include_archived).await } + /// Appends rollout items through the live thread so derived metadata stays in sync. + pub async fn append_rollout_items(&self, items: &[RolloutItem]) -> ThreadStoreResult<()> { + let live_thread = self + .codex + .session + .live_thread_for_persistence("append rollout items") + .map_err(|err| ThreadStoreError::Internal { + message: err.to_string(), + })?; + live_thread.append_items(items).await + } + pub fn state_db(&self) -> Option { self.codex.state_db() } diff --git a/codex-rs/ext/goal/src/api.rs b/codex-rs/ext/goal/src/api.rs index a4dc93409e..807f481d0d 100644 --- a/codex-rs/ext/goal/src/api.rs +++ b/codex-rs/ext/goal/src/api.rs @@ -6,8 +6,11 @@ use std::sync::PoisonError; use std::sync::Weak; use codex_protocol::ThreadId; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::ThreadGoal; use codex_protocol::protocol::ThreadGoalStatus; +use codex_protocol::protocol::ThreadGoalUpdatedEvent; use codex_protocol::protocol::validate_thread_goal_objective; use crate::runtime::GoalRuntimeHandle; @@ -61,6 +64,14 @@ pub struct GoalSetOutcome { } impl GoalSetOutcome { + pub fn thread_goal_updated_item(&self) -> RolloutItem { + RolloutItem::EventMsg(EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { + thread_id: self.goal.thread_id, + turn_id: None, + goal: self.goal.clone(), + })) + } + pub async fn apply_runtime_effects(&self, goal_service: &GoalService) { if let Some(runtime) = goal_service.runtime_for_thread(self.goal.thread_id) && let Err(err) = runtime From 4c7228e423839fb6bd6e2f2a6abeb397ab3f2e28 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Thu, 18 Jun 2026 11:03:42 -0700 Subject: [PATCH 034/112] [codex] Initialize exec-server OpenTelemetry at startup (#25019) ## Summary - Initialize stderr tracing and the configured OpenTelemetry provider for local and remote `codex exec-server` startup. - Instrument the local and remote server entrypoints with a root runtime span. - Keep raw Noise environment, registration, and stream identifiers out of exported spans while preserving them in local debug events. - Keep telemetry setup in a focused CLI module instead of growing the top-level command entrypoint. ## Stack - Previous: none (`#27058` has merged) - Next: #27466 ## Validation - `just test -p codex-exec-server --lib` (139 passed) - `just test -p codex-cli --test exec_server` (3 passed) - `just bazel-lock-check` - `just fix -p codex-exec-server -p codex-cli` - `just fmt` --------- Co-authored-by: Richard Lee --- codex-rs/cli/src/exec_server_telemetry.rs | 41 +++++++++++++++++++ codex-rs/cli/src/main.rs | 19 ++++++--- codex-rs/cli/tests/exec_server.rs | 15 +++++++ .../exec-server/src/noise_relay/harness.rs | 10 ++--- codex-rs/exec-server/src/relay.rs | 14 +++---- codex-rs/exec-server/src/remote.rs | 5 +++ codex-rs/exec-server/src/server.rs | 5 +++ 7 files changed, 88 insertions(+), 21 deletions(-) create mode 100644 codex-rs/cli/src/exec_server_telemetry.rs diff --git a/codex-rs/cli/src/exec_server_telemetry.rs b/codex-rs/cli/src/exec_server_telemetry.rs new file mode 100644 index 0000000000..4d688a0159 --- /dev/null +++ b/codex-rs/cli/src/exec_server_telemetry.rs @@ -0,0 +1,41 @@ +use tracing_subscriber::EnvFilter; +use tracing_subscriber::prelude::*; + +const DEFAULT_ANALYTICS_ENABLED: bool = false; +const DEFAULT_LOG_FILTER: &str = "error,opentelemetry_sdk=off,opentelemetry_otlp=off"; +const OTEL_SERVICE_NAME: &str = "codex-exec-server"; + +pub(crate) fn init( + config: Option<&codex_core::config::Config>, +) -> Result> { + let fmt_layer = tracing_subscriber::fmt::layer() + .with_writer(std::io::stderr) + .with_filter(stderr_env_filter()); + let otel = match config { + Some(config) => codex_core::otel_init::build_provider( + config, + env!("CARGO_PKG_VERSION"), + Some(OTEL_SERVICE_NAME), + DEFAULT_ANALYTICS_ENABLED, + ), + None => Ok(None), + }; + let provider = otel.as_ref().ok().and_then(Option::as_ref); + codex_core::otel_init::record_process_start(provider, OTEL_SERVICE_NAME); + + let otel_logger_layer = provider.and_then(|otel| otel.logger_layer()); + let otel_tracing_layer = provider.and_then(|otel| otel.tracing_layer()); + let _ = tracing_subscriber::registry() + .with(fmt_layer) + .with(otel_tracing_layer) + .with(otel_logger_layer) + .try_init(); + tracing::callsite::rebuild_interest_cache(); + otel +} + +fn stderr_env_filter() -> EnvFilter { + EnvFilter::try_from_default_env() + .or_else(|_| EnvFilter::try_new(DEFAULT_LOG_FILTER)) + .unwrap_or_else(|_| EnvFilter::new("error")) +} diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index f6c5fbfb98..8b633265e5 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -49,6 +49,7 @@ mod app_cmd; #[cfg(any(target_os = "macos", target_os = "windows"))] mod desktop_app; mod doctor; +mod exec_server_telemetry; mod marketplace_cmd; mod mcp_cmd; mod plugin_cmd; @@ -1692,6 +1693,9 @@ async fn run_exec_server_command( .environment_id .ok_or_else(|| anyhow::anyhow!("--environment-id is required when --remote is set"))?; let config = load_exec_server_config(root_config_overrides, strict_config).await?; + let _otel = exec_server_telemetry::init(Some(&config)) + .inspect_err(|err| eprintln!("Could not create otel exporter: {err}")) + .ok(); let auth_provider = load_exec_server_remote_auth_provider(&config, &base_url, cmd.use_agent_identity_auth) .await?; @@ -1706,12 +1710,15 @@ async fn run_exec_server_command( codex_exec_server::run_remote_environment(remote_config, runtime_paths).await?; Ok(()) } else { - if strict_config { - // Local exec-server startup does not consume Config, but strict - // mode should still reject unknown fields before opening a listener. - let _validated_config = - load_exec_server_config(root_config_overrides, strict_config).await?; - } + let config_result = load_exec_server_config(root_config_overrides, strict_config).await; + let config = if strict_config { + Some(config_result?) + } else { + config_result.ok() + }; + let _otel = exec_server_telemetry::init(config.as_ref()) + .inspect_err(|err| eprintln!("Could not create otel exporter: {err}")) + .ok(); let listen_url = cmd .listen .as_deref() diff --git a/codex-rs/cli/tests/exec_server.rs b/codex-rs/cli/tests/exec_server.rs index 0afe0d37b5..5e412a9de1 100644 --- a/codex-rs/cli/tests/exec_server.rs +++ b/codex-rs/cli/tests/exec_server.rs @@ -1,6 +1,7 @@ use std::path::Path; use anyhow::Result; +use predicates::prelude::PredicateBooleanExt; use predicates::str::contains; use tempfile::TempDir; @@ -33,3 +34,17 @@ foo = "bar" Ok(()) } + +#[test] +fn local_exec_server_ignores_invalid_config_without_strict_config() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write(codex_home.path().join("config.toml"), "not valid toml = [")?; + + let mut cmd = codex_command(codex_home.path())?; + cmd.args(["exec-server", "--listen", "stdio"]) + .assert() + .success() + .stderr(contains("not valid toml").not()); + + Ok(()) +} diff --git a/codex-rs/exec-server/src/noise_relay/harness.rs b/codex-rs/exec-server/src/noise_relay/harness.rs index ef75f25263..1989525224 100644 --- a/codex-rs/exec-server/src/noise_relay/harness.rs +++ b/codex-rs/exec-server/src/noise_relay/harness.rs @@ -86,12 +86,10 @@ where let (outgoing_tx, mut outgoing_rx) = mpsc::channel(CHANNEL_CAPACITY); let (incoming_tx, incoming_rx) = mpsc::channel(CHANNEL_CAPACITY); let (disconnected_tx, disconnected_rx) = watch::channel(false); - let stream_span = tracing::debug_span!( - "noise_relay.stream", - noise_side = "harness", - environment_id = %environment_id, - executor_registration_id = %executor_registration_id, - stream_id = %stream_id, + let stream_span = tracing::debug_span!("noise_relay.stream", noise_side = "harness",); + debug!( + environment_id, + executor_registration_id, stream_id, "Noise harness relay details" ); let websocket_task = tokio::spawn(async move { diff --git a/codex-rs/exec-server/src/relay.rs b/codex-rs/exec-server/src/relay.rs index 1202ec2d12..033da4b2a7 100644 --- a/codex-rs/exec-server/src/relay.rs +++ b/codex-rs/exec-server/src/relay.rs @@ -440,15 +440,7 @@ pub(crate) trait HarnessKeyValidator: Send + Sync { /// /// Parsing the first Noise message authenticates the harness key. Only a /// successful registry check turns that pending handshake into a virtual stream. -#[tracing::instrument( - level = "debug", - skip_all, - fields( - noise_side = "executor", - environment_id = %environment_id, - executor_registration_id = %executor_registration_id, - ) -)] +#[tracing::instrument(level = "debug", skip_all, fields(noise_side = "executor"))] pub(crate) async fn run_multiplexed_environment( stream: WebSocketStream, processor: ConnectionProcessor, @@ -460,6 +452,10 @@ pub(crate) async fn run_multiplexed_environment( S: AsyncRead + AsyncWrite + Unpin + Send + 'static, V: HarnessKeyValidator + Clone + 'static, { + debug!( + environment_id, + executor_registration_id, "Noise executor relay details" + ); let (mut websocket_sink, mut websocket_stream) = stream.split(); let (physical_outgoing_tx, mut physical_outgoing_rx) = mpsc::channel::>(CHANNEL_CAPACITY); diff --git a/codex-rs/exec-server/src/remote.rs b/codex-rs/exec-server/src/remote.rs index e2111318eb..199e787542 100644 --- a/codex-rs/exec-server/src/remote.rs +++ b/codex-rs/exec-server/src/remote.rs @@ -408,6 +408,11 @@ impl RemoteEnvironmentConfig { /// reconnects. The registration and rendezvous URL are also reused until /// rendezvous rejects the URL, at which point the next attempt registers again. /// The websocket carries cleartext routing metadata and encrypted payloads. +#[tracing::instrument( + name = "codex.exec_server", + skip_all, + fields(otel.kind = "internal") +)] pub async fn run_remote_environment( config: RemoteEnvironmentConfig, runtime_paths: ExecServerRuntimePaths, diff --git a/codex-rs/exec-server/src/server.rs b/codex-rs/exec-server/src/server.rs index bf33eb77ba..d443b0ed1f 100644 --- a/codex-rs/exec-server/src/server.rs +++ b/codex-rs/exec-server/src/server.rs @@ -13,6 +13,11 @@ pub use transport::ExecServerListenUrlParseError; use crate::ExecServerRuntimePaths; +#[tracing::instrument( + name = "codex.exec_server", + skip_all, + fields(otel.kind = "internal") +)] pub async fn run_main( listen_url: &str, runtime_paths: ExecServerRuntimePaths, From afbb69a2fb52d7cb9b2f4fa1c50948dcb6d5a029 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Thu, 18 Jun 2026 11:04:30 -0700 Subject: [PATCH 035/112] [codex] Fix Windows sandbox runtime ACL refresh (#28943) ## Why Codex Desktop repairs sandbox-user read/execute access for binaries copied to `%LOCALAPPDATA%\OpenAI\Codex\bin`, but Computer Use launches its bundled Node runtime from `%LOCALAPPDATA%\OpenAI\Codex\runtimes`. On fresh Windows installations, `CodexSandboxUsers` may therefore be unable to execute the bundled Node binary. The command runner starts, but `CreateProcessAsUserW` fails with error 5 (`ACCESS_DENIED`), causing the Node REPL to exit before Computer Use can discover applications. This is a follow-up to #21564, which added the original runtime `bin` ACL repair. ## What changed - Expand the Codex Desktop runtime ACL roots from only `bin` to both `bin` and `runtimes`. - Apply the existing inherited read/execute ACL repair to each runtime directory when it exists. - Rename the setup helper to reflect that it now handles multiple runtime paths. ## Validation - `cargo fmt -- --check` - `just test -p codex-windows-sandbox` was run: 113 tests passed and five environment-dependent legacy execution tests failed because `CreateRestrictedToken` returned error 87. --- .../src/bin/setup_main/win.rs | 2 +- .../bin/setup_main/win/setup_runtime_bin.rs | 145 +++++++++--------- 2 files changed, 76 insertions(+), 71 deletions(-) diff --git a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs index e3376f4055..e1146987bf 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs @@ -819,7 +819,7 @@ fn run_setup_full(payload: &Payload, log: &mut dyn Write, sbx_dir: &Path) -> Res } if refresh_only { - setup_runtime_bin::ensure_codex_app_runtime_bin_readable( + setup_runtime_bin::ensure_codex_app_runtime_paths_readable( sandbox_group_psid, &mut refresh_errors, log, diff --git a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin.rs index 47cacf72e0..509670e36b 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin.rs @@ -10,83 +10,88 @@ use windows_sys::Win32::Security::OBJECT_INHERIT_ACE; use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_EXECUTE; use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ; -pub(super) fn ensure_codex_app_runtime_bin_readable( +pub(super) fn ensure_codex_app_runtime_paths_readable( sandbox_group_psid: *mut c_void, refresh_errors: &mut Vec, log: &mut dyn Write, ) -> Result<()> { - let local_app_data = std::env::var_os("LOCALAPPDATA") + let local_app_data = local_app_data_root(); + let Some(local_app_data) = local_app_data else { + return Ok(()); + }; + + let read_execute_mask = FILE_GENERIC_READ | FILE_GENERIC_EXECUTE; + let codex_root = local_app_data.join("OpenAI").join("Codex"); + + for runtime_path in [codex_root.join("bin"), codex_root.join("runtimes")] { + if !runtime_path.is_dir() { + continue; + } + + let has_access = match path_mask_allows( + &runtime_path, + &[sandbox_group_psid], + read_execute_mask, + /*require_all_bits*/ true, + ) { + Ok(has_access) => has_access, + Err(err) => { + refresh_errors.push(format!( + "runtime read/execute mask check failed on {} for sandbox_group: {err}", + runtime_path.display() + )); + super::log_line( + log, + &format!( + "runtime read/execute mask check failed on {} for sandbox_group: {err}; continuing", + runtime_path.display() + ), + )?; + false + } + }; + if has_access { + continue; + } + + super::log_line( + log, + &format!( + "granting read/execute ACE to {} for sandbox users", + runtime_path.display() + ), + )?; + let result = unsafe { + ensure_allow_mask_aces_with_inheritance( + &runtime_path, + &[sandbox_group_psid], + read_execute_mask, + OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE, + ) + }; + if let Err(err) = result { + refresh_errors.push(format!( + "grant read/execute ACE failed on {} for sandbox_group: {err}", + runtime_path.display() + )); + super::log_line( + log, + &format!( + "grant read/execute ACE failed on {} for sandbox_group: {err}", + runtime_path.display() + ), + )?; + } + } + Ok(()) +} + +fn local_app_data_root() -> Option { + std::env::var_os("LOCALAPPDATA") .map(PathBuf::from) .or_else(|| { std::env::var_os("USERPROFILE") .map(PathBuf::from) .map(|profile| profile.join("AppData").join("Local")) - }); - let Some(local_app_data) = local_app_data else { - return Ok(()); - }; - - // Codex desktop copies bundled Windows binaries out of WindowsApps to this - // fixed LocalAppData cache before launching codex.exe. - let runtime_bin_dir = local_app_data.join("OpenAI").join("Codex").join("bin"); - if !runtime_bin_dir.is_dir() { - return Ok(()); - } - - let read_execute_mask = FILE_GENERIC_READ | FILE_GENERIC_EXECUTE; - let has_access = match path_mask_allows( - &runtime_bin_dir, - &[sandbox_group_psid], - read_execute_mask, - /*require_all_bits*/ true, - ) { - Ok(has_access) => has_access, - Err(err) => { - refresh_errors.push(format!( - "runtime bin read/execute mask check failed on {} for sandbox_group: {err}", - runtime_bin_dir.display() - )); - super::log_line( - log, - &format!( - "runtime bin read/execute mask check failed on {} for sandbox_group: {err}; continuing", - runtime_bin_dir.display() - ), - )?; - false - } - }; - if has_access { - return Ok(()); - } - - super::log_line( - log, - &format!( - "granting read/execute ACE to {} for sandbox users", - runtime_bin_dir.display() - ), - )?; - let result = unsafe { - ensure_allow_mask_aces_with_inheritance( - &runtime_bin_dir, - &[sandbox_group_psid], - read_execute_mask, - OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE, - ) - }; - if let Err(err) = result { - refresh_errors.push(format!( - "grant read/execute ACE failed on {} for sandbox_group: {err}", - runtime_bin_dir.display() - )); - super::log_line( - log, - &format!( - "grant read/execute ACE failed on {} for sandbox_group: {err}", - runtime_bin_dir.display() - ), - )?; - } - Ok(()) + }) } From 636a2594c6f1ab691a79473388872487c8368ba8 Mon Sep 17 00:00:00 2001 From: rka-oai Date: Thu, 18 Jun 2026 11:05:53 -0700 Subject: [PATCH 036/112] Synchronize realtime notification test requests (#28946) ## What Deliver the scripted realtime notification batch after the assistant text append request instead of after the preceding developer text append request. ## Why The batch ends with an upstream error that closes the realtime conversation. When it is emitted after the developer append, it races the subsequent assistant append: the app-server RPC can acknowledge the append before its downstream WebSocket send completes, and the test intermittently observes three requests instead of four. Making the fake server wait for the assistant append before emitting the terminal batch establishes the ordering the test asserts without sleeps or production-code changes. ## Validation - `git diff --check` - CI (the failure is timing-dependent and most reproducible in the Windows Bazel shard) --- codex-rs/app-server/tests/suite/v2/realtime_conversation.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index c09278bb88..d9f7d6fd37 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -538,6 +538,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { "session": { "id": "sess_backend", "instructions": "backend prompt" } })], vec![], + vec![], vec![ json!({ "type": "response.output_audio.delta", @@ -590,7 +591,6 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { "message": "upstream boom" }), ], - vec![], ]]) .await; From df5f12285454ce0a6e78a1ce084b57b6860add41 Mon Sep 17 00:00:00 2001 From: rka-oai Date: Thu, 18 Jun 2026 11:39:02 -0700 Subject: [PATCH 037/112] Add Config for Time Reminders (varlatency 1/n) (#28822) ## Summary Example: > [features.current_time_reminder] enabled = true reminder_interval_model_requests = 1 clock_source = "system" ## Testing - `just test -p codex-core varlatency` - `just test -p codex-core lock_contains_prompts_and_materializes_features` - `just fix -p codex-core -p codex-config -p codex-features` --- codex-rs/config/src/schema.rs | 9 ++++ codex-rs/core/config.schema.json | 40 ++++++++++++++ codex-rs/core/src/config/config_tests.rs | 61 ++++++++++++++++++++++ codex-rs/core/src/config/mod.rs | 58 ++++++++++++++++++++ codex-rs/core/src/session/config_lock.rs | 20 +++++++ codex-rs/features/src/feature_configs.rs | 30 +++++++++++ codex-rs/features/src/lib.rs | 22 ++++++++ codex-rs/thread-manager-sample/src/main.rs | 1 + 8 files changed, 241 insertions(+) diff --git a/codex-rs/config/src/schema.rs b/codex-rs/config/src/schema.rs index 6d3f901bf4..fad074e9c3 100644 --- a/codex-rs/config/src/schema.rs +++ b/codex-rs/config/src/schema.rs @@ -53,6 +53,15 @@ pub fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema { ); continue; } + if feature.id == codex_features::Feature::CurrentTimeReminder { + validation.properties.insert( + feature.key.to_string(), + schema_gen.subschema_for::>(), + ); + continue; + } if feature.id == codex_features::Feature::AppsMcpPathOverride { validation.properties.insert( feature.key.to_string(), diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 852f6de39a..7faf569f7d 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -476,6 +476,9 @@ "connectors": { "type": "boolean" }, + "current_time_reminder": { + "$ref": "#/definitions/FeatureToml_for_CurrentTimeReminderConfigToml" + }, "default_mode_request_user_input": { "type": "boolean" }, @@ -799,6 +802,30 @@ }, "type": "object" }, + "CurrentTimeReminderConfigToml": { + "additionalProperties": false, + "properties": { + "clock_source": { + "$ref": "#/definitions/CurrentTimeSource" + }, + "enabled": { + "type": "boolean" + }, + "reminder_interval_model_requests": { + "format": "uint64", + "minimum": 1.0, + "type": "integer" + } + }, + "type": "object" + }, + "CurrentTimeSource": { + "enum": [ + "system", + "external" + ], + "type": "string" + }, "DebugConfigLockToml": { "additionalProperties": false, "properties": { @@ -891,6 +918,16 @@ } ] }, + "FeatureToml_for_CurrentTimeReminderConfigToml": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/CurrentTimeReminderConfigToml" + } + ] + }, "FeatureToml_for_MultiAgentV2ConfigToml": { "anyOf": [ { @@ -4679,6 +4716,9 @@ "connectors": { "type": "boolean" }, + "current_time_reminder": { + "$ref": "#/definitions/FeatureToml_for_CurrentTimeReminderConfigToml" + }, "default_mode_request_user_input": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 2244e3e1b7..b13c9575f1 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -522,6 +522,67 @@ async fn load_config_rejects_enabled_rollout_budget_without_limit() -> std::io:: Ok(()) } +#[tokio::test] +async fn load_config_resolves_current_time_reminder() -> std::io::Result<()> { + for (config_toml, expected) in [ + ( + r#" +[features] +current_time_reminder = true +"#, + CurrentTimeReminderConfig::default(), + ), + ( + r#" +[features.current_time_reminder] +enabled = true +reminder_interval_model_requests = 4 +clock_source = "external" +"#, + CurrentTimeReminderConfig { + reminder_interval_model_requests: 4, + clock_source: CurrentTimeSource::External, + }, + ), + ] { + let config = load_current_time_reminder_config(config_toml).await?; + assert!(config.features.enabled(Feature::CurrentTimeReminder)); + assert_eq!(config.current_time_reminder, Some(expected)); + } + Ok(()) +} + +#[tokio::test] +async fn load_config_rejects_zero_current_time_reminder_interval() -> std::io::Result<()> { + let error = load_current_time_reminder_config( + r#" +[features.current_time_reminder] +enabled = true +reminder_interval_model_requests = 0 +"#, + ) + .await + .expect_err("zero reminder interval should be rejected"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + error.to_string(), + "features.current_time_reminder.reminder_interval_model_requests must be positive" + ); + Ok(()) +} + +async fn load_current_time_reminder_config(config_toml: &str) -> std::io::Result { + let codex_home = tempdir()?; + let config_toml = toml::from_str(config_toml).expect("TOML should deserialize"); + Config::load_from_base_config_with_overrides( + config_toml, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await +} + #[test] fn rejects_provider_auth_with_env_key() { let err = toml::from_str::( diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 0427613361..a1854067f5 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -58,6 +58,8 @@ use codex_core_plugins::PluginsConfigInput; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::LOCAL_FS; use codex_features::CodeModeConfigToml; +use codex_features::CurrentTimeReminderConfigToml; +use codex_features::CurrentTimeSource; use codex_features::Feature; use codex_features::FeatureConfigSource; use codex_features::FeatureOverrides; @@ -1022,6 +1024,8 @@ pub struct Config { /// Shared token budget for the root thread and its sub-agents. pub rollout_budget: Option, + /// Current-time reminder configuration, when enabled. + pub current_time_reminder: Option, /// Centralized feature flags; source of truth for feature gating. pub features: ManagedFeatures, @@ -1075,6 +1079,21 @@ pub struct RolloutBudgetConfig { pub prefill_token_weight: f64, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct CurrentTimeReminderConfig { + pub reminder_interval_model_requests: u64, + pub clock_source: CurrentTimeSource, +} + +impl Default for CurrentTimeReminderConfig { + fn default() -> Self { + Self { + reminder_interval_model_requests: 1, + clock_source: CurrentTimeSource::System, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct MultiAgentV2Config { pub max_concurrent_threads_per_session: usize, @@ -2539,6 +2558,34 @@ fn resolve_rollout_budget_config( })) } +fn resolve_current_time_reminder_config( + config_toml: &ConfigToml, + features: &ManagedFeatures, +) -> std::io::Result> { + if !features.enabled(Feature::CurrentTimeReminder) { + return Ok(None); + } + + let base = current_time_reminder_toml_config(config_toml.features.as_ref()); + let default = CurrentTimeReminderConfig::default(); + let reminder_interval_model_requests = base + .and_then(|config| config.reminder_interval_model_requests) + .unwrap_or(default.reminder_interval_model_requests); + if reminder_interval_model_requests == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "features.current_time_reminder.reminder_interval_model_requests must be positive", + )); + } + + Ok(Some(CurrentTimeReminderConfig { + reminder_interval_model_requests, + clock_source: base + .and_then(|config| config.clock_source) + .unwrap_or(default.clock_source), + })) +} + fn resolve_terminal_resize_reflow_config(config_toml: &ConfigToml) -> TerminalResizeReflowConfig { let Some(tui) = config_toml.tui.as_ref() else { return TerminalResizeReflowConfig::default(); @@ -2578,6 +2625,15 @@ fn multi_agent_v2_toml_config(features: Option<&FeaturesToml>) -> Option<&MultiA } } +fn current_time_reminder_toml_config( + features: Option<&FeaturesToml>, +) -> Option<&CurrentTimeReminderConfigToml> { + match features?.current_time_reminder.as_ref()? { + FeatureToml::Enabled(_) => None, + FeatureToml::Config(config) => Some(config), + } +} + fn network_proxy_toml_config(features: Option<&FeaturesToml>) -> Option<&NetworkProxyConfigToml> { match features?.network_proxy.as_ref()? { FeatureToml::Enabled(_) => None, @@ -3217,6 +3273,7 @@ impl Config { let code_mode = resolve_code_mode_config(&cfg); let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg); let rollout_budget = resolve_rollout_budget_config(&cfg, &features)?; + let current_time_reminder = resolve_current_time_reminder_config(&cfg, &features)?; let terminal_resize_reflow = resolve_terminal_resize_reflow_config(&cfg); let agent_roles = @@ -3758,6 +3815,7 @@ impl Config { ghost_snapshot, multi_agent_v2, rollout_budget, + current_time_reminder, features, suppress_unstable_features_warning: cfg .suppress_unstable_features_warning diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index b5f1689ad2..61c39a5dd3 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -2,6 +2,7 @@ use anyhow::Context; use codex_config::config_toml::ConfigLockfileToml; use codex_config::config_toml::ConfigToml; use codex_config::types::MemoriesToml; +use codex_features::CurrentTimeReminderConfigToml; use codex_features::Feature; use codex_features::FeatureToml; use codex_features::FeaturesToml; @@ -155,6 +156,12 @@ fn save_config_resolved_fields( rollout_budget.enabled = Some(config.features.enabled(Feature::RolloutBudget)); features.rollout_budget = Some(FeatureToml::Config(rollout_budget)); } + if let Some(current_time_reminder) = config.current_time_reminder.as_ref() { + let mut current_time_reminder: CurrentTimeReminderConfigToml = + resolved_config_to_toml(current_time_reminder, "features.current_time_reminder")?; + current_time_reminder.enabled = Some(config.features.enabled(Feature::CurrentTimeReminder)); + features.current_time_reminder = Some(FeatureToml::Config(current_time_reminder)); + } lock_config.memories = Some(resolved_config_to_toml::( &config.memories, "memories", @@ -227,6 +234,11 @@ mod tests { .features .enable(Feature::RolloutBudget) .expect("rollout_budget should be enableable in tests"); + config.current_time_reminder = Some(crate::config::CurrentTimeReminderConfig::default()); + config + .features + .enable(Feature::CurrentTimeReminder) + .expect("current_time_reminder should be enableable in tests"); sc.original_config_do_not_use = Arc::new(config); sc.base_instructions = "resolved instructions".to_string(); sc.developer_instructions = Some("resolved developer instructions".to_string()); @@ -302,6 +314,14 @@ mod tests { prefill_token_weight: Some(0.25), })) ); + assert_eq!( + features.current_time_reminder, + Some(FeatureToml::Config(CurrentTimeReminderConfigToml { + enabled: Some(true), + reminder_interval_model_requests: Some(1), + clock_source: Some(codex_features::CurrentTimeSource::System), + })) + ); assert_eq!(lockfile.version, crate::config_lock::CONFIG_LOCK_VERSION); } diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index ad4b6da63c..dc78d8cd43 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -102,6 +102,36 @@ impl FeatureConfig for RolloutBudgetConfigToml { } } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum CurrentTimeSource { + #[default] + System, + External, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CurrentTimeReminderConfigToml { + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1))] + pub reminder_interval_model_requests: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub clock_source: Option, +} + +impl FeatureConfig for CurrentTimeReminderConfigToml { + fn enabled(&self) -> Option { + self.enabled + } + + fn set_enabled(&mut self, enabled: bool) { + self.enabled = Some(enabled); + } +} + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct RemovedAppsMcpPathOverrideConfigToml { diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 731ea20a10..58f291407a 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -17,6 +17,8 @@ use toml::Table; mod feature_configs; mod legacy; pub use feature_configs::CodeModeConfigToml; +pub use feature_configs::CurrentTimeReminderConfigToml; +pub use feature_configs::CurrentTimeSource; pub use feature_configs::MultiAgentV2ConfigToml; pub use feature_configs::NetworkProxyConfigToml; pub use feature_configs::NetworkProxyDomainPermissionToml; @@ -206,6 +208,8 @@ pub enum Feature { TokenBudget, /// Track and report a shared token budget across a session's agent threads. RolloutBudget, + /// Add current-time reminders to model-visible context. + CurrentTimeReminder, /// Expose an input-interruptible sleep tool. SleepTool, /// Route MCP tool approval prompts through the MCP elicitation request path. @@ -623,6 +627,8 @@ pub struct FeaturesToml { pub multi_agent_v2: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub rollout_budget: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_time_reminder: Option>, #[serde(default, rename = "apps_mcp_path_override", skip_serializing)] #[schemars(skip)] removed_apps_mcp_path_override: Option>, @@ -658,6 +664,13 @@ impl FeaturesToml { if let Some(enabled) = self.rollout_budget.as_ref().and_then(FeatureToml::enabled) { entries.insert(Feature::RolloutBudget.key().to_string(), enabled); } + if let Some(enabled) = self + .current_time_reminder + .as_ref() + .and_then(FeatureToml::enabled) + { + entries.insert(Feature::CurrentTimeReminder.key().to_string(), enabled); + } if let Some(enabled) = self.network_proxy.as_ref().and_then(FeatureToml::enabled) { entries.insert(Feature::NetworkProxy.key().to_string(), enabled); } @@ -670,6 +683,7 @@ impl FeaturesToml { code_mode, multi_agent_v2, rollout_budget, + current_time_reminder, removed_apps_mcp_path_override: _, network_proxy, entries, @@ -685,6 +699,8 @@ impl FeaturesToml { materialize_resolved_feature_enabled(multi_agent_v2, enabled); } else if spec.id == Feature::RolloutBudget { materialize_resolved_feature_enabled(rollout_budget, enabled); + } else if spec.id == Feature::CurrentTimeReminder { + materialize_resolved_feature_enabled(current_time_reminder, enabled); } else if spec.id == Feature::NetworkProxy { materialize_resolved_feature_enabled(network_proxy, enabled); } else { @@ -1184,6 +1200,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::CurrentTimeReminder, + key: "current_time_reminder", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::SleepTool, key: "sleep_tool", diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index ca11ff9072..1534ba86a0 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -278,6 +278,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R ghost_snapshot: GhostSnapshotConfig::default(), multi_agent_v2: MultiAgentV2Config::default(), rollout_budget: None, + current_time_reminder: None, features: Default::default(), suppress_unstable_features_warning: false, active_project: ProjectConfig { trust_level: None }, From 32a696dbacaa1383745455ea2a77d5477891ed0b Mon Sep 17 00:00:00 2001 From: rka-oai Date: Thu, 18 Jun 2026 11:52:19 -0700 Subject: [PATCH 038/112] [codex] rollout budget implementation (varlength 2/N) (#28494) ## Stack Depends on #28746. This PR implements shared rollout-budget accounting and model-visible reminders using the configuration defined in #28746. # Description / Main changes to Core: `AgentControl` will now be the area where "rollout level" features & accounting will have to live. It is incorrectly named for this responsibility, but I think it can hold all the necessary shared state & features (rollout token budget, mutliple thread interruption responsibilitym etc) In this PR, we have one "token ledger" that each thread will subtract from when sampling. The "charge" will occur when response.completed() is done and the calculation will be done on the responses api usage carrier. The calculation will weigh sampling and pre-fill tokens as specified. Every time the budget crosses the configured reminder threshold, a developer message is appended before the thread's next request This remaining budget will _always_ be restated/reminded after a compaction event. Expiration and fan-out interruption will be in the stacked follow-up (and also live in Agent Control). ## Reminders "You have weighted {session_tokens_left} tokens left in the shared session token budget." The first request in each thread context receives the current remainder. Later reminders are emitted after aggregate weighted usage crosses a configured interval. If several intervals are crossed before a thread sends another request, Core inserts one reminder with the latest remainder. Compaction response usage is charged before the next context starts. The next reminder is appended after the compaction summary, leaving the initial context content stable. ## Tests Integration coverage verifies: - weighted output and non-cached input accounting - initial and periodic reminders - shared accounting between a root and sub-agent - post-compaction remainder and message placement Local checks: - `just fmt` - `just test -p codex-core rollout_budget` - `git diff --check` The full workspace test suite was not run locally. --- codex-rs/core/src/agent/control.rs | 19 +- codex-rs/core/src/compact_remote_v2.rs | 1 + codex-rs/core/src/context/mod.rs | 2 + codex-rs/core/src/context/rollout_budget.rs | 27 ++ codex-rs/core/src/event_mapping.rs | 1 + codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/rollout_budget.rs | 108 +++++++ codex-rs/core/src/session/handlers.rs | 4 + codex-rs/core/src/session/mod.rs | 2 + codex-rs/core/src/session/rollout_budget.rs | 30 ++ codex-rs/core/src/session/turn.rs | 9 +- codex-rs/core/src/thread_manager.rs | 21 +- codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/rollout_budget.rs | 304 ++++++++++++++++++++ 14 files changed, 521 insertions(+), 9 deletions(-) create mode 100644 codex-rs/core/src/context/rollout_budget.rs create mode 100644 codex-rs/core/src/rollout_budget.rs create mode 100644 codex-rs/core/src/session/rollout_budget.rs create mode 100644 codex-rs/core/tests/suite/rollout_budget.rs diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 01f0e1415d..410c97cbd5 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -6,7 +6,9 @@ use crate::agent::role::resolve_role_config; use crate::agent::status::is_final; use crate::codex_thread::ThreadConfigSnapshot; use crate::config::Config; +use crate::config::RolloutBudgetConfig; use crate::environment_selection::TurnEnvironmentSnapshot; +use crate::rollout_budget::RolloutBudget; use crate::session::emit_subagent_session_started; use crate::session_prefix::format_inter_agent_completion_message; use crate::session_prefix::format_subagent_context_line; @@ -100,15 +102,24 @@ pub(crate) struct AgentControl { state: Arc, v2_residency: Arc, agent_execution_limiter: Arc, + /// Session-scoped state shared by the root thread and every cloned sub-agent control handle. + rollout_budget: Arc, } impl AgentControl { /// Construct a new `AgentControl` that can spawn/message agents via the given manager state. - pub(crate) fn new(manager: Weak) -> Self { - Self { + pub(crate) fn new( + manager: Weak, + rollout_budget: Option, + ) -> Self { + let control = Self { manager, ..Default::default() + }; + if let Some(rollout_budget) = rollout_budget { + control.rollout_budget.configure(rollout_budget); } + control } pub(crate) fn with_session_id(mut self, session_id: SessionId, max_threads: usize) -> Self { @@ -121,6 +132,10 @@ impl AgentControl { self.session_id } + pub(crate) fn rollout_budget(&self) -> &RolloutBudget { + self.rollout_budget.as_ref() + } + /// Send rich user input items to an existing agent thread. pub(crate) async fn send_input( &self, diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 6c3a939024..f9c2ddc710 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -284,6 +284,7 @@ async fn run_remote_compact_task_inner_impl( token_usage, } = compaction_output_result?; if let Some(token_usage) = token_usage { + sess.record_rollout_budget_usage(&token_usage); analytics_details.active_context_tokens_before = Some(token_usage.input_tokens); analytics_details.compaction_summary_tokens = Some(token_usage.output_tokens); analytics_details.cached_input_tokens = Some(token_usage.cached_input_tokens); diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index a1f204e475..7fc1375781 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -24,6 +24,7 @@ mod realtime_end_instructions; mod realtime_start_instructions; mod realtime_start_with_instructions; mod recommended_plugins_instructions; +mod rollout_budget; mod subagent_notification; mod token_budget_context; mod turn_aborted; @@ -64,6 +65,7 @@ pub(crate) use realtime_end_instructions::RealtimeEndInstructions; pub(crate) use realtime_start_instructions::RealtimeStartInstructions; pub(crate) use realtime_start_with_instructions::RealtimeStartWithInstructions; pub(crate) use recommended_plugins_instructions::RecommendedPluginsInstructions; +pub(crate) use rollout_budget::RolloutBudgetContext; pub(crate) use subagent_notification::SubagentNotification; pub(crate) use token_budget_context::TokenBudgetContext; pub(crate) use token_budget_context::TokenBudgetRemainingContext; diff --git a/codex-rs/core/src/context/rollout_budget.rs b/codex-rs/core/src/context/rollout_budget.rs new file mode 100644 index 0000000000..33ed724b8e --- /dev/null +++ b/codex-rs/core/src/context/rollout_budget.rs @@ -0,0 +1,27 @@ +use super::ContextualUserFragment; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RolloutBudgetContext { + pub(crate) remaining_tokens: i64, +} + +impl ContextualUserFragment for RolloutBudgetContext { + fn role(&self) -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("\n", "\n") + } + + fn body(&self) -> String { + format!( + "You have {} weighted tokens left in the shared session token budget.", + self.remaining_tokens + ) + } +} diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 9e96ebff52..73193984b3 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -33,6 +33,7 @@ const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[ SKILLS_INSTRUCTIONS_OPEN_TAG, "", "", + "", ]; pub(crate) fn is_contextual_user_message_content(message: &[ContentItem]) -> bool { diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 0641d7226e..0e193baa1d 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -131,6 +131,7 @@ pub use agents_md::DEFAULT_AGENTS_MD_FILENAME; pub use agents_md::LOCAL_AGENTS_MD_FILENAME; pub use agents_md::LoadedAgentsMd; mod rollout; +mod rollout_budget; pub(crate) mod safety; mod session_rollout_init_error; pub mod shell; diff --git a/codex-rs/core/src/rollout_budget.rs b/codex-rs/core/src/rollout_budget.rs new file mode 100644 index 0000000000..accabac761 --- /dev/null +++ b/codex-rs/core/src/rollout_budget.rs @@ -0,0 +1,108 @@ +use crate::config::RolloutBudgetConfig; +use codex_protocol::ThreadId; +use codex_protocol::protocol::TokenUsage; +use std::collections::HashMap; +use std::sync::Mutex; +use std::sync::MutexGuard; +use std::sync::OnceLock; + +pub(crate) struct RolloutBudgetReminder { + pub(crate) remaining_tokens: i64, + reminder_index: i64, +} + +/// Shared accounting and reminder state for one root-thread session tree. +#[derive(Default)] +pub(crate) struct RolloutBudget { + state: OnceLock>, +} + +struct RolloutBudgetState { + config: RolloutBudgetConfig, + weighted_tokens_used: f64, + /// Last reminder delivered to each thread, so every thread observes crossed thresholds. + deliveries: HashMap, +} + +struct ThreadBudgetDelivery { + window_id: String, + reminder_index: i64, +} + +impl RolloutBudget { + pub(crate) fn configure(&self, config: RolloutBudgetConfig) { + self.state.get_or_init(|| { + Mutex::new(RolloutBudgetState { + config, + weighted_tokens_used: 0.0, + deliveries: HashMap::new(), + }) + }); + } + + pub(crate) fn record_usage(&self, usage: &TokenUsage) { + let Some(mut state) = self.lock() else { + return; + }; + state.weighted_tokens_used += usage.output_tokens.max(0) as f64 + * state.config.sampling_token_weight + + usage.non_cached_input() as f64 * state.config.prefill_token_weight; + } + + pub(crate) fn pending_reminder( + &self, + thread_id: ThreadId, + window_id: &str, + ) -> Option { + let state = self.lock()?; + let reminder_index = (state.weighted_tokens_used + / state.config.reminder_interval_tokens as f64) + .floor() as i64; + if state.deliveries.get(&thread_id).is_some_and(|delivery| { + delivery.window_id.as_str() == window_id && delivery.reminder_index >= reminder_index + }) { + return None; + } + Some(RolloutBudgetReminder { + remaining_tokens: (state.config.limit_tokens as f64 - state.weighted_tokens_used) + .max(0.0) + .floor() as i64, + reminder_index, + }) + } + + pub(crate) fn mark_reminder_delivered( + &self, + thread_id: ThreadId, + window_id: &str, + reminder: RolloutBudgetReminder, + ) { + // Mark delivery only after history insertion; cancellation before then should retry it. + let Some(mut state) = self.lock() else { + return; + }; + state.deliveries.insert( + thread_id, + ThreadBudgetDelivery { + window_id: window_id.to_string(), + reminder_index: reminder.reminder_index, + }, + ); + } + + /// Forces the next sampling request for `thread_id` to restate the current remainder. + pub(crate) fn rearm_reminder(&self, thread_id: ThreadId) { + let Some(mut state) = self.lock() else { + return; + }; + state.deliveries.remove(&thread_id); + } + + fn lock(&self) -> Option> { + self.state.get().map(|state| { + state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + }) + } +} diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 4ed79d04e3..5299747f3d 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -525,6 +525,10 @@ pub async fn thread_rollback(sess: &Arc, sub_id: String, num_turns: u32 .collect::>(); sess.apply_rollout_reconstruction(turn_context.as_ref(), replay_items.as_slice()) .await; + sess.services + .agent_control + .rollout_budget() + .rearm_reminder(sess.thread_id()); sess.recompute_token_usage(turn_context.as_ref()).await; sess.persist_rollout_items(&[RolloutItem::EventMsg(rollback_msg.clone())]) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 84e60a3d2d..5aa56b68f8 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -211,6 +211,7 @@ mod input_queue; mod mcp; mod multi_agents; mod review; +mod rollout_budget; mod rollout_reconstruction; #[allow(clippy::module_inception)] pub(crate) mod session; @@ -3360,6 +3361,7 @@ impl Session { } state.token_info() }; + self.record_rollout_budget_usage(token_usage); if let Some(token_info) = token_info.as_ref() { for contributor in self.services.extensions.token_usage_contributors() { contributor diff --git a/codex-rs/core/src/session/rollout_budget.rs b/codex-rs/core/src/session/rollout_budget.rs new file mode 100644 index 0000000000..4627d95ee7 --- /dev/null +++ b/codex-rs/core/src/session/rollout_budget.rs @@ -0,0 +1,30 @@ +use super::session::Session; +use super::turn_context::TurnContext; +use crate::context::ContextualUserFragment; +use codex_protocol::protocol::TokenUsage; + +pub(super) async fn maybe_record_reminder( + sess: &Session, + turn_context: &TurnContext, + window_id: &str, +) { + let budget = sess.services.agent_control.rollout_budget(); + let Some(reminder) = budget.pending_reminder(sess.thread_id(), window_id) else { + return; + }; + let response_item = ContextualUserFragment::into(crate::context::RolloutBudgetContext { + remaining_tokens: reminder.remaining_tokens, + }); + sess.record_conversation_items(turn_context, std::slice::from_ref(&response_item)) + .await; + budget.mark_reminder_delivered(sess.thread_id(), window_id, reminder); +} + +impl Session { + pub(crate) fn record_rollout_budget_usage(&self, usage: &TokenUsage) { + self.services + .agent_control + .rollout_budget() + .record_usage(usage); + } +} diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 257e741203..657e00ff35 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -218,6 +218,14 @@ pub(crate) async fn run_turn( break; } + let window_id = sess.current_window_id().await; + super::rollout_budget::maybe_record_reminder( + sess.as_ref(), + turn_context.as_ref(), + &window_id, + ) + .await; + // Construct the input that we will send to the model. let sampling_request_input: Vec = async { sess.clone_history() @@ -227,7 +235,6 @@ pub(crate) async fn run_turn( .instrument(trace_span!("run_turn.prepare_sampling_request_input")) .await; - let window_id = sess.current_window_id().await; let responses_metadata = turn_context.turn_metadata_state.to_responses_metadata( sess.installation_id.clone(), window_id, diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 63335c0129..b452dc45ab 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -621,6 +621,7 @@ impl ThreadManager { options: StartThreadOptions, forked_from_thread_id: Option, ) -> CodexResult { + let agent_control = self.agent_control_for_config(&options.config); let (resumed_session_source, resumed_thread_source) = options .initial_history .get_resumed_session_sources() @@ -631,7 +632,7 @@ impl ThreadManager { options.config, options.initial_history, Arc::clone(&self.state.auth_manager), - self.agent_control(), + agent_control, session_source, /*parent_thread_id*/ None, forked_from_thread_id, @@ -710,6 +711,7 @@ impl ThreadManager { auth_manager: Arc, parent_trace: Option, ) -> CodexResult { + let agent_control = self.agent_control_for_config(&config); let environments = default_thread_environment_selections( self.state.environment_manager.as_ref(), &config.cwd, @@ -721,7 +723,7 @@ impl ThreadManager { config, initial_history, auth_manager, - self.agent_control(), + agent_control, session_source, /*parent_thread_id*/ None, /*forked_from_thread_id*/ None, @@ -743,6 +745,7 @@ impl ThreadManager { config: Config, user_shell_override: crate::shell::Shell, ) -> CodexResult { + let agent_control = self.agent_control_for_config(&config); let environments = default_thread_environment_selections( self.state.environment_manager.as_ref(), &config.cwd, @@ -751,7 +754,7 @@ impl ThreadManager { config, InitialHistory::New, Arc::clone(&self.state.auth_manager), - self.agent_control(), + agent_control, /*parent_thread_id*/ None, /*forked_from_thread_id*/ None, /*thread_source*/ None, @@ -772,6 +775,7 @@ impl ThreadManager { auth_manager: Arc, user_shell_override: crate::shell::Shell, ) -> CodexResult { + let agent_control = self.agent_control_for_config(&config); let initial_history = self.initial_history_from_rollout_path(rollout_path).await?; let environments = default_thread_environment_selections( self.state.environment_manager.as_ref(), @@ -784,7 +788,7 @@ impl ThreadManager { config, initial_history, auth_manager, - self.agent_control(), + agent_control, session_source, /*parent_thread_id*/ None, /*forked_from_thread_id*/ None, @@ -952,11 +956,12 @@ impl ThreadManager { self.state.environment_manager.as_ref(), &config.cwd, ); + let agent_control = self.agent_control_for_config(&config); Box::pin(self.state.spawn_thread( config, history, Arc::clone(&self.state.auth_manager), - self.agent_control(), + agent_control, /*parent_thread_id*/ None, forked_from_thread_id, thread_source, @@ -971,7 +976,11 @@ impl ThreadManager { } pub(crate) fn agent_control(&self) -> AgentControl { - AgentControl::new(Arc::downgrade(&self.state)) + AgentControl::new(Arc::downgrade(&self.state), /*rollout_budget*/ None) + } + + fn agent_control_for_config(&self, config: &Config) -> AgentControl { + AgentControl::new(Arc::downgrade(&self.state), config.rollout_budget) } #[cfg(test)] diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 275458ffdc..0fdd958b39 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -100,6 +100,7 @@ mod resume; mod resume_warning; mod review; mod rmcp_client; +mod rollout_budget; mod rollout_list_find; mod safety_check_downgrade; mod search_tool; diff --git a/codex-rs/core/tests/suite/rollout_budget.rs b/codex-rs/core/tests/suite/rollout_budget.rs new file mode 100644 index 0000000000..c2a044eb52 --- /dev/null +++ b/codex-rs/core/tests/suite/rollout_budget.rs @@ -0,0 +1,304 @@ +use anyhow::Result; +use codex_core::config::RolloutBudgetConfig; +use codex_features::Feature; +use codex_model_provider_info::built_in_model_providers; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use core_test_support::responses::ResponsesRequest; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_completed_with_tokens; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once_match; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::time::Duration; +use tokio::time::timeout; + +const ROLLOUT_BUDGET: RolloutBudgetConfig = RolloutBudgetConfig { + limit_tokens: 100, + reminder_interval_tokens: 25, + sampling_token_weight: 1.0, + prefill_token_weight: 1.0, +}; + +fn rollout_budget_texts(request: &ResponsesRequest) -> Vec { + request + .message_input_texts("developer") + .into_iter() + .filter(|text| text.starts_with("")) + .collect() +} + +fn rollout_budget_message(remaining_tokens: i64) -> String { + format!( + "\nYou have {remaining_tokens} weighted tokens left in the shared session token budget.\n" + ) +} + +fn wire_request_contains(request: &wiremock::Request, text: &str) -> bool { + std::str::from_utf8(&request.body).is_ok_and(|body| body.contains(text)) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn adds_weighted_initial_and_periodic_reminders() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + json!({ + "type": "response.completed", + "response": { + "id": "resp-1", + "usage": { + "input_tokens": 60, + "input_tokens_details": { "cached_tokens": 40 }, + "output_tokens": 15, + "output_tokens_details": null, + "total_tokens": 75 + } + } + }), + ]), + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ], + ) + .await; + let test = test_codex() + .with_config(|config| { + config.rollout_budget = Some(RolloutBudgetConfig { + sampling_token_weight: 2.0, + prefill_token_weight: 0.5, + ..ROLLOUT_BUDGET + }); + }) + .build(&server) + .await?; + + test.submit_turn("first turn").await?; + test.submit_turn("second turn").await?; + + let requests = responses.requests(); + assert_eq!( + rollout_budget_texts(&requests[0]), + vec![rollout_budget_message(/*remaining_tokens*/ 100)] + ); + assert_eq!( + rollout_budget_texts(&requests[1]), + vec![ + rollout_budget_message(/*remaining_tokens*/ 100), + rollout_budget_message(/*remaining_tokens*/ 60), + ] + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn subagent_usage_draws_from_the_shared_budget() -> Result<()> { + skip_if_no_network!(Ok(())); + + const ROOT_PROMPT: &str = "spawn a budget worker"; + const CHILD_PROMPT: &str = "consume child budget"; + const FOLLOW_UP_PROMPT: &str = "report the shared budget"; + const SPAWN_CALL_ID: &str = "spawn-budget-worker"; + + let server = start_mock_server().await; + let spawn_args = json!({ + "message": CHILD_PROMPT, + "task_name": "budget_worker", + }) + .to_string(); + mount_sse_once_match( + &server, + |request: &wiremock::Request| wire_request_contains(request, ROOT_PROMPT), + sse(vec![ + ev_response_created("root-1"), + ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + ev_completed_with_tokens("root-1", /*total_tokens*/ 10), + ]), + ) + .await; + mount_sse_once_match( + &server, + |request: &wiremock::Request| wire_request_contains(request, "\"type\":\"agent_message\""), + sse(vec![ + ev_response_created("child-1"), + ev_completed_with_tokens("child-1", /*total_tokens*/ 30), + ]), + ) + .await; + mount_sse_once_match( + &server, + |request: &wiremock::Request| { + wire_request_contains(request, SPAWN_CALL_ID) + && !wire_request_contains(request, "\"type\":\"agent_message\"") + }, + sse(vec![ + ev_response_created("root-2"), + ev_completed_with_tokens("root-2", /*total_tokens*/ 10), + ]), + ) + .await; + let follow_up = mount_sse_once_match( + &server, + |request: &wiremock::Request| wire_request_contains(request, FOLLOW_UP_PROMPT), + sse(vec![ev_response_created("root-3"), ev_completed("root-3")]), + ) + .await; + + let test = test_codex() + .with_config(|config| { + config + .features + .enable(Feature::Collab) + .expect("test config should allow multi-agent tools"); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow multi-agent v2"); + config.rollout_budget = Some(ROLLOUT_BUDGET); + }) + .build(&server) + .await?; + + let mut created_threads = test.thread_manager.subscribe_thread_created(); + test.submit_turn(ROOT_PROMPT).await?; + let child_thread_id = timeout(Duration::from_secs(10), created_threads.recv()).await??; + let child_thread = test.thread_manager.get_thread(child_thread_id).await?; + wait_for_event(child_thread.as_ref(), |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + test.submit_turn(FOLLOW_UP_PROMPT).await?; + + let request = follow_up.single_request(); + assert_eq!( + rollout_budget_texts(&request).last(), + Some(&rollout_budget_message(/*remaining_tokens*/ 50)) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn restates_the_current_remainder_after_compaction() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_completed_with_tokens("resp-1", /*total_tokens*/ 20), + ]), + sse(vec![ + ev_response_created("resp-compact"), + ev_assistant_message("msg-compact", "compact summary"), + ev_completed_with_tokens("resp-compact", /*total_tokens*/ 10), + ]), + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ], + ) + .await; + let mut model_provider = built_in_model_providers(/*openai_base_url*/ None)["openai"].clone(); + model_provider.name = "OpenAI-compatible test provider".to_string(); + model_provider.base_url = Some(format!("{}/v1", server.uri())); + model_provider.supports_websockets = false; + let test = test_codex() + .with_config(move |config| { + config.model_provider = model_provider; + config.rollout_budget = Some(RolloutBudgetConfig { + reminder_interval_tokens: 50, + ..ROLLOUT_BUDGET + }); + }) + .build(&server) + .await?; + + test.submit_turn("first turn").await?; + test.codex.submit(Op::Compact).await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + test.submit_turn("second turn").await?; + + let requests = responses.requests(); + assert_eq!( + rollout_budget_texts(&requests[2]), + vec![rollout_budget_message(/*remaining_tokens*/ 70)], + "a new context window should restate the current remainder" + ); + let request_body = requests[2].body_json().to_string(); + let summary_position = request_body + .find("compact summary") + .expect("post-compaction request should contain the summary"); + let reminder_position = request_body + .find("You have 70 weighted tokens left in the shared session token budget.") + .expect("post-compaction request should contain the current remainder"); + assert!( + summary_position < reminder_position, + "the current remainder should follow the compaction summary" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn restates_the_current_remainder_after_rollback() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_completed_with_tokens("resp-1", /*total_tokens*/ 30), + ]), + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ], + ) + .await; + let test = test_codex() + .with_config(|config| { + config.rollout_budget = Some(RolloutBudgetConfig { + reminder_interval_tokens: 50, + ..ROLLOUT_BUDGET + }); + }) + .build(&server) + .await?; + + test.submit_turn("rolled-back turn").await?; + test.codex + .submit(Op::ThreadRollback { num_turns: 1 }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::ThreadRolledBack(_)) + }) + .await; + test.submit_turn("turn after rollback").await?; + + let requests = responses.requests(); + assert_eq!( + rollout_budget_texts(&requests[1]), + vec![rollout_budget_message(/*remaining_tokens*/ 70)], + "rollback should rearm the current budget reminder without refunding usage" + ); + + Ok(()) +} From 21a599fa56472a7cea8132c5a47a4374d4d5aa17 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Thu, 18 Jun 2026 11:54:49 -0700 Subject: [PATCH 039/112] Support `openai/form` extended form elicitations (#27500) # Summary Allow App Server clients to opt into `openai/form` MCP elicitations. --- .../analytics/src/analytics_client_tests.rs | 4 + codex-rs/app-server-client/src/lib.rs | 32 +- codex-rs/app-server-client/src/remote.rs | 4 +- .../schema/json/ClientRequest.json | 4 + .../McpServerElicitationRequestParams.json | 21 + .../schema/json/ServerRequest.json | 21 + .../codex_app_server_protocol.schemas.json | 25 + .../codex_app_server_protocol.v2.schemas.json | 4 + .../schema/json/v1/InitializeParams.json | 4 + .../typescript/InitializeCapabilities.ts | 4 + .../v2/McpServerElicitationRequestParams.ts | 2 +- .../src/protocol/common.rs | 8 +- .../app-server-protocol/src/protocol/v1.rs | 3 + .../src/protocol/v2/mcp.rs | 18 + .../src/protocol/v2/tests.rs | 34 + codex-rs/app-server-test-client/src/lib.rs | 1 + codex-rs/app-server/README.md | 12 + codex-rs/app-server/src/message_processor.rs | 17 + .../initialize_processor.rs | 19 +- .../request_processors/thread_processor.rs | 14 + .../src/request_processors/turn_processor.rs | 12 + .../app-server/tests/suite/v2/attestation.rs | 1 + .../suite/v2/connection_handling_websocket.rs | 2 +- .../tests/suite/v2/experimental_api.rs | 8 + .../app-server/tests/suite/v2/initialize.rs | 1 + .../tests/suite/v2/mcp_server_elicitation.rs | 813 +++++++++++++----- .../tests/suite/v2/thread_status.rs | 1 + codex-rs/codex-mcp/src/connection_manager.rs | 2 + .../codex-mcp/src/connection_manager_tests.rs | 41 +- codex-rs/codex-mcp/src/elicitation.rs | 53 +- codex-rs/codex-mcp/src/mcp/mod.rs | 2 + codex-rs/codex-mcp/src/rmcp_client.rs | 58 +- codex-rs/core/src/codex_delegate.rs | 4 + codex-rs/core/src/codex_thread.rs | 7 + codex-rs/core/src/connectors.rs | 1 + codex-rs/core/src/mcp_tool_call_tests.rs | 1 + codex-rs/core/src/session/mcp.rs | 63 +- codex-rs/core/src/session/mcp_tests.rs | 48 +- codex-rs/core/src/session/mod.rs | 3 + codex-rs/core/src/session/session.rs | 7 + codex-rs/core/src/session/tests.rs | 5 + .../core/src/session/tests/guardian_tests.rs | 1 + codex-rs/core/src/state/service.rs | 2 + codex-rs/core/src/test_support.rs | 9 +- codex-rs/core/src/thread_manager.rs | 33 +- codex-rs/core/src/thread_manager_tests.rs | 14 + .../src/tools/handlers/multi_agents_tests.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 31 +- codex-rs/core/tests/suite/agents_md.rs | 2 + .../core/tests/suite/compact_resume_fork.rs | 1 + codex-rs/core/tests/suite/fork_thread.rs | 1 + .../core/tests/suite/realtime_conversation.rs | 1 + codex-rs/core/tests/suite/resume_warning.rs | 1 + codex-rs/core/tests/suite/rmcp_client.rs | 119 ++- .../tests/suite/subagent_notifications.rs | 1 + .../tests/suite/unstable_features_warning.rs | 2 + codex-rs/exec/src/lib.rs | 1 + codex-rs/memories/write/src/runtime.rs | 1 + codex-rs/protocol/src/approvals.rs | 13 +- .../rmcp-client/src/bin/test_stdio_server.rs | 47 + .../src/elicitation_client_service.rs | 115 ++- .../rmcp-client/src/logging_client_handler.rs | 3 +- codex-rs/rmcp-client/src/rmcp_client.rs | 20 +- codex-rs/tui/src/app/thread_routing.rs | 5 +- codex-rs/tui/src/chatwidget/tool_requests.rs | 3 +- codex-rs/tui/src/lib.rs | 2 + codex-rs/tui/src/onboarding/auth.rs | 1 + 67 files changed, 1486 insertions(+), 328 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 55467c06b6..fcdd97af8e 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -774,6 +774,7 @@ fn sample_initialize_fact(connection_id: u64) -> AnalyticsFact { experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_openai_form_elicitation: false, }), }, product_client_id: DEFAULT_ORIGINATOR.to_string(), @@ -1669,6 +1670,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_openai_form_elicitation: false, }), }, product_client_id: DEFAULT_ORIGINATOR.to_string(), @@ -1818,6 +1820,7 @@ async fn compaction_event_ingests_custom_fact() { experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_openai_form_elicitation: false, }), }, product_client_id: DEFAULT_ORIGINATOR.to_string(), @@ -1946,6 +1949,7 @@ async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() { experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_openai_form_elicitation: false, }), }, product_client_id: DEFAULT_ORIGINATOR.to_string(), diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index bfcf5522d8..b7a958eff1 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -350,6 +350,8 @@ pub struct InProcessClientStartArgs { pub client_version: String, /// Whether experimental APIs are requested at initialize time. pub experimental_api: bool, + /// Whether MCP servers may send `openai/form` elicitation requests. + pub mcp_server_openai_form_elicitation: bool, /// Notification methods this client opts out of receiving. pub opt_out_notification_methods: Vec, /// Queue capacity for command/event channels (clamped to at least 1). @@ -374,6 +376,7 @@ impl InProcessClientStartArgs { } else { Some(self.opt_out_notification_methods.clone()) }, + mcp_server_openai_form_elicitation: self.mcp_server_openai_form_elicitation, }; InitializeParams { @@ -1044,6 +1047,7 @@ mod tests { client_name: "codex-app-server-client-test".to_string(), client_version: "0.0.0-test".to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: false, opt_out_notification_methods: Vec::new(), channel_capacity, }) @@ -1237,11 +1241,25 @@ mod tests { client_name: "codex-app-server-client-test".to_string(), client_version: "0.0.0-test".to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: false, opt_out_notification_methods: Vec::new(), channel_capacity: 8, } } + #[test] + fn remote_initialize_params_forward_openai_form_capability() { + let mut args = test_remote_connect_args("ws://localhost/rpc".to_string()); + args.mcp_server_openai_form_elicitation = true; + + assert!( + args.initialize_params() + .capabilities + .expect("initialize capabilities") + .mcp_server_openai_form_elicitation + ); + } + #[tokio::test] async fn typed_request_roundtrip_works() { let client = start_test_client(SessionSource::Exec).await; @@ -1512,6 +1530,7 @@ mod tests { client_name: "codex-app-server-client-test".to_string(), client_version: "0.0.0-test".to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: false, opt_out_notification_methods: Vec::new(), channel_capacity: 8, }) @@ -1600,6 +1619,7 @@ mod tests { client_name: "codex-app-server-client-test".to_string(), client_version: "0.0.0-test".to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: false, opt_out_notification_methods: Vec::new(), channel_capacity: 8, }) @@ -1619,6 +1639,7 @@ mod tests { client_name: "codex-app-server-client-test".to_string(), client_version: "0.0.0-test".to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: false, opt_out_notification_methods: Vec::new(), channel_capacity: 8, }) @@ -2189,7 +2210,7 @@ mod tests { } #[tokio::test] - async fn runtime_start_args_forward_environment_manager() { + async fn runtime_start_args_forward_environment_manager_and_openai_form_capability() { let config = Arc::new(build_test_config().await); let environment_manager = Arc::new( EnvironmentManager::create_for_tests( @@ -2222,12 +2243,20 @@ mod tests { client_name: "codex-app-server-client-test".to_string(), client_version: "0.0.0-test".to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: true, opt_out_notification_methods: Vec::new(), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, } .into_runtime_start_args(); assert_eq!(runtime_args.config, config); + assert!( + runtime_args + .initialize + .capabilities + .expect("initialize capabilities") + .mcp_server_openai_form_elicitation + ); assert!(Arc::ptr_eq( &runtime_args.environment_manager, &environment_manager @@ -2263,6 +2292,7 @@ mod tests { client_name: "codex-app-server-client-test".to_string(), client_version: "0.0.0-test".to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: false, opt_out_notification_methods: Vec::new(), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, } diff --git a/codex-rs/app-server-client/src/remote.rs b/codex-rs/app-server-client/src/remote.rs index e1c9f16c4a..5575fe4ad4 100644 --- a/codex-rs/app-server-client/src/remote.rs +++ b/codex-rs/app-server-client/src/remote.rs @@ -86,11 +86,12 @@ pub struct RemoteAppServerConnectArgs { pub client_name: String, pub client_version: String, pub experimental_api: bool, + pub mcp_server_openai_form_elicitation: bool, pub opt_out_notification_methods: Vec, pub channel_capacity: usize, } impl RemoteAppServerConnectArgs { - fn initialize_params(&self) -> InitializeParams { + pub(crate) fn initialize_params(&self) -> InitializeParams { let capabilities = InitializeCapabilities { experimental_api: self.experimental_api, request_attestation: false, @@ -99,6 +100,7 @@ impl RemoteAppServerConnectArgs { } else { Some(self.opt_out_notification_methods.clone()) }, + mcp_server_openai_form_elicitation: self.mcp_server_openai_form_elicitation, }; InitializeParams { diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 5ebb861de0..e183c88b08 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1272,6 +1272,10 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, + "mcpServerOpenaiFormElicitation": { + "description": "Allow downstream MCP servers to request OpenAI extended form elicitations.", + "type": "boolean" + }, "optOutNotificationMethods": { "description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).", "items": { diff --git a/codex-rs/app-server-protocol/schema/json/McpServerElicitationRequestParams.json b/codex-rs/app-server-protocol/schema/json/McpServerElicitationRequestParams.json index aa7fa817ae..3fc6971330 100644 --- a/codex-rs/app-server-protocol/schema/json/McpServerElicitationRequestParams.json +++ b/codex-rs/app-server-protocol/schema/json/McpServerElicitationRequestParams.json @@ -557,6 +557,27 @@ ], "type": "object" }, + { + "properties": { + "_meta": true, + "message": { + "type": "string" + }, + "mode": { + "enum": [ + "openai/form" + ], + "type": "string" + }, + "requestedSchema": true + }, + "required": [ + "message", + "mode", + "requestedSchema" + ], + "type": "object" + }, { "properties": { "_meta": true, diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 31ca31de2c..c6ac80dee5 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -1390,6 +1390,27 @@ ], "type": "object" }, + { + "properties": { + "_meta": true, + "message": { + "type": "string" + }, + "mode": { + "enum": [ + "openai/form" + ], + "type": "string" + }, + "requestedSchema": true + }, + "required": [ + "message", + "mode", + "requestedSchema" + ], + "type": "object" + }, { "properties": { "_meta": true, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index c75ae416fa..8ef80b7b54 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -2900,6 +2900,10 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, + "mcpServerOpenaiFormElicitation": { + "description": "Allow downstream MCP servers to request OpenAI extended form elicitations.", + "type": "boolean" + }, "optOutNotificationMethods": { "description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).", "items": { @@ -3655,6 +3659,27 @@ ], "type": "object" }, + { + "properties": { + "_meta": true, + "message": { + "type": "string" + }, + "mode": { + "enum": [ + "openai/form" + ], + "type": "string" + }, + "requestedSchema": true + }, + "required": [ + "message", + "mode", + "requestedSchema" + ], + "type": "object" + }, { "properties": { "_meta": true, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index a4a0267dc5..00d7a4bbdf 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -7440,6 +7440,10 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, + "mcpServerOpenaiFormElicitation": { + "description": "Allow downstream MCP servers to request OpenAI extended form elicitations.", + "type": "boolean" + }, "optOutNotificationMethods": { "description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).", "items": { diff --git a/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json b/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json index af5c509249..75f0860ddd 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json @@ -30,6 +30,10 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, + "mcpServerOpenaiFormElicitation": { + "description": "Allow downstream MCP servers to request OpenAI extended form elicitations.", + "type": "boolean" + }, "optOutNotificationMethods": { "description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).", "items": { diff --git a/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts b/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts index c5043e3b64..dcc4dffb0b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts +++ b/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts @@ -14,6 +14,10 @@ experimentalApi: boolean, * Opt into `attestation/generate` requests for upstream `x-oai-attestation`. */ requestAttestation: boolean, +/** + * Allow downstream MCP servers to request OpenAI extended form elicitations. + */ +mcpServerOpenaiFormElicitation?: boolean, /** * Exact notification method names that should be suppressed for this * connection (for example `thread/started`). diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestParams.ts index 90d60f77c7..a4f1e732c6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerElicitationRequestParams.ts @@ -13,4 +13,4 @@ export type McpServerElicitationRequestParams = { threadId: string, * context is app-server correlation rather than part of the protocol identity of the * elicitation itself. */ -turnId: string | null, serverName: string, } & ({ "mode": "form", _meta: JsonValue | null, message: string, requestedSchema: McpElicitationSchema, } | { "mode": "url", _meta: JsonValue | null, message: string, url: string, elicitationId: string, }); +turnId: string | null, serverName: string, } & ({ "mode": "form", _meta: JsonValue | null, message: string, requestedSchema: McpElicitationSchema, } | { "mode": "openai/form", _meta: JsonValue | null, message: string, requestedSchema: JsonValue, } | { "mode": "url", _meta: JsonValue | null, message: string, url: string, elicitationId: string, }); diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index edeabfc7ce..c1fac138a8 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2150,7 +2150,7 @@ mod tests { } #[test] - fn serialize_initialize_with_opt_out_notification_methods() -> Result<()> { + fn serialize_initialize_capabilities() -> Result<()> { let request = ClientRequest::Initialize { request_id: RequestId::Integer(42), params: v1::InitializeParams { @@ -2162,6 +2162,7 @@ mod tests { capabilities: Some(v1::InitializeCapabilities { experimental_api: true, request_attestation: true, + mcp_server_openai_form_elicitation: true, opt_out_notification_methods: Some(vec![ "thread/started".to_string(), "item/agentMessage/delta".to_string(), @@ -2183,6 +2184,7 @@ mod tests { "capabilities": { "experimentalApi": true, "requestAttestation": true, + "mcpServerOpenaiFormElicitation": true, "optOutNotificationMethods": [ "thread/started", "item/agentMessage/delta" @@ -2196,7 +2198,7 @@ mod tests { } #[test] - fn deserialize_initialize_with_opt_out_notification_methods() -> Result<()> { + fn deserialize_initialize_capabilities() -> Result<()> { let request: ClientRequest = serde_json::from_value(json!({ "method": "initialize", "id": 42, @@ -2209,6 +2211,7 @@ mod tests { "capabilities": { "experimentalApi": true, "requestAttestation": true, + "mcpServerOpenaiFormElicitation": true, "optOutNotificationMethods": [ "thread/started", "item/agentMessage/delta" @@ -2230,6 +2233,7 @@ mod tests { capabilities: Some(v1::InitializeCapabilities { experimental_api: true, request_attestation: true, + mcp_server_openai_form_elicitation: true, opt_out_notification_methods: Some(vec![ "thread/started".to_string(), "item/agentMessage/delta".to_string(), diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index f83674d4c3..dccea51a36 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -50,6 +50,9 @@ pub struct InitializeCapabilities { /// Opt into `attestation/generate` requests for upstream `x-oai-attestation`. #[serde(default)] pub request_attestation: bool, + /// Allow downstream MCP servers to request OpenAI extended form elicitations. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub mcp_server_openai_form_elicitation: bool, /// Exact notification method names that should be suppressed for this /// connection (for example `thread/started`). #[ts(optional = nullable)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs index 37197a2237..cdfeb524f5 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs @@ -632,6 +632,15 @@ pub enum McpServerElicitationRequest { message: String, requested_schema: McpElicitationSchema, }, + #[serde(rename = "openai/form", rename_all = "camelCase")] + #[ts(rename = "openai/form", rename_all = "camelCase")] + OpenAiForm { + #[serde(rename = "_meta")] + #[ts(rename = "_meta")] + meta: Option, + message: String, + requested_schema: JsonValue, + }, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] Url { @@ -658,6 +667,15 @@ impl TryFrom for McpServerElicitationRequest { message, requested_schema: serde_json::from_value(requested_schema)?, }), + CoreElicitationRequest::OpenAiForm { + meta, + message, + requested_schema, + } => Ok(Self::OpenAiForm { + meta, + message, + requested_schema, + }), CoreElicitationRequest::Url { meta, message, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index b973b8b4ae..bb66aa632e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -1901,6 +1901,40 @@ fn mcp_server_elicitation_request_from_core_form_request() { ); } +#[test] +fn mcp_server_elicitation_request_from_core_openai_form_request() { + let requested_schema = json!({ + "type": "object", + "properties": { + "template": { + "type": "openai/imagePicker", + "title": "Template", + "items": [{ + "id": "monthly-review", + "title": "Monthly review", + "image": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=", + }], + }, + }, + "required": ["template"], + }); + let request = McpServerElicitationRequest::try_from(CoreElicitationRequest::OpenAiForm { + meta: None, + message: "Choose a report".to_string(), + requested_schema: requested_schema.clone(), + }) + .expect("OpenAI form request should convert"); + + assert_eq!( + request, + McpServerElicitationRequest::OpenAiForm { + meta: None, + message: "Choose a report".to_string(), + requested_schema, + } + ); +} + #[test] fn mcp_elicitation_schema_matches_mcp_2025_11_25_primitives() { let schema: McpElicitationSchema = serde_json::from_value(json!({ diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 0be6e68bb2..856d75585e 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -1668,6 +1668,7 @@ impl CodexClient { .map(|method| (*method).to_string()) .collect(), ), + mcp_server_openai_form_elicitation: false, }), }, }; diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index a6c5ddab7d..183c7c6007 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -86,6 +86,13 @@ Clients must send a single `initialize` request per transport connection before `initialize.params.capabilities` also supports per-connection notification opt-out via `optOutNotificationMethods`, which is a list of exact method names to suppress for that connection. Matching is exact (no wildcards/prefixes). Unknown method names are accepted and ignored. +Clients that handle OpenAI extended MCP forms, including a fallback for +unsupported field types, set +`initialize.params.capabilities.mcpServerOpenaiFormElicitation` to `true`. +App-server then advertises the downstream `openai/form` MCP extension for +threads started, resumed, or forked by that connection. Clients that cannot +handle the request envelope omit the field or set it to `false`. + Applications building on top of `codex app-server` should identify themselves via the `clientInfo` parameter. **Important**: `clientInfo.name` is used to identify the client for the OpenAI Compliance Logs Platform. If @@ -1470,12 +1477,17 @@ Order of messages: 1. `mcpServer/elicitation/request` (request) — includes `threadId`, nullable `turnId`, `serverName`, and either: - a form request: `{ "mode": "form", "message": "...", "requestedSchema": { ... } }` + - an OpenAI extended form request: `{ "mode": "openai/form", "message": "...", "requestedSchema": { ... } }` - a URL request: `{ "mode": "url", "message": "...", "url": "...", "elicitationId": "..." }` 2. Client response — `{ "action": "accept", "content": ... }`, `{ "action": "decline", "content": null }`, or `{ "action": "cancel", "content": null }`. 3. `serverRequest/resolved` — `{ threadId, requestId }` confirms the pending request has been resolved or cleared, including lifecycle cleanup on turn start/complete/interrupt. `turnId` is best-effort. When the elicitation is correlated with an active turn, the request includes that turn id; otherwise it is `null`. +For `openai/form`, app-server forwards `requestedSchema` as opaque JSON. The +client owns validation and rendering of supported field types and must return a +valid `decline` or `cancel` response when it cannot render a form. + For MCP tool approval elicitations, form request `meta` includes `codex_approval_kind: "mcp_tool_call"` and may include `persist: "session"`, `persist: "always"`, or `persist: ["session", "always"]` to advertise whether diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 3544592b49..9e9437bd34 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -223,6 +223,7 @@ pub(crate) struct InitializedConnectionSessionState { pub(crate) app_server_client_name: String, pub(crate) client_version: String, pub(crate) request_attestation: bool, + pub(crate) supports_openai_form_elicitation: bool, } impl Default for ConnectionSessionState { @@ -274,6 +275,12 @@ impl ConnectionSessionState { .is_some_and(|session| session.request_attestation) } + pub(crate) fn supports_openai_form_elicitation(&self) -> bool { + self.initialized + .get() + .is_some_and(|session| session.supports_openai_form_elicitation) + } + pub(crate) fn initialize(&self, session: InitializedConnectionSessionState) -> Result<(), ()> { self.initialized.set(session).map_err(|_| ()) } @@ -884,6 +891,7 @@ impl MessageProcessor { let serialization_scope = codex_request.serialization_scope(); let app_server_client_name = session.app_server_client_name().map(str::to_string); let client_version = session.client_version().map(str::to_string); + let supports_openai_form_elicitation = session.supports_openai_form_elicitation(); let error_request_id = connection_request_id.clone(); let rpc_gate = Arc::clone(&session.rpc_gate); let processor = Arc::clone(self); @@ -899,6 +907,7 @@ impl MessageProcessor { request_context, app_server_client_name, client_version, + supports_openai_form_elicitation, ) .await; if let Err(error) = result { @@ -928,6 +937,7 @@ impl MessageProcessor { request_context: RequestContext, app_server_client_name: Option, client_version: Option, + supports_openai_form_elicitation: bool, ) -> Result<(), JSONRPCErrorError> { let connection_id = connection_request_id.connection_id; let request_id = ConnectionRequestId { @@ -1080,6 +1090,7 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), + supports_openai_form_elicitation, request_context, ) .await @@ -1096,6 +1107,8 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), + /*supports_openai_form_elicitation*/ + supports_openai_form_elicitation, ) .await } @@ -1106,6 +1119,8 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), + /*supports_openai_form_elicitation*/ + supports_openai_form_elicitation, ) .await } @@ -1305,6 +1320,8 @@ impl MessageProcessor { params, app_server_client_name.clone(), client_version.clone(), + /*supports_openai_form_elicitation*/ + supports_openai_form_elicitation, ) .await } diff --git a/codex-rs/app-server/src/request_processors/initialize_processor.rs b/codex-rs/app-server/src/request_processors/initialize_processor.rs index a40007db11..cfdad27f50 100644 --- a/codex-rs/app-server/src/request_processors/initialize_processor.rs +++ b/codex-rs/app-server/src/request_processors/initialize_processor.rs @@ -67,17 +67,13 @@ impl InitializeRequestProcessor { // experimental API). Proposed direction is instance-global first-write-wins // with initialize-time mismatch rejection. let analytics_initialize_params = params.clone(); - let (experimental_api_enabled, request_attestation, opt_out_notification_methods) = - match params.capabilities { - Some(capabilities) => ( - capabilities.experimental_api, - capabilities.request_attestation, - capabilities - .opt_out_notification_methods - .unwrap_or_default(), - ), - None => (false, false, Vec::new()), - }; + let capabilities = params.capabilities.unwrap_or_default(); + let experimental_api_enabled = capabilities.experimental_api; + let request_attestation = capabilities.request_attestation; + let supports_openai_form_elicitation = capabilities.mcp_server_openai_form_elicitation; + let opt_out_notification_methods = capabilities + .opt_out_notification_methods + .unwrap_or_default(); let ClientInfo { name, title: _title, @@ -101,6 +97,7 @@ impl InitializeRequestProcessor { app_server_client_name: name.clone(), client_version: version, request_attestation, + supports_openai_form_elicitation, }) .is_err() { diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 91005d6f71..63883cbdfe 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -417,6 +417,7 @@ impl ThreadRequestProcessor { params: ThreadStartParams, app_server_client_name: Option, app_server_client_version: Option, + supports_openai_form_elicitation: bool, request_context: RequestContext, ) -> Result, JSONRPCErrorError> { self.thread_start_inner( @@ -424,6 +425,7 @@ impl ThreadRequestProcessor { params, app_server_client_name, app_server_client_version, + supports_openai_form_elicitation, request_context, ) .await @@ -446,12 +448,14 @@ impl ThreadRequestProcessor { params: ThreadResumeParams, app_server_client_name: Option, app_server_client_version: Option, + supports_openai_form_elicitation: bool, ) -> Result, JSONRPCErrorError> { self.thread_resume_inner( request_id, params, app_server_client_name, app_server_client_version, + supports_openai_form_elicitation, ) .await .map(|()| None) @@ -463,12 +467,14 @@ impl ThreadRequestProcessor { params: ThreadForkParams, app_server_client_name: Option, app_server_client_version: Option, + supports_openai_form_elicitation: bool, ) -> Result, JSONRPCErrorError> { self.thread_fork_inner( request_id, params, app_server_client_name, app_server_client_version, + supports_openai_form_elicitation, ) .await .map(|()| None) @@ -874,6 +880,7 @@ impl ThreadRequestProcessor { params: ThreadStartParams, app_server_client_name: Option, app_server_client_version: Option, + supports_openai_form_elicitation: bool, request_context: RequestContext, ) -> Result<(), JSONRPCErrorError> { let ThreadStartParams { @@ -945,6 +952,7 @@ impl ThreadRequestProcessor { request_id, app_server_client_name, app_server_client_version, + supports_openai_form_elicitation, config, typesafe_overrides, dynamic_tools, @@ -1018,6 +1026,7 @@ impl ThreadRequestProcessor { request_id: ConnectionRequestId, app_server_client_name: Option, app_server_client_version: Option, + supports_openai_form_elicitation: bool, config_overrides: Option>, typesafe_overrides: ConfigOverrides, dynamic_tools: Option>, @@ -1146,6 +1155,7 @@ impl ThreadRequestProcessor { parent_trace: request_trace, environments, thread_extension_init, + supports_openai_form_elicitation, }) .instrument(tracing::info_span!( "app_server.thread_start.create_thread", @@ -2506,6 +2516,7 @@ impl ThreadRequestProcessor { params: ThreadResumeParams, app_server_client_name: Option, app_server_client_version: Option, + supports_openai_form_elicitation: bool, ) -> Result<(), JSONRPCErrorError> { if let Ok(thread_id) = ThreadId::from_string(¶ms.thread_id) && self @@ -2650,6 +2661,7 @@ impl ThreadRequestProcessor { thread_history, self.auth_manager.clone(), self.request_trace_context(&request_id).await, + supports_openai_form_elicitation, ) .await { @@ -3267,6 +3279,7 @@ impl ThreadRequestProcessor { params: ThreadForkParams, app_server_client_name: Option, app_server_client_version: Option, + supports_openai_form_elicitation: bool, ) -> Result<(), JSONRPCErrorError> { let ThreadForkParams { thread_id, @@ -3376,6 +3389,7 @@ impl ThreadRequestProcessor { }), thread_source.map(Into::into), self.request_trace_context(&request_id).await, + supports_openai_form_elicitation, ) .await .map_err(|err| match err { diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index fbfcb797b6..bd7aaf1ba0 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -101,12 +101,14 @@ impl TurnRequestProcessor { params: TurnStartParams, app_server_client_name: Option, app_server_client_version: Option, + supports_openai_form_elicitation: bool, ) -> Result, JSONRPCErrorError> { self.turn_start_inner( request_id, params, app_server_client_name, app_server_client_version, + /*supports_openai_form_elicitation*/ supports_openai_form_elicitation, ) .await .map(|response| Some(response.into())) @@ -384,6 +386,7 @@ impl TurnRequestProcessor { params: TurnStartParams, app_server_client_name: Option, app_server_client_version: Option, + supports_openai_form_elicitation: bool, ) -> Result { let (thread_id, thread) = self.load_thread(¶ms.thread_id) @@ -410,6 +413,14 @@ impl TurnRequestProcessor { .inspect_err(|error| { self.track_error_response(&request_id, error, /*error_type*/ None); })?; + thread + .set_openai_form_elicitation_support(supports_openai_form_elicitation) + .await + .map_err(|err| { + internal_error(format!( + "failed to update OpenAI form elicitation support: {err}" + )) + })?; let environment_selections = resolve_turn_environment_selections(self.thread_manager.as_ref(), params.environments)?; @@ -1164,6 +1175,7 @@ impl TurnRequestProcessor { }), /*thread_source*/ None, self.request_trace_context(request_id).await, + /*supports_openai_form_elicitation*/ false, ) .await .map_err(|err| { diff --git a/codex-rs/app-server/tests/suite/v2/attestation.rs b/codex-rs/app-server/tests/suite/v2/attestation.rs index d007614730..ea567a2d43 100644 --- a/codex-rs/app-server/tests/suite/v2/attestation.rs +++ b/codex-rs/app-server/tests/suite/v2/attestation.rs @@ -81,6 +81,7 @@ async fn attestation_generate_round_trip_adds_header_to_responses_websocket_hand experimental_api: true, request_attestation: true, opt_out_notification_methods: None, + mcp_server_openai_form_elicitation: false, }), ), ) diff --git a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs index 0e00bc6309..648d60f3b5 100644 --- a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs +++ b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs @@ -706,7 +706,7 @@ pub(super) async fn send_request( send_jsonrpc(stream, message).await } -async fn send_jsonrpc(stream: &mut WsClient, message: JSONRPCMessage) -> Result<()> { +pub(super) async fn send_jsonrpc(stream: &mut WsClient, message: JSONRPCMessage) -> Result<()> { let payload = serde_json::to_string(&message)?; stream .send(WebSocketMessage::Text(payload.into())) diff --git a/codex-rs/app-server/tests/suite/v2/experimental_api.rs b/codex-rs/app-server/tests/suite/v2/experimental_api.rs index 3b4f6e949a..62ad4c0677 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_api.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_api.rs @@ -39,6 +39,7 @@ async fn mock_experimental_method_requires_experimental_api_capability() -> Resu experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_openai_form_elicitation: false, }), ) .await?; @@ -70,6 +71,7 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_openai_form_elicitation: false, }), ) .await?; @@ -116,6 +118,7 @@ async fn thread_memory_mode_set_requires_experimental_api_capability() -> Result experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_openai_form_elicitation: false, }), ) .await?; @@ -150,6 +153,7 @@ async fn thread_settings_update_requires_experimental_api_capability() -> Result experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_openai_form_elicitation: false, }), ) .await?; @@ -184,6 +188,7 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result< experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_openai_form_elicitation: false, }), ) .await?; @@ -234,6 +239,7 @@ async fn thread_start_mock_field_requires_experimental_api_capability() -> Resul experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_openai_form_elicitation: false, }), ) .await?; @@ -272,6 +278,7 @@ async fn thread_start_without_dynamic_tools_allows_without_experimental_api_capa experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_openai_form_elicitation: false, }), ) .await?; @@ -309,6 +316,7 @@ async fn thread_start_granular_approval_policy_requires_experimental_api_capabil experimental_api: false, request_attestation: false, opt_out_notification_methods: None, + mcp_server_openai_form_elicitation: false, }), ) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/initialize.rs b/codex-rs/app-server/tests/suite/v2/initialize.rs index 0cf8504d3b..6fb7aa3bc3 100644 --- a/codex-rs/app-server/tests/suite/v2/initialize.rs +++ b/codex-rs/app-server/tests/suite/v2/initialize.rs @@ -214,6 +214,7 @@ async fn initialize_opt_out_notification_methods_filters_notifications() -> Resu experimental_api: true, request_attestation: false, opt_out_notification_methods: Some(vec!["thread/started".to_string()]), + mcp_server_openai_form_elicitation: false, }), ), ) diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs index b4bd1d377a..113f58f336 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs @@ -14,6 +14,9 @@ use axum::http::StatusCode; use axum::http::Uri; use axum::http::header::AUTHORIZATION; use axum::routing::get; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::InitializeCapabilities; +use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::McpElicitationSchema; @@ -24,6 +27,7 @@ use codex_app_server_protocol::McpServerElicitationRequestResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerRequestResolvedNotification; +use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnCompletedNotification; @@ -34,6 +38,7 @@ use codex_app_server_protocol::UserInput as V2UserInput; use codex_config::types::AuthCredentialsStoreMode; use core_test_support::assert_regex_match; use core_test_support::responses; +use core_test_support::responses::ResponseMock; use pretty_assertions::assert_eq; use rmcp::handler::server::ServerHandler; use rmcp::model::BooleanSchema; @@ -41,14 +46,18 @@ use rmcp::model::CallToolRequestParams; use rmcp::model::CallToolResult; use rmcp::model::Content; use rmcp::model::CreateElicitationRequestParams; +use rmcp::model::CustomRequest; use rmcp::model::ElicitationAction; use rmcp::model::ElicitationSchema; +use rmcp::model::InitializeRequestParams; +use rmcp::model::InitializeResult; use rmcp::model::JsonObject; use rmcp::model::ListToolsResult; use rmcp::model::Meta; use rmcp::model::PrimitiveSchema; use rmcp::model::ServerCapabilities; use rmcp::model::ServerInfo; +use rmcp::model::ServerRequest as McpServerRequest; use rmcp::model::Tool; use rmcp::model::ToolAnnotations; use rmcp::service::RequestContext; @@ -63,6 +72,15 @@ use tokio::net::TcpListener; use tokio::task::JoinHandle; use tokio::time::timeout; +use super::connection_handling_websocket::WsClient; +use super::connection_handling_websocket::connect_websocket; +use super::connection_handling_websocket::read_jsonrpc_message; +use super::connection_handling_websocket::read_notification_for_method; +use super::connection_handling_websocket::read_response_for_id; +use super::connection_handling_websocket::send_jsonrpc; +use super::connection_handling_websocket::send_request; +use super::connection_handling_websocket::spawn_websocket_server; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const CONNECTOR_ID: &str = "calendar"; const CONNECTOR_NAME: &str = "Calendar"; @@ -71,9 +89,278 @@ const CALLABLE_TOOL_NAME: &str = "_confirm_action"; const TOOL_NAME: &str = "calendar_confirm_action"; const TOOL_CALL_ID: &str = "call-calendar-confirm"; const ELICITATION_MESSAGE: &str = "Allow this request?"; +const OPENAI_FORM_MESSAGE: &str = "Select a template"; +const IMAGE_DATA_URL: &str = + "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4="; + +#[derive(Clone, Copy)] +enum ElicitationScenario { + StandardForm, + OpenAiForm, +} #[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn mcp_server_elicitation_round_trip() -> Result<()> { +async fn mcp_server_form_elicitation_round_trip() -> Result<()> { + let mut fixture = ElicitationRoundTripFixture::start(ElicitationScenario::StandardForm).await?; + let (request_id, params) = fixture.read_elicitation().await?; + let requested_schema: McpElicitationSchema = serde_json::from_value(serde_json::to_value( + ElicitationSchema::builder() + .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) + .build() + .map_err(anyhow::Error::msg)?, + )?)?; + assert_eq!( + params, + McpServerElicitationRequestParams { + thread_id: fixture.thread_id.clone(), + turn_id: Some(fixture.turn_id.clone()), + server_name: "codex_apps".to_string(), + request: McpServerElicitationRequest::Form { + meta: None, + message: ELICITATION_MESSAGE.to_string(), + requested_schema, + }, + } + ); + + fixture + .accept(request_id.clone(), json!({ "confirmed": true })) + .await?; + fixture.finish(request_id, "accepted").await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn mcp_server_openai_form_elicitation_round_trip() -> Result<()> { + let mut fixture = ElicitationRoundTripFixture::start(ElicitationScenario::OpenAiForm).await?; + let (request_id, params) = fixture.read_elicitation().await?; + assert_eq!( + params, + McpServerElicitationRequestParams { + thread_id: fixture.thread_id.clone(), + turn_id: Some(fixture.turn_id.clone()), + server_name: "codex_apps".to_string(), + request: McpServerElicitationRequest::OpenAiForm { + meta: None, + message: OPENAI_FORM_MESSAGE.to_string(), + requested_schema: json!({ + "type": "object", + "properties": { + "template": { + "type": "openai/imagePicker", + "title": "Template", + "items": [{ + "id": "monthly-review", + "title": "Monthly review", + "image": IMAGE_DATA_URL, + }], + }, + }, + "required": ["template"], + }), + }, + } + ); + + fixture + .accept(request_id.clone(), json!({ "template": "monthly-review" })) + .await?; + fixture.finish(request_id, "accepted monthly-review").await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn openai_form_capability_follows_the_turn_starting_connection() -> Result<()> { + let (responses_server, response_mock, apps_server_url, apps_server_handle) = + start_elicitation_services(ElicitationScenario::OpenAiForm).await?; + let codex_home = TempDir::new()?; + write_config_toml(codex_home.path(), &responses_server.uri(), &apps_server_url)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?; + let mut supported_client = connect_websocket(bind_addr).await?; + initialize_websocket_client( + &mut supported_client, + /*id*/ 1, + "supported-client", + /*supports_openai_form_elicitation*/ true, + ) + .await?; + + send_request( + &mut supported_client, + "thread/start", + /*id*/ 2, + Some(serde_json::to_value(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + })?), + ) + .await?; + let ThreadStartResponse { thread, .. } = + to_response(read_response_for_id(&mut supported_client, /*id*/ 2).await?)?; + + send_request( + &mut supported_client, + "turn/start", + /*id*/ 3, + Some(serde_json::to_value(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Warm up connectors.".to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model".to_string()), + ..Default::default() + })?), + ) + .await?; + let _: TurnStartResponse = + to_response(read_response_for_id(&mut supported_client, /*id*/ 3).await?)?; + let _: TurnCompletedNotification = serde_json::from_value( + read_notification_for_method(&mut supported_client, "turn/completed") + .await? + .params + .expect("turn/completed params"), + )?; + + let mut unsupported_client = connect_websocket(bind_addr).await?; + initialize_websocket_client( + &mut unsupported_client, + /*id*/ 4, + "unsupported-client", + /*supports_openai_form_elicitation*/ false, + ) + .await?; + send_request( + &mut unsupported_client, + "thread/resume", + /*id*/ 5, + Some(serde_json::to_value(ThreadResumeParams { + thread_id: thread.id.clone(), + ..Default::default() + })?), + ) + .await?; + let _ = read_response_for_id(&mut unsupported_client, /*id*/ 5).await?; + + send_request( + &mut supported_client, + "turn/start", + /*id*/ 6, + Some(serde_json::to_value(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Use [$calendar](app://calendar) to run the calendar tool.".to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model".to_string()), + ..Default::default() + })?), + ) + .await?; + let TurnStartResponse { turn } = + to_response(read_response_for_id(&mut supported_client, /*id*/ 6).await?)?; + + let (request_id, params) = loop { + let JSONRPCMessage::Request(request) = read_jsonrpc_message(&mut supported_client).await? + else { + continue; + }; + let request: ServerRequest = serde_json::from_value(serde_json::to_value(request)?)?; + let ServerRequest::McpServerElicitationRequest { request_id, params } = request else { + continue; + }; + break (request_id, params); + }; + assert_eq!( + params.request, + McpServerElicitationRequest::OpenAiForm { + meta: None, + message: OPENAI_FORM_MESSAGE.to_string(), + requested_schema: json!({ + "type": "object", + "properties": { + "template": { + "type": "openai/imagePicker", + "title": "Template", + "items": [{ + "id": "monthly-review", + "title": "Monthly review", + "image": IMAGE_DATA_URL, + }], + }, + }, + "required": ["template"], + }), + } + ); + send_jsonrpc( + &mut supported_client, + JSONRPCMessage::Response(JSONRPCResponse { + id: request_id, + result: serde_json::to_value(McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Accept, + content: Some(json!({ "template": "monthly-review" })), + meta: None, + })?, + }), + ) + .await?; + + let completed: TurnCompletedNotification = serde_json::from_value( + read_notification_for_method(&mut supported_client, "turn/completed") + .await? + .params + .expect("turn/completed params"), + )?; + assert_eq!(completed.thread_id, thread.id); + assert_eq!(completed.turn.id, turn.id); + assert_eq!(completed.turn.status, TurnStatus::Completed); + assert_eq!(response_mock.requests().len(), 3); + + process.kill().await?; + apps_server_handle.abort(); + let _ = apps_server_handle.await; + Ok(()) +} + +async fn initialize_websocket_client( + client: &mut WsClient, + id: i64, + name: &str, + supports_openai_form_elicitation: bool, +) -> Result<()> { + send_request( + client, + "initialize", + id, + Some(serde_json::to_value(InitializeParams { + client_info: ClientInfo { + name: name.to_string(), + title: None, + version: "0.1.0".to_string(), + }, + capabilities: Some(InitializeCapabilities { + experimental_api: true, + mcp_server_openai_form_elicitation: supports_openai_form_elicitation, + ..Default::default() + }), + })?), + ) + .await?; + let _ = read_response_for_id(client, id).await?; + Ok(()) +} + +async fn start_elicitation_services( + scenario: ElicitationScenario, +) -> Result<(wiremock::MockServer, ResponseMock, String, JoinHandle<()>)> { let responses_server = responses::start_mock_server().await; let tool_call_arguments = serde_json::to_string(&json!({}))?; let response_mock = responses::mount_sse_sequence( @@ -102,201 +389,223 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> { ], ) .await; + let (apps_server_url, apps_server_handle) = start_apps_server(scenario).await?; + Ok(( + responses_server, + response_mock, + apps_server_url, + apps_server_handle, + )) +} - let (apps_server_url, apps_server_handle) = start_apps_server().await?; +struct ElicitationRoundTripFixture { + mcp: TestAppServer, + response_mock: ResponseMock, + _responses_server: wiremock::MockServer, + thread_id: String, + turn_id: String, + apps_server_handle: JoinHandle<()>, +} - let codex_home = TempDir::new()?; - write_config_toml(codex_home.path(), &responses_server.uri(), &apps_server_url)?; - write_chatgpt_auth( - codex_home.path(), - ChatGptAuthFixture::new("chatgpt-token") - .account_id("account-123") - .chatgpt_user_id("user-123") - .chatgpt_account_id("account-123"), - AuthCredentialsStoreMode::File, - )?; +impl ElicitationRoundTripFixture { + async fn start(scenario: ElicitationScenario) -> Result { + let (responses_server, response_mock, apps_server_url, apps_server_handle) = + start_elicitation_services(scenario).await?; + let codex_home = TempDir::new()?; + write_config_toml(codex_home.path(), &responses_server.uri(), &apps_server_url)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.initialize_with_capabilities( + ClientInfo { + name: "codex-app-server-tests".to_string(), + title: None, + version: "0.1.0".to_string(), + }, + Some(InitializeCapabilities { + experimental_api: true, + mcp_server_openai_form_elicitation: true, + ..Default::default() + }), + ), + ) + .await??; - let thread_start_id = mcp - .send_thread_start_request(ThreadStartParams { - model: Some("mock-model".to_string()), - ..Default::default() + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?; + + let warmup_turn_start_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: vec![V2UserInput::Text { + text: "Warm up connectors.".to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let warmup_turn_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(warmup_turn_start_id)), + ) + .await??; + let _: TurnStartResponse = to_response(warmup_turn_start_resp)?; + let warmup_completed = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let warmup_completed: TurnCompletedNotification = serde_json::from_value( + warmup_completed + .params + .clone() + .expect("warmup turn/completed params"), + )?; + assert_eq!(warmup_completed.thread_id, thread.id); + assert_eq!(warmup_completed.turn.status, TurnStatus::Completed); + + let turn_start_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: vec![V2UserInput::Text { + text: "Use [$calendar](app://calendar) to run the calendar tool.".to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let turn_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)), + ) + .await??; + let TurnStartResponse { turn } = to_response(turn_start_resp)?; + + Ok(Self { + mcp, + response_mock, + _responses_server: responses_server, + thread_id: thread.id, + turn_id: turn.id, + apps_server_handle, }) - .await?; - let thread_start_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), - ) - .await??; - let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?; - - let warmup_turn_start_id = mcp - .send_turn_start_request(TurnStartParams { - thread_id: thread.id.clone(), - client_user_message_id: None, - input: vec![V2UserInput::Text { - text: "Warm up connectors.".to_string(), - text_elements: Vec::new(), - }], - model: Some("mock-model".to_string()), - ..Default::default() - }) - .await?; - let warmup_turn_start_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(warmup_turn_start_id)), - ) - .await??; - let _: TurnStartResponse = to_response(warmup_turn_start_resp)?; - - let warmup_completed = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/completed"), - ) - .await??; - let warmup_completed: TurnCompletedNotification = serde_json::from_value( - warmup_completed - .params - .clone() - .expect("warmup turn/completed params"), - )?; - assert_eq!(warmup_completed.thread_id, thread.id); - assert_eq!(warmup_completed.turn.status, TurnStatus::Completed); - - let turn_start_id = mcp - .send_turn_start_request(TurnStartParams { - thread_id: thread.id.clone(), - client_user_message_id: None, - input: vec![V2UserInput::Text { - text: "Use [$calendar](app://calendar) to run the calendar tool.".to_string(), - text_elements: Vec::new(), - }], - model: Some("mock-model".to_string()), - ..Default::default() - }) - .await?; - let turn_start_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)), - ) - .await??; - let TurnStartResponse { turn } = to_response(turn_start_resp)?; - - let server_req = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_request_message(), - ) - .await??; - let ServerRequest::McpServerElicitationRequest { request_id, params } = server_req else { - panic!("expected McpServerElicitationRequest request, got: {server_req:?}"); - }; - let requested_schema: McpElicitationSchema = serde_json::from_value(serde_json::to_value( - ElicitationSchema::builder() - .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) - .build() - .map_err(anyhow::Error::msg)?, - )?)?; - - assert_eq!( - params, - McpServerElicitationRequestParams { - thread_id: thread.id.clone(), - turn_id: Some(turn.id.clone()), - server_name: "codex_apps".to_string(), - request: McpServerElicitationRequest::Form { - meta: None, - message: ELICITATION_MESSAGE.to_string(), - requested_schema, - }, - } - ); - - let resolved_request_id = request_id.clone(); - mcp.send_response( - request_id, - serde_json::to_value(McpServerElicitationRequestResponse { - action: McpServerElicitationAction::Accept, - content: Some(json!({ - "confirmed": true, - })), - meta: None, - })?, - ) - .await?; - - let mut saw_resolved = false; - loop { - let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??; - let JSONRPCMessage::Notification(notification) = message else { - continue; - }; - - match notification.method.as_str() { - "serverRequest/resolved" => { - let resolved: ServerRequestResolvedNotification = serde_json::from_value( - notification - .params - .clone() - .expect("serverRequest/resolved params"), - )?; - assert_eq!( - resolved, - ServerRequestResolvedNotification { - thread_id: thread.id.clone(), - request_id: resolved_request_id.clone(), - } - ); - saw_resolved = true; - } - "turn/completed" => { - let completed: TurnCompletedNotification = serde_json::from_value( - notification.params.clone().expect("turn/completed params"), - )?; - assert!(saw_resolved, "serverRequest/resolved should arrive first"); - assert_eq!(completed.thread_id, thread.id); - assert_eq!(completed.turn.id, turn.id); - assert_eq!(completed.turn.status, TurnStatus::Completed); - break; - } - _ => {} - } } - let requests = response_mock.requests(); - assert_eq!(requests.len(), 3); - let function_call_output = requests[2].function_call_output(TOOL_CALL_ID); - assert_eq!( - function_call_output.get("type"), - Some(&Value::String("function_call_output".to_string())) - ); - assert_eq!( - function_call_output.get("call_id"), - Some(&Value::String(TOOL_CALL_ID.to_string())) - ); - let output = function_call_output - .get("output") - .and_then(Value::as_str) - .expect("function_call_output output should be a JSON string"); - let payload = assert_regex_match( - r#"(?s)^Wall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\n(.*)$"#, - output, - ) - .get(1) - .expect("wall-time wrapped output should include payload") - .as_str(); - assert_eq!( - serde_json::from_str::(payload)?, - json!([{ - "type": "text", - "text": "accepted" - }]) - ); + async fn read_elicitation(&mut self) -> Result<(RequestId, McpServerElicitationRequestParams)> { + let request = timeout( + DEFAULT_READ_TIMEOUT, + self.mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::McpServerElicitationRequest { request_id, params } = request else { + panic!("expected McpServerElicitationRequest request, got: {request:?}"); + }; + Ok((request_id, params)) + } - apps_server_handle.abort(); - let _ = apps_server_handle.await; - Ok(()) + async fn accept(&mut self, request_id: RequestId, content: Value) -> Result<()> { + self.mcp + .send_response( + request_id, + serde_json::to_value(McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Accept, + content: Some(content), + meta: None, + })?, + ) + .await + } + + async fn finish(mut self, request_id: RequestId, expected_text: &str) -> Result<()> { + let mut resolved = false; + loop { + let message = timeout(DEFAULT_READ_TIMEOUT, self.mcp.read_next_message()).await??; + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + match notification.method.as_str() { + "serverRequest/resolved" => { + let notification: ServerRequestResolvedNotification = serde_json::from_value( + notification + .params + .clone() + .expect("serverRequest/resolved params"), + )?; + assert_eq!(notification.thread_id, self.thread_id); + assert_eq!(notification.request_id, request_id); + resolved = true; + } + "turn/completed" => { + let notification: TurnCompletedNotification = serde_json::from_value( + notification.params.clone().expect("turn/completed params"), + )?; + assert!( + resolved, + "server request should resolve before turn completion" + ); + assert_eq!(notification.thread_id, self.thread_id); + assert_eq!(notification.turn.id, self.turn_id); + assert_eq!(notification.turn.status, TurnStatus::Completed); + break; + } + _ => {} + } + } + + let requests = self.response_mock.requests(); + assert_eq!(requests.len(), 3); + let function_call_output = requests[2].function_call_output(TOOL_CALL_ID); + assert_eq!( + function_call_output.get("type"), + Some(&Value::String("function_call_output".to_string())) + ); + assert_eq!( + function_call_output.get("call_id"), + Some(&Value::String(TOOL_CALL_ID.to_string())) + ); + let output = function_call_output + .get("output") + .and_then(Value::as_str) + .expect("function_call_output output should be a JSON string"); + let payload = assert_regex_match( + r#"(?s)^Wall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\n(.*)$"#, + output, + ) + .get(1) + .expect("wall-time wrapped output should include payload") + .as_str(); + assert_eq!( + serde_json::from_str::(payload)?, + json!([{ "type": "text", "text": expected_text }]) + ); + + self.apps_server_handle.abort(); + let _ = self.apps_server_handle.await; + Ok(()) + } } #[derive(Clone)] @@ -305,10 +614,33 @@ struct AppsServerState { expected_account_id: String, } -#[derive(Clone, Default)] -struct ElicitationAppsMcpServer; +#[derive(Clone)] +struct ElicitationAppsMcpServer { + scenario: ElicitationScenario, +} impl ServerHandler for ElicitationAppsMcpServer { + async fn initialize( + &self, + request: InitializeRequestParams, + context: RequestContext, + ) -> Result { + if matches!(self.scenario, ElicitationScenario::OpenAiForm) { + assert_eq!( + request + .capabilities + .extensions + .as_ref() + .and_then(|extensions| extensions.get("openai/form")) + .cloned() + .map(Value::Object), + Some(json!({})) + ); + } + context.peer.set_peer_info(request); + Ok(self.get_info()) + } + fn get_info(&self) -> ServerInfo { ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) .with_protocol_version(rmcp::model::ProtocolVersion::V_2025_06_18) @@ -351,40 +683,91 @@ impl ServerHandler for ElicitationAppsMcpServer { _request: CallToolRequestParams, context: RequestContext, ) -> Result { - let requested_schema = ElicitationSchema::builder() - .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) - .build() - .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; - - let result = context - .peer - .create_elicitation(CreateElicitationRequestParams::FormElicitationParams { - meta: None, - message: ELICITATION_MESSAGE.to_string(), - requested_schema, - }) - .await - .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; - - let output = match result.action { - ElicitationAction::Accept => { + match self.scenario { + ElicitationScenario::StandardForm => { + let requested_schema = ElicitationSchema::builder() + .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) + .build() + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; + let result = context + .peer + .create_elicitation(CreateElicitationRequestParams::FormElicitationParams { + meta: None, + message: ELICITATION_MESSAGE.to_string(), + requested_schema, + }) + .await + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; assert_eq!( result.content, Some(json!({ "confirmed": true, })) ); - "accepted" + let output = match result.action { + ElicitationAction::Accept => "accepted", + ElicitationAction::Decline => "declined", + ElicitationAction::Cancel => "cancelled", + }; + Ok(CallToolResult::success(vec![Content::text(output)])) } - ElicitationAction::Decline => "declined", - ElicitationAction::Cancel => "cancelled", - }; - - Ok(CallToolResult::success(vec![Content::text(output)])) + ElicitationScenario::OpenAiForm => { + let result = context + .peer + .send_request(McpServerRequest::CustomRequest(CustomRequest::new( + "openai/form", + Some(json!({ + "message": OPENAI_FORM_MESSAGE, + "requestedSchema": { + "type": "object", + "properties": { + "template": { + "type": "openai/imagePicker", + "title": "Template", + "items": [{ + "id": "monthly-review", + "title": "Monthly review", + "image": IMAGE_DATA_URL, + }], + }, + }, + "required": ["template"], + }, + })), + ))) + .await + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; + let result = match result { + rmcp::model::ClientResult::CustomResult(result) => result.0, + rmcp::model::ClientResult::CreateElicitationResult(result) => { + serde_json::to_value(result) + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))? + } + result => { + return Err(rmcp::ErrorData::internal_error( + format!("unexpected OpenAI form response: {result:?}"), + None, + )); + } + }; + assert_eq!( + result, + json!({ + "action": "accept", + "content": { + "template": "monthly-review", + }, + }) + ); + Ok(CallToolResult::success(vec![Content::text( + "accepted monthly-review", + )])) + } + } } } -async fn start_apps_server() -> Result<(String, JoinHandle<()>)> { +async fn start_apps_server(scenario: ElicitationScenario) -> Result<(String, JoinHandle<()>)> { let state = Arc::new(AppsServerState { expected_bearer: "Bearer chatgpt-token".to_string(), expected_account_id: "account-123".to_string(), @@ -394,7 +777,7 @@ async fn start_apps_server() -> Result<(String, JoinHandle<()>)> { let addr = listener.local_addr()?; let mcp_service = StreamableHttpService::new( - move || Ok(ElicitationAppsMcpServer), + move || Ok(ElicitationAppsMcpServer { scenario }), Arc::new(LocalSessionManager::default()), StreamableHttpServerConfig::default(), ); diff --git a/codex-rs/app-server/tests/suite/v2/thread_status.rs b/codex-rs/app-server/tests/suite/v2/thread_status.rs index b8349a77f1..e922bd13c1 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_status.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_status.rs @@ -148,6 +148,7 @@ async fn thread_status_changed_can_be_opted_out() -> Result<()> { experimental_api: true, request_attestation: false, opt_out_notification_methods: Some(vec!["thread/status/changed".to_string()]), + mcp_server_openai_form_elicitation: false, }), ), ) diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index e50532714a..e9c835e863 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -133,6 +133,7 @@ impl McpConnectionManager { host_owned_codex_apps_enabled: bool, prefix_mcp_tool_names: bool, client_elicitation_capability: ElicitationCapability, + supports_openai_form_elicitation: bool, tool_plugin_provenance: ToolPluginProvenance, auth: Option<&CodexAuth>, elicitation_reviewer: Option, @@ -209,6 +210,7 @@ impl McpConnectionManager { runtime_context.clone(), runtime_auth_provider, client_elicitation_capability.clone(), + supports_openai_form_elicitation, ); clients.insert(server_name.clone(), async_managed_client.clone()); let tx_event = tx_event.clone(); diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 20bb8d43cc..c8d8f79081 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -251,13 +251,15 @@ async fn disabled_permissions_auto_accept_elicitation_with_empty_form_schema() { let response = sender( NumberOrString::Number(1), - CreateElicitationRequestParams::FormElicitationParams { - meta: None, - message: "Confirm?".to_string(), - requested_schema: rmcp::model::ElicitationSchema::builder() - .build() - .expect("schema should build"), - }, + codex_rmcp_client::Elicitation::Mcp( + CreateElicitationRequestParams::FormElicitationParams { + meta: None, + message: "Confirm?".to_string(), + requested_schema: rmcp::model::ElicitationSchema::builder() + .build() + .expect("schema should build"), + }, + ), ) .await .expect("elicitation should auto accept"); @@ -284,17 +286,19 @@ async fn disabled_permissions_do_not_auto_accept_elicitation_with_requested_fiel let response = sender( NumberOrString::Number(1), - CreateElicitationRequestParams::FormElicitationParams { - meta: None, - message: "What should I say?".to_string(), - requested_schema: rmcp::model::ElicitationSchema::builder() - .required_property( - "message", - rmcp::model::PrimitiveSchema::String(rmcp::model::StringSchema::new()), - ) - .build() - .expect("schema should build"), - }, + codex_rmcp_client::Elicitation::Mcp( + CreateElicitationRequestParams::FormElicitationParams { + meta: None, + message: "What should I say?".to_string(), + requested_schema: rmcp::model::ElicitationSchema::builder() + .required_property( + "message", + rmcp::model::PrimitiveSchema::String(rmcp::model::StringSchema::new()), + ) + .build() + .expect("schema should build"), + }, + ), ) .await .expect("elicitation should auto decline"); @@ -1265,6 +1269,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() { /*host_owned_codex_apps_enabled*/ false, /*prefix_mcp_tool_names*/ true, ElicitationCapability::default(), + /*supports_openai_form_elicitation*/ false, ToolPluginProvenance::default(), /*auth*/ None, /*elicitation_reviewer*/ None, diff --git a/codex-rs/codex-mcp/src/elicitation.rs b/codex-rs/codex-mcp/src/elicitation.rs index a51cd7c623..abd5a46899 100644 --- a/codex-rs/codex-mcp/src/elicitation.rs +++ b/codex-rs/codex-mcp/src/elicitation.rs @@ -22,11 +22,11 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; +use codex_rmcp_client::Elicitation; use codex_rmcp_client::ElicitationResponse; use codex_rmcp_client::SendElicitation; use futures::future::BoxFuture; use futures::future::FutureExt; -use rmcp::model::CreateElicitationRequestParams; use rmcp::model::ElicitationAction; use rmcp::model::RequestId; use tokio::sync::Mutex; @@ -36,7 +36,7 @@ use tokio::sync::oneshot; pub struct ElicitationReviewRequest { pub server_name: String, pub request_id: RequestId, - pub elicitation: CreateElicitationRequestParams, + pub elicitation: Elicitation, } pub trait ElicitationReviewer: Send + Sync { @@ -172,11 +172,13 @@ impl ElicitationRequestManager { } let request = match elicitation { - CreateElicitationRequestParams::FormElicitationParams { - meta, - message, - requested_schema, - } => ElicitationRequest::Form { + Elicitation::Mcp( + rmcp::model::CreateElicitationRequestParams::FormElicitationParams { + meta, + message, + requested_schema, + }, + ) => ElicitationRequest::Form { meta: meta .map(serde_json::to_value) .transpose() @@ -185,12 +187,14 @@ impl ElicitationRequestManager { requested_schema: serde_json::to_value(requested_schema) .context("failed to serialize MCP elicitation schema")?, }, - CreateElicitationRequestParams::UrlElicitationParams { - meta, - message, - url, - elicitation_id, - } => ElicitationRequest::Url { + Elicitation::Mcp( + rmcp::model::CreateElicitationRequestParams::UrlElicitationParams { + meta, + message, + url, + elicitation_id, + }, + ) => ElicitationRequest::Url { meta: meta .map(serde_json::to_value) .transpose() @@ -199,6 +203,15 @@ impl ElicitationRequestManager { url, elicitation_id, }, + Elicitation::OpenAiForm { + meta, + message, + requested_schema, + } => ElicitationRequest::OpenAiForm { + meta, + message, + requested_schema, + }, }; let (tx, rx) = oneshot::channel(); { @@ -243,14 +256,18 @@ pub(crate) fn elicitation_is_rejected_by_policy(approval_policy: AskForApproval) type ResponderMap = HashMap<(String, RequestId), oneshot::Sender>; -fn can_auto_accept_elicitation(elicitation: &CreateElicitationRequestParams) -> bool { +fn can_auto_accept_elicitation(elicitation: &Elicitation) -> bool { match elicitation { - CreateElicitationRequestParams::FormElicitationParams { - requested_schema, .. - } => { + Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::FormElicitationParams { + requested_schema, + .. + }) => { // Auto-accept confirm/approval elicitations without schema requirements. requested_schema.properties.is_empty() } - CreateElicitationRequestParams::UrlElicitationParams { .. } => false, + Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::UrlElicitationParams { + .. + }) + | Elicitation::OpenAiForm { .. } => false, } } diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 7a585cb5d8..d87602438f 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -305,6 +305,7 @@ pub async fn read_mcp_resource( host_owned_codex_apps_enabled, config.prefix_mcp_tool_names, config.client_elicitation_capability.clone(), + /*supports_openai_form_elicitation*/ false, tool_plugin_provenance(config), auth, /*elicitation_reviewer*/ None, @@ -379,6 +380,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( host_owned_codex_apps_enabled, config.prefix_mcp_tool_names, config.client_elicitation_capability.clone(), + /*supports_openai_form_elicitation*/ false, tool_plugin_provenance, auth, /*elicitation_reviewer*/ None, diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index 3ba1adcc94..aa56650e8a 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -7,6 +7,7 @@ //! [`crate::connection_manager`]. use std::borrow::Cow; +use std::collections::BTreeMap; use std::collections::HashMap; use std::env; use std::ffi::OsString; @@ -62,6 +63,7 @@ use rmcp::model::ClientCapabilities; use rmcp::model::ElicitationCapability; use rmcp::model::Implementation; use rmcp::model::InitializeRequestParams; +use rmcp::model::JsonObject; use rmcp::model::ProtocolVersion; use rmcp::model::Tool as RmcpTool; use tokio_util::sync::CancellationToken; @@ -70,6 +72,7 @@ use tracing::warn; /// MCP server capability indicating that Codex should include [`SandboxState`] /// in tool-call request `_meta` under this key. pub const MCP_SANDBOX_STATE_META_CAPABILITY: &str = "codex/sandbox-state-meta"; +pub const OPENAI_FORM_CAPABILITY: &str = "openai/form"; pub(crate) const MCP_TOOLS_LIST_DURATION_METRIC: &str = "codex.mcp.tools.list.duration_ms"; pub(crate) const MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC: &str = @@ -151,6 +154,7 @@ impl AsyncManagedClient { runtime_context: McpRuntimeContext, runtime_auth_provider: Option, client_elicitation_capability: ElicitationCapability, + supports_openai_form_elicitation: bool, ) -> Self { let tool_filter = server .configured_config() @@ -204,6 +208,7 @@ impl AsyncManagedClient { elicitation_requests, codex_apps_tools_cache_context, client_elicitation_capability, + supports_openai_form_elicitation, }, ) .await @@ -483,14 +488,12 @@ async fn start_server_task( elicitation_requests, codex_apps_tools_cache_context, client_elicitation_capability, + supports_openai_form_elicitation, } = params; - let mut capabilities = ClientCapabilities::default(); - capabilities.elicitation = Some(client_elicitation_capability); - let params = InitializeRequestParams::new( - capabilities, - Implementation::new("codex-mcp-client", env!("CARGO_PKG_VERSION")).with_title("Codex"), - ) - .with_protocol_version(ProtocolVersion::V_2025_06_18); + let params = mcp_initialize_request_params( + client_elicitation_capability, + supports_openai_form_elicitation, + ); let send_elicitation = elicitation_requests.make_sender(server_name.clone(), tx_event); @@ -550,6 +553,25 @@ async fn start_server_task( Ok(managed) } +fn mcp_initialize_request_params( + client_elicitation_capability: ElicitationCapability, + supports_openai_form_elicitation: bool, +) -> InitializeRequestParams { + let mut capabilities = ClientCapabilities::default(); + capabilities.elicitation = Some(client_elicitation_capability); + if supports_openai_form_elicitation { + capabilities.extensions = Some(BTreeMap::from([( + OPENAI_FORM_CAPABILITY.to_string(), + JsonObject::new(), + )])); + } + InitializeRequestParams::new( + capabilities, + Implementation::new("codex-mcp-client", env!("CARGO_PKG_VERSION")).with_title("Codex"), + ) + .with_protocol_version(ProtocolVersion::V_2025_06_18) +} + fn mcp_server_info_from_implementation(server_info: Implementation) -> McpServerInfo { McpServerInfo { name: server_info.name, @@ -574,6 +596,7 @@ struct StartServerTaskParams { elicitation_requests: ElicitationRequestManager, codex_apps_tools_cache_context: Option, client_elicitation_capability: ElicitationCapability, + supports_openai_form_elicitation: bool, } async fn make_rmcp_client( @@ -668,6 +691,27 @@ mod tests { use rmcp::model::JsonObject; use rmcp::model::Meta; + #[test] + fn mcp_initialize_advertises_openai_form_only_when_supported() { + let unsupported = mcp_initialize_request_params( + ElicitationCapability::default(), + /*supports_openai_form_elicitation*/ false, + ); + assert_eq!(unsupported.capabilities.extensions, None); + + let supported = mcp_initialize_request_params( + ElicitationCapability::default(), + /*supports_openai_form_elicitation*/ true, + ); + assert_eq!( + supported.capabilities.extensions, + Some(BTreeMap::from([( + OPENAI_FORM_CAPABILITY.to_string(), + JsonObject::new(), + )])) + ); + } + fn tool_with_connector_meta() -> RmcpTool { RmcpTool::new( "capture_file_upload", diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index b3f0d6c43c..b7b1fe7580 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -113,6 +113,10 @@ pub(crate) async fn run_codex_thread_interactive( parent_trace: None, environment_selections: parent_ctx.environments.to_selections(), thread_extension_init: codex_extension_api::ExtensionDataInit::default(), + supports_openai_form_elicitation: parent_session + .services + .supports_openai_form_elicitation + .load(std::sync::atomic::Ordering::Relaxed), analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), thread_store: Arc::clone(&parent_session.services.thread_store), attestation_provider: parent_session.services.attestation_provider.clone(), diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 1df188fc31..8c44857297 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -335,6 +335,13 @@ impl CodexThread { .await } + pub async fn set_openai_form_elicitation_support(&self, supported: bool) -> anyhow::Result<()> { + self.codex + .session + .set_openai_form_elicitation_support(supported) + .await + } + /// Preview persistent thread settings overrides without committing them. pub async fn preview_thread_settings_overrides( &self, diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 496f14e86a..6d73015595 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -291,6 +291,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager( host_owned_codex_apps_enabled, mcp_config.prefix_mcp_tool_names, mcp_config.client_elicitation_capability, + /*supports_openai_form_elicitation*/ false, ToolPluginProvenance::default(), auth.as_ref(), /*elicitation_reviewer*/ None, diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 4249c0d4b0..24abfd9e09 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1314,6 +1314,7 @@ async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: /*host_owned_codex_apps_enabled*/ true, turn_context.config.prefix_mcp_tool_names(), rmcp::model::ElicitationCapability::default(), + /*supports_openai_form_elicitation*/ false, codex_mcp::ToolPluginProvenance::default(), auth.as_ref(), /*elicitation_reviewer*/ None, diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index d1b8982223..d03576aadc 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -16,7 +16,7 @@ use codex_protocol::mcp_approval_meta::TOOL_DESCRIPTION_KEY as MCP_ELICITATION_T use codex_protocol::mcp_approval_meta::TOOL_NAME_KEY as MCP_ELICITATION_TOOL_NAME_KEY; use codex_protocol::mcp_approval_meta::TOOL_PARAMS_KEY as MCP_ELICITATION_TOOL_PARAMS_KEY; use codex_protocol::mcp_approval_meta::TOOL_TITLE_KEY as MCP_ELICITATION_TOOL_TITLE_KEY; -use rmcp::model::CreateElicitationRequestParams; +use codex_rmcp_client::Elicitation; use rmcp::model::ElicitationAction; use rmcp::model::Meta; use serde_json::Map; @@ -143,6 +143,15 @@ impl Session { requested_schema, } } + McpServerElicitationRequest::OpenAiForm { + meta, + message, + requested_schema, + } => codex_protocol::approvals::ElicitationRequest::OpenAiForm { + meta, + message, + requested_schema, + }, McpServerElicitationRequest::Url { meta, message, @@ -353,6 +362,9 @@ impl Session { host_owned_codex_apps_enabled, mcp_config.prefix_mcp_tool_names, mcp_config.client_elicitation_capability, + self.services + .supports_openai_form_elicitation + .load(std::sync::atomic::Ordering::Relaxed), tool_plugin_provenance, auth.as_ref(), elicitation_reviewer, @@ -419,6 +431,34 @@ impl Session { .await; } + pub(crate) async fn set_openai_form_elicitation_support( + &self, + supported: bool, + ) -> anyhow::Result<()> { + if self + .services + .supports_openai_form_elicitation + .load(std::sync::atomic::Ordering::Relaxed) + == supported + { + return Ok(()); + } + + let config = self.get_config().await; + let refresh_config = McpServerRefreshConfig { + mcp_servers: serde_json::to_value(config.mcp_servers.get())?, + mcp_oauth_credentials_store_mode: serde_json::to_value( + config.mcp_oauth_credentials_store_mode, + )?, + auth_keyring_backend_kind: serde_json::to_value(config.auth_keyring_backend_kind())?, + }; + self.services + .supports_openai_form_elicitation + .store(supported, std::sync::atomic::Ordering::Relaxed); + *self.pending_mcp_server_refresh_config.lock().await = Some(refresh_config); + Ok(()) + } + pub(crate) async fn refresh_mcp_servers_now( &self, turn_context: &TurnContext, @@ -510,12 +550,15 @@ fn guardian_elicitation_review_request( request: &ElicitationReviewRequest, ) -> GuardianElicitationReview { let (meta, requested_schema) = match &request.elicitation { - CreateElicitationRequestParams::FormElicitationParams { + Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::FormElicitationParams { meta, requested_schema, .. - } => (meta, Some(requested_schema)), - CreateElicitationRequestParams::UrlElicitationParams { meta, .. } => { + }) => (meta, Some(requested_schema)), + Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::UrlElicitationParams { + meta, + .. + }) => { return if meta_requests_approval_request(meta) { GuardianElicitationReview::Decline( "guardian MCP elicitation review only supports form elicitations", @@ -524,6 +567,7 @@ fn guardian_elicitation_review_request( GuardianElicitationReview::NotRequested }; } + Elicitation::OpenAiForm { .. } => return GuardianElicitationReview::NotRequested, }; let Some(meta) = meta.as_ref().map(|meta| &meta.0) else { @@ -585,13 +629,10 @@ fn guardian_elicitation_review_request( )) } -fn elicitation_connector_id(elicitation: &CreateElicitationRequestParams) -> Option<&str> { - match elicitation { - CreateElicitationRequestParams::FormElicitationParams { meta, .. } - | CreateElicitationRequestParams::UrlElicitationParams { meta, .. } => meta - .as_ref() - .and_then(|meta| metadata_str(&meta.0, MCP_ELICITATION_CONNECTOR_ID_KEY)), - } +fn elicitation_connector_id(elicitation: &Elicitation) -> Option<&str> { + elicitation + .meta() + .and_then(|meta| metadata_str(meta, MCP_ELICITATION_CONNECTOR_ID_KEY)) } fn meta_requests_approval_request(meta: &Option) -> bool { diff --git a/codex-rs/core/src/session/mcp_tests.rs b/codex-rs/core/src/session/mcp_tests.rs index 0e3664019b..e5d0184bb7 100644 --- a/codex-rs/core/src/session/mcp_tests.rs +++ b/codex-rs/core/src/session/mcp_tests.rs @@ -30,13 +30,15 @@ fn form_request(meta: Option) -> ElicitationReviewRequest { ElicitationReviewRequest { server_name: "browser-use".to_string(), request_id: rmcp::model::NumberOrString::Number(7), - elicitation: CreateElicitationRequestParams::FormElicitationParams { - meta, - message: "Allow origin?".to_string(), - requested_schema: ElicitationSchema::builder() - .build() - .expect("schema should build"), - }, + elicitation: Elicitation::Mcp( + rmcp::model::CreateElicitationRequestParams::FormElicitationParams { + meta, + message: "Allow origin?".to_string(), + requested_schema: ElicitationSchema::builder() + .build() + .expect("schema should build"), + }, + ), } } @@ -171,12 +173,14 @@ fn guardian_elicitation_review_request_declines_unsupported_opt_in_shapes() { let url_request = ElicitationReviewRequest { server_name: "browser-use".to_string(), request_id: rmcp::model::NumberOrString::Number(8), - elicitation: CreateElicitationRequestParams::UrlElicitationParams { - meta: guardian_meta(Some(json!({}))), - message: "Open URL".to_string(), - url: "https://example.com".to_string(), - elicitation_id: "elicit-1".to_string(), - }, + elicitation: Elicitation::Mcp( + rmcp::model::CreateElicitationRequestParams::UrlElicitationParams { + meta: guardian_meta(Some(json!({}))), + message: "Open URL".to_string(), + url: "https://example.com".to_string(), + elicitation_id: "elicit-1".to_string(), + }, + ), }; assert!(matches!( guardian_elicitation_review_request(&url_request), @@ -186,14 +190,16 @@ fn guardian_elicitation_review_request_declines_unsupported_opt_in_shapes() { let non_empty_schema_request = ElicitationReviewRequest { server_name: "browser-use".to_string(), request_id: rmcp::model::NumberOrString::Number(9), - elicitation: CreateElicitationRequestParams::FormElicitationParams { - meta: guardian_meta(Some(json!({}))), - message: "Allow origin?".to_string(), - requested_schema: ElicitationSchema::builder() - .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) - .build() - .expect("schema should build"), - }, + elicitation: Elicitation::Mcp( + rmcp::model::CreateElicitationRequestParams::FormElicitationParams { + meta: guardian_meta(Some(json!({}))), + message: "Allow origin?".to_string(), + requested_schema: ElicitationSchema::builder() + .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) + .build() + .expect("schema should build"), + }, + ), }; assert!(matches!( guardian_elicitation_review_request(&non_empty_schema_request), diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 5aa56b68f8..f92bd63680 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -435,6 +435,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) parent_trace: Option, pub(crate) environment_selections: Vec, pub(crate) thread_extension_init: ExtensionDataInit, + pub(crate) supports_openai_form_elicitation: bool, pub(crate) analytics_events_client: Option, pub(crate) thread_store: Arc, pub(crate) attestation_provider: Option>, @@ -517,6 +518,7 @@ impl Codex { parent_trace: _, environment_selections, thread_extension_init, + supports_openai_form_elicitation, analytics_events_client, thread_store, attestation_provider, @@ -659,6 +661,7 @@ impl Codex { mcp_manager.clone(), extensions, thread_extension_init, + supports_openai_form_elicitation, agent_control, environment_manager, inherited_environments, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index c4bc064a47..2b61468c15 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -484,6 +484,7 @@ impl Session { mcp_manager: Arc, extensions: Arc>, thread_extension_init: ExtensionDataInit, + supports_openai_form_elicitation: bool, agent_control: AgentControl, environment_manager: Arc, inherited_environments: Option, @@ -1009,6 +1010,9 @@ impl Session { session_extension_data, thread_extension_data, mcp_thread_init, + supports_openai_form_elicitation: std::sync::atomic::AtomicBool::new( + supports_openai_form_elicitation, + ), agent_control, network_proxy: arc_swap::ArcSwapOption::from(network_proxy.map(Arc::new)), network_proxy_audit_metadata, @@ -1149,6 +1153,9 @@ impl Session { host_owned_codex_apps_enabled, config.prefix_mcp_tool_names(), client_elicitation_capability, + sess.services + .supports_openai_form_elicitation + .load(std::sync::atomic::Ordering::Relaxed), tool_plugin_provenance, auth, Some(sess.mcp_elicitation_reviewer()), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 63e8783cc6..b13cd029ce 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -4856,6 +4856,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { mcp_manager, Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), codex_extension_api::ExtensionDataInit::default(), + /*supports_openai_form_elicitation*/ false, AgentControl::default(), environment_manager, /*inherited_environments*/ None, @@ -5018,6 +5019,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { ), thread_extension_data: codex_extension_api::ExtensionData::new(thread_id.to_string()), mcp_thread_init: codex_extension_api::ExtensionDataInit::default(), + supports_openai_form_elicitation: std::sync::atomic::AtomicBool::new(false), agent_control, network_proxy: arc_swap::ArcSwapOption::from(None), network_proxy_audit_metadata: crate::config::NetworkProxyAuditMetadata::default(), @@ -5203,6 +5205,7 @@ async fn make_session_with_config_and_rx( mcp_manager, Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), codex_extension_api::ExtensionDataInit::default(), + /*supports_openai_form_elicitation*/ false, AgentControl::default(), environment_manager, /*inherited_environments*/ None, @@ -5307,6 +5310,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( mcp_manager, Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), codex_extension_api::ExtensionDataInit::default(), + /*supports_openai_form_elicitation*/ false, agent_control, environment_manager, /*inherited_environments*/ None, @@ -7061,6 +7065,7 @@ where ), thread_extension_data: codex_extension_api::ExtensionData::new(thread_id.to_string()), mcp_thread_init: codex_extension_api::ExtensionDataInit::default(), + supports_openai_form_elicitation: std::sync::atomic::AtomicBool::new(false), agent_control, network_proxy: arc_swap::ArcSwapOption::from(None), network_proxy_audit_metadata: crate::config::NetworkProxyAuditMetadata::default(), diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index b9727a0401..0bed032351 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -732,6 +732,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { parent_trace: None, environment_selections: Vec::new(), thread_extension_init: codex_extension_api::ExtensionDataInit::default(), + supports_openai_form_elicitation: false, analytics_events_client: None, thread_store, attestation_provider: None, diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 9724d8b3ad..914fd4c32d 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::sync::Arc; +use std::sync::atomic::AtomicBool; use crate::SkillsService; use crate::agent::AgentControl; @@ -67,6 +68,7 @@ pub(crate) struct SessionServices { pub(crate) extensions: Arc>, pub(crate) session_extension_data: ExtensionData, pub(crate) thread_extension_data: ExtensionData, + pub(crate) supports_openai_form_elicitation: AtomicBool, pub(crate) mcp_thread_init: ExtensionDataInit, pub(crate) agent_control: AgentControl, pub(crate) network_proxy: ArcSwapOption, diff --git a/codex-rs/core/src/test_support.rs b/codex-rs/core/src/test_support.rs index 60e80ca290..1db714fada 100644 --- a/codex-rs/core/src/test_support.rs +++ b/codex-rs/core/src/test_support.rs @@ -112,9 +112,14 @@ pub async fn start_thread_with_user_shell_override( thread_manager: &ThreadManager, config: Config, user_shell_override: crate::shell::Shell, + supports_openai_form_elicitation: bool, ) -> codex_protocol::error::Result { thread_manager - .start_thread_with_user_shell_override_for_tests(config, user_shell_override) + .start_thread_with_user_shell_override_for_tests( + config, + user_shell_override, + supports_openai_form_elicitation, + ) .await } @@ -124,6 +129,7 @@ pub async fn resume_thread_from_rollout_with_user_shell_override( rollout_path: PathBuf, auth_manager: Arc, user_shell_override: crate::shell::Shell, + supports_openai_form_elicitation: bool, ) -> codex_protocol::error::Result { thread_manager .resume_thread_from_rollout_with_user_shell_override_for_tests( @@ -131,6 +137,7 @@ pub async fn resume_thread_from_rollout_with_user_shell_override( rollout_path, auth_manager, user_shell_override, + supports_openai_form_elicitation, ) .await } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index b452dc45ab..77a5520026 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -186,6 +186,7 @@ pub struct StartThreadOptions { pub parent_trace: Option, pub environments: Vec, pub thread_extension_init: ExtensionDataInit, + pub supports_openai_form_elicitation: bool, } pub(crate) struct ResumeThreadWithHistoryOptions { @@ -604,6 +605,7 @@ impl ThreadManager { parent_trace: None, environments, thread_extension_init: ExtensionDataInit::default(), + supports_openai_form_elicitation: false, })) .await } @@ -644,6 +646,7 @@ impl ThreadManager { options.parent_trace, options.environments, options.thread_extension_init, + options.supports_openai_form_elicitation, /*user_shell_override*/ None, )) .await @@ -692,6 +695,7 @@ impl ThreadManager { rollout_path: PathBuf, auth_manager: Arc, parent_trace: Option, + supports_openai_form_elicitation: bool, ) -> CodexResult { let initial_history = self.initial_history_from_rollout_path(rollout_path).await?; Box::pin(self.resume_thread_with_history( @@ -699,6 +703,7 @@ impl ThreadManager { initial_history, auth_manager, parent_trace, + supports_openai_form_elicitation, )) .await } @@ -710,6 +715,7 @@ impl ThreadManager { initial_history: InitialHistory, auth_manager: Arc, parent_trace: Option, + supports_openai_form_elicitation: bool, ) -> CodexResult { let agent_control = self.agent_control_for_config(&config); let environments = default_thread_environment_selections( @@ -735,6 +741,7 @@ impl ThreadManager { parent_trace, environments, /*thread_extension_init*/ ExtensionDataInit::default(), + supports_openai_form_elicitation, /*user_shell_override*/ None, )) .await @@ -744,6 +751,7 @@ impl ThreadManager { &self, config: Config, user_shell_override: crate::shell::Shell, + supports_openai_form_elicitation: bool, ) -> CodexResult { let agent_control = self.agent_control_for_config(&config); let environments = default_thread_environment_selections( @@ -763,6 +771,7 @@ impl ThreadManager { /*parent_trace*/ None, environments, /*thread_extension_init*/ ExtensionDataInit::default(), + supports_openai_form_elicitation, /*user_shell_override*/ Some(user_shell_override), )) .await @@ -774,6 +783,7 @@ impl ThreadManager { rollout_path: PathBuf, auth_manager: Arc, user_shell_override: crate::shell::Shell, + supports_openai_form_elicitation: bool, ) -> CodexResult { let agent_control = self.agent_control_for_config(&config); let initial_history = self.initial_history_from_rollout_path(rollout_path).await?; @@ -800,6 +810,7 @@ impl ThreadManager { /*parent_trace*/ None, environments, /*thread_extension_init*/ ExtensionDataInit::default(), + supports_openai_form_elicitation, /*user_shell_override*/ Some(user_shell_override), )) .await @@ -880,8 +891,15 @@ impl ThreadManager { { let snapshot = snapshot.into(); let history = self.initial_history_from_rollout_path(path).await?; - self.fork_thread_from_history(snapshot, config, history, thread_source, parent_trace) - .await + self.fork_thread_from_history( + snapshot, + config, + history, + thread_source, + parent_trace, + /*supports_openai_form_elicitation*/ false, + ) + .await } async fn initial_history_from_rollout_path( @@ -910,6 +928,7 @@ impl ThreadManager { history: InitialHistory, thread_source: Option, parent_trace: Option, + supports_openai_form_elicitation: bool, ) -> CodexResult where S: Into, @@ -920,6 +939,7 @@ impl ThreadManager { history, thread_source, parent_trace, + supports_openai_form_elicitation, ) .await } @@ -931,6 +951,7 @@ impl ThreadManager { history: InitialHistory, thread_source: Option, parent_trace: Option, + supports_openai_form_elicitation: bool, ) -> CodexResult { // `forked_from_id()` describes this history's existing lineage. When // forking a resumed thread, the child copies the resumed thread itself. @@ -970,6 +991,7 @@ impl ThreadManager { parent_trace, environments, /*thread_extension_init*/ ExtensionDataInit::default(), + supports_openai_form_elicitation, /*user_shell_override*/ None, )) .await @@ -1230,6 +1252,7 @@ impl ThreadManagerState { /*parent_trace*/ None, environments, /*thread_extension_init*/ ExtensionDataInit::default(), + /*supports_openai_form_elicitation*/ false, /*user_shell_override*/ None, )) .await @@ -1267,6 +1290,7 @@ impl ThreadManagerState { /*parent_trace*/ None, environments, /*thread_extension_init*/ ExtensionDataInit::default(), + /*supports_openai_form_elicitation*/ false, /*user_shell_override*/ None, )) .await @@ -1305,6 +1329,7 @@ impl ThreadManagerState { /*parent_trace*/ None, environments, /*thread_extension_init*/ ExtensionDataInit::default(), + /*supports_openai_form_elicitation*/ false, /*user_shell_override*/ None, )) .await @@ -1326,6 +1351,7 @@ impl ThreadManagerState { parent_trace: Option, environments: Vec, thread_extension_init: ExtensionDataInit, + supports_openai_form_elicitation: bool, user_shell_override: Option, ) -> CodexResult { Box::pin(self.spawn_thread_with_source( @@ -1344,6 +1370,7 @@ impl ThreadManagerState { parent_trace, environments, thread_extension_init, + supports_openai_form_elicitation, user_shell_override, )) .await @@ -1367,6 +1394,7 @@ impl ThreadManagerState { parent_trace: Option, environments: Vec, thread_extension_init: ExtensionDataInit, + supports_openai_form_elicitation: bool, user_shell_override: Option, ) -> CodexResult { let is_resumed_thread = matches!(&initial_history, InitialHistory::Resumed(_)); @@ -1434,6 +1462,7 @@ impl ThreadManagerState { parent_trace, environment_selections: environments, thread_extension_init, + supports_openai_form_elicitation, analytics_events_client: self.analytics_events_client.clone(), thread_store: Arc::clone(&self.thread_store), attestation_provider: self.attestation_provider.clone(), diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index be952bbcbc..ee010b5e1d 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -325,6 +325,7 @@ async fn start_thread_keeps_internal_threads_hidden_from_normal_lookups() { parent_trace: None, environments: Vec::new(), thread_extension_init: Default::default(), + supports_openai_form_elicitation: false, }) .await .expect("internal thread should start"); @@ -463,6 +464,7 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors() parent_trace: None, environments: Vec::new(), thread_extension_init: selected_root_init("selected-a", "env-a"), + supports_openai_form_elicitation: false, }) .await .expect("start first thread"); @@ -477,6 +479,7 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors() parent_trace: None, environments: Vec::new(), thread_extension_init: selected_root_init("selected-b", "env-b"), + supports_openai_form_elicitation: false, }) .await .expect("start second thread"); @@ -567,6 +570,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { parent_trace: None, environments: environments.clone(), thread_extension_init: Default::default(), + supports_openai_form_elicitation: false, }) .await .expect("start source thread"); @@ -593,6 +597,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { rollout_path.clone(), auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("resume source thread"); @@ -729,6 +734,7 @@ async fn resume_active_thread_from_rollout_returns_running_thread() { rollout_path, auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("resume active source thread"); @@ -792,6 +798,7 @@ async fn resume_stopped_thread_from_rollout_spawns_new_thread() { rollout_path, auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("resume stopped source thread"); @@ -842,6 +849,7 @@ async fn resume_stopped_thread_from_rollout_preserves_thread_source() { parent_trace: None, environments: Vec::new(), thread_extension_init: Default::default(), + supports_openai_form_elicitation: false, }) .await .expect("start source thread"); @@ -868,6 +876,7 @@ async fn resume_stopped_thread_from_rollout_preserves_thread_source() { rollout_path, auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("resume source thread"); @@ -947,6 +956,7 @@ async fn rollout_path_resume_and_fork_read_history_through_thread_store() { }), auth_manager.clone(), /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("seed rollout path in store"); @@ -963,6 +973,7 @@ async fn rollout_path_resume_and_fork_read_history_through_thread_store() { rollout_path.clone(), auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("resume from rollout path"); @@ -1254,6 +1265,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor ]), auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("create source thread from completed history"); @@ -1368,6 +1380,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { ]), auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("create source thread from explicit partial history"); @@ -1458,6 +1471,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ ]), auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("create source thread from partial history"); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 557164f4de..ed15a7483a 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -2832,6 +2832,7 @@ async fn resume_agent_restores_closed_agent_and_accepts_send_input() { })]), AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy")), /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("start thread"); diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index daf6f79b5c..b10e26bf0c 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -15,6 +15,7 @@ use anyhow::Result; use anyhow::anyhow; use codex_config::CloudConfigBundleLoader; use codex_core::CodexThread; +use codex_core::StartThreadOptions; use codex_core::ThreadManager; use codex_core::config::Config; use codex_core::resolve_installation_id; @@ -39,6 +40,7 @@ use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::Op; use codex_protocol::protocol::RealtimeConversationVersion as RealtimeWsVersion; use codex_protocol::protocol::SandboxPolicy; @@ -258,6 +260,7 @@ pub struct TestCodexBuilder { exec_server_url: Option, extensions: Arc>, user_instructions_provider: Option>, + supports_openai_form_elicitation: bool, } impl TestCodexBuilder { @@ -354,6 +357,11 @@ impl TestCodexBuilder { self } + pub fn with_openai_form_elicitation(mut self) -> Self { + self.supports_openai_form_elicitation = true; + self + } + pub fn with_windows_cmd_shell(self) -> Self { if cfg!(windows) { self.with_user_shell(get_shell_by_model_provided_path(&PathBuf::from("cmd.exe"))) @@ -574,6 +582,7 @@ impl TestCodexBuilder { path, auth_manager, user_shell_override, + self.supports_openai_form_elicitation, ), ) .await? @@ -585,6 +594,7 @@ impl TestCodexBuilder { path, auth_manager, /*parent_trace*/ None, + self.supports_openai_form_elicitation, )) .await? } @@ -594,11 +604,29 @@ impl TestCodexBuilder { thread_manager.as_ref(), config.clone(), user_shell_override, + self.supports_openai_form_elicitation, ), ) .await? } - (None, None) => Box::pin(thread_manager.start_thread(config.clone())).await?, + (None, None) => { + let environments = thread_manager.default_environment_selections(&config.cwd); + Box::pin( + thread_manager.start_thread_with_options(StartThreadOptions { + config: config.clone(), + initial_history: InitialHistory::New, + session_source: None, + thread_source: None, + dynamic_tools: Vec::new(), + metrics_service_name: None, + parent_trace: None, + environments, + thread_extension_init: Default::default(), + supports_openai_form_elicitation: self.supports_openai_form_elicitation, + }), + ) + .await? + } }; Ok(TestCodex { @@ -1143,6 +1171,7 @@ pub fn test_codex() -> TestCodexBuilder { exec_server_url: None, extensions: empty_extension_registry(), user_instructions_provider: None, + supports_openai_form_elicitation: false, } } diff --git a/codex-rs/core/tests/suite/agents_md.rs b/codex-rs/core/tests/suite/agents_md.rs index a4ab404065..5b79e9f1e6 100644 --- a/codex-rs/core/tests/suite/agents_md.rs +++ b/codex-rs/core/tests/suite/agents_md.rs @@ -448,6 +448,7 @@ async fn loads_user_instructions_without_a_primary_environment() -> Result<()> { parent_trace: None, environments: Vec::new(), thread_extension_init: Default::default(), + supports_openai_form_elicitation: false, }) .await?; assert_eq!(provider.load_count(), 2); @@ -664,6 +665,7 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho }, ], thread_extension_init: Default::default(), + supports_openai_form_elicitation: false, }) .await?; assert_eq!(provider.load_count(), 2); diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 909dfb8c82..3270a922b0 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -830,6 +830,7 @@ async fn resume_conversation( path, auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, )) .await .expect("resume conversation") diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index a1fdf79a8e..d51ba7d2f6 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -201,6 +201,7 @@ async fn fork_thread_from_history_does_not_require_source_rollout_path() { }), /*thread_source*/ None, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("fork from stored history"); diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index dccf460f0b..0788709952 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -2267,6 +2267,7 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge InitialHistory::Forked(history), auth_manager_from_auth(CodexAuth::from_api_key("dummy")), /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await?; let codex = resumed_thread.thread; diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 546219ce90..0b9ff204d4 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -110,6 +110,7 @@ async fn emits_warning_when_resumed_model_differs() { initial_history, auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("resume conversation"); diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 656ca69317..2c64b5fba2 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -50,6 +50,7 @@ use core_test_support::assert_regex_match; use core_test_support::responses; use core_test_support::responses::mount_models_once; use core_test_support::responses::mount_sse_once; +use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::skip_if_wine_exec; use core_test_support::stdio_server_bin; @@ -339,13 +340,23 @@ async fn call_cwd_tool( fixture: &TestCodex, server_name: &str, call_id: &str, +) -> anyhow::Result { + call_structured_tool(server, fixture, server_name, "cwd", call_id).await +} + +async fn call_structured_tool( + server: &MockServer, + fixture: &TestCodex, + server_name: &str, + tool_name: &str, + call_id: &str, ) -> anyhow::Result { let namespace = format!("mcp__{server_name}"); mount_sse_once( server, responses::sse(vec![ responses::ev_response_created("resp-1"), - responses::ev_function_call_with_namespace(call_id, &namespace, "cwd", r#"{}"#), + responses::ev_function_call_with_namespace(call_id, &namespace, tool_name, r#"{}"#), responses::ev_completed("resp-1"), ]), ) @@ -353,7 +364,7 @@ async fn call_cwd_tool( mount_sse_once( server, responses::sse(vec![ - responses::ev_assistant_message("msg-1", "rmcp cwd tool completed successfully."), + responses::ev_assistant_message("msg-1", "rmcp tool completed successfully."), responses::ev_completed("resp-2"), ]), ) @@ -361,7 +372,7 @@ async fn call_cwd_tool( fixture .codex - .submit(read_only_user_turn(fixture, "call the rmcp cwd tool")) + .submit(read_only_user_turn(fixture, "call the requested rmcp tool")) .await?; wait_for_event(&fixture.codex, |ev| { @@ -378,7 +389,7 @@ async fn call_cwd_tool( let structured_content = end .result .as_ref() - .expect("rmcp cwd tool should return success") + .expect("rmcp tool should return success") .structured_content .as_ref() .expect("structured content") @@ -388,6 +399,106 @@ async fn call_cwd_tool( Ok(structured_content) } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn openai_form_capability_is_advertised_to_mcp_servers() -> anyhow::Result<()> { + assert_openai_form_capability_advertisement(/*expected*/ true).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn openai_form_capability_is_not_advertised_by_default() -> anyhow::Result<()> { + assert_openai_form_capability_advertisement(/*expected*/ false).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn openai_form_capability_updates_for_loaded_thread() -> anyhow::Result<()> { + skip_if_wine_exec!( + Ok(()), + "requires a Windows test_stdio_server in the Wine-exec environment" + ); + + let server = start_mock_server().await; + let server_name = "capabilities"; + let command = stdio_server_bin()?; + let fixture = test_codex() + .with_config(move |config| { + insert_mcp_server( + config, + server_name, + stdio_transport(command, /*env*/ None, Vec::new()), + TestMcpServerOptions::default(), + ); + }) + .build(&server) + .await?; + wait_for_mcp_server(&fixture.codex, server_name).await?; + + let unsupported = call_structured_tool( + &server, + &fixture, + server_name, + "client_capabilities", + "call-client-capabilities-unsupported", + ) + .await?; + assert_eq!( + unsupported, + json!({ "supportsOpenaiFormElicitation": false }) + ); + + fixture + .codex + .set_openai_form_elicitation_support(/*supported*/ true) + .await?; + let supported = call_structured_tool( + &server, + &fixture, + server_name, + "client_capabilities", + "call-client-capabilities-supported", + ) + .await?; + assert_eq!(supported, json!({ "supportsOpenaiFormElicitation": true })); + Ok(()) +} + +async fn assert_openai_form_capability_advertisement(expected: bool) -> anyhow::Result<()> { + skip_if_wine_exec!( + Ok(()), + "requires a Windows test_stdio_server in the Wine-exec environment" + ); + + let server = start_mock_server().await; + let server_name = "capabilities"; + let command = stdio_server_bin()?; + let mut builder = test_codex().with_config(move |config| { + insert_mcp_server( + config, + server_name, + stdio_transport(command, /*env*/ None, Vec::new()), + TestMcpServerOptions::default(), + ); + }); + if expected { + builder = builder.with_openai_form_elicitation(); + } + let fixture = builder.build(&server).await?; + wait_for_mcp_server(&fixture.codex, server_name).await?; + + let structured = call_structured_tool( + &server, + &fixture, + server_name, + "client_capabilities", + "call-client-capabilities", + ) + .await?; + assert_eq!( + structured, + json!({ "supportsOpenaiFormElicitation": expected }) + ); + Ok(()) +} + fn assert_cwd_tool_output(structured: &Value, expected_cwd: &Path) { let actual_cwd = structured .get("cwd") diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 2501aa0dde..a31d7e3616 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -754,6 +754,7 @@ async fn subagent_stop_replaces_stop_and_skips_internal_subagents() -> Result<() parent_trace: None, environments: Vec::new(), thread_extension_init: Default::default(), + supports_openai_form_elicitation: false, }) .await?; diff --git a/codex-rs/core/tests/suite/unstable_features_warning.rs b/codex-rs/core/tests/suite/unstable_features_warning.rs index e66c674f92..783acc638b 100644 --- a/codex-rs/core/tests/suite/unstable_features_warning.rs +++ b/codex-rs/core/tests/suite/unstable_features_warning.rs @@ -47,6 +47,7 @@ async fn emits_warning_when_unstable_features_enabled_via_config() { InitialHistory::New, auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("spawn conversation"); @@ -93,6 +94,7 @@ async fn suppresses_warning_when_configured() { InitialHistory::New, auth_manager, /*parent_trace*/ None, + /*supports_openai_form_elicitation*/ false, ) .await .expect("spawn conversation"); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 05bd5cc1df..a1f4b95b50 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -556,6 +556,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result client_name: "codex_exec".to_string(), client_version: env!("CARGO_PKG_VERSION").to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: false, opt_out_notification_methods: Vec::new(), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }; diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index 8dae6ca617..1d8fcb567b 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -313,6 +313,7 @@ impl MemoryStartupContext { parent_trace: None, environments, thread_extension_init: Default::default(), + supports_openai_form_elicitation: false, }) .await?; diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index 314f6bd923..5cf4f27f18 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -342,6 +342,15 @@ pub enum ElicitationRequest { message: String, requested_schema: JsonValue, }, + #[serde(rename = "openai/form")] + #[ts(rename = "openai/form")] + OpenAiForm { + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + #[ts(optional, rename = "_meta")] + meta: Option, + message: String, + requested_schema: JsonValue, + }, Url { #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] #[ts(optional, rename = "_meta")] @@ -355,7 +364,9 @@ pub enum ElicitationRequest { impl ElicitationRequest { pub fn message(&self) -> &str { match self { - Self::Form { message, .. } | Self::Url { message, .. } => message, + Self::Form { message, .. } + | Self::OpenAiForm { message, .. } + | Self::Url { message, .. } => message, } } } diff --git a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs index 4407a27eeb..9ff36e349d 100644 --- a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs @@ -4,6 +4,8 @@ use std::collections::HashMap; use std::collections::hash_map::Entry; use std::sync::Arc; use std::sync::OnceLock; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; use std::time::Duration; use rmcp::ErrorData as McpError; @@ -11,6 +13,8 @@ use rmcp::ServiceExt; use rmcp::handler::server::ServerHandler; use rmcp::model::CallToolRequestParams; use rmcp::model::CallToolResult; +use rmcp::model::InitializeRequestParams; +use rmcp::model::InitializeResult; use rmcp::model::JsonObject; use rmcp::model::ListResourceTemplatesResult; use rmcp::model::ListResourcesResult; @@ -38,6 +42,7 @@ struct TestToolServer { tools: Arc>, resources: Arc>, resource_templates: Arc>, + supports_openai_form_elicitation: Arc, } const MEMO_URI: &str = "memo://codex/example-note"; @@ -68,6 +73,7 @@ impl TestToolServer { let tools = vec![ Self::echo_tool(), Self::echo_dash_tool(), + Self::client_capabilities_tool(), Self::cwd_tool(), Self::sync_tool(), Self::sync_readonly_tool(), @@ -81,6 +87,7 @@ impl TestToolServer { tools: Arc::new(tools), resources: Arc::new(resources), resource_templates: Arc::new(resource_templates), + supports_openai_form_elicitation: Arc::new(AtomicBool::new(false)), } } @@ -166,6 +173,24 @@ impl TestToolServer { tool } + fn client_capabilities_tool() -> Tool { + #[expect(clippy::expect_used)] + let schema: JsonObject = serde_json::from_value(serde_json::json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + })) + .expect("client capabilities tool schema should deserialize"); + + let mut tool = Tool::new( + Cow::Borrowed("client_capabilities"), + Cow::Borrowed("Return capabilities advertised by the MCP client."), + Arc::new(schema), + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + tool + } + fn sync_tool() -> Tool { #[expect(clippy::expect_used)] let schema: JsonObject = serde_json::from_value(json!({ @@ -396,6 +421,23 @@ struct ImageScenarioArgs { } impl ServerHandler for TestToolServer { + async fn initialize( + &self, + request: InitializeRequestParams, + context: rmcp::service::RequestContext, + ) -> Result { + self.supports_openai_form_elicitation.store( + request + .capabilities + .extensions + .as_ref() + .is_some_and(|extensions| extensions.contains_key("openai/form")), + Ordering::Relaxed, + ); + context.peer.set_peer_info(request); + Ok(self.get_info()) + } + fn get_info(&self) -> ServerInfo { let mut capabilities = ServerCapabilities::builder() .enable_tools() @@ -481,6 +523,11 @@ impl ServerHandler for TestToolServer { context: rmcp::service::RequestContext, ) -> Result { match request.name.as_ref() { + "client_capabilities" => Ok(Self::structured_result(json!({ + "supportsOpenaiFormElicitation": self + .supports_openai_form_elicitation + .load(Ordering::Relaxed), + }))), "sandbox_meta" => Ok(Self::structured_result(serde_json::Value::Object( context.meta.0, ))), diff --git a/codex-rs/rmcp-client/src/elicitation_client_service.rs b/codex-rs/rmcp-client/src/elicitation_client_service.rs index 49f11f0a76..2227ee4d63 100644 --- a/codex-rs/rmcp-client/src/elicitation_client_service.rs +++ b/codex-rs/rmcp-client/src/elicitation_client_service.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use rmcp::RoleClient; use rmcp::model::ClientInfo; use rmcp::model::ClientResult; +use rmcp::model::CustomRequest; use rmcp::model::CustomResult; use rmcp::model::ElicitationAction; use rmcp::model::Meta; @@ -12,7 +13,9 @@ use rmcp::model::ServerRequest; use rmcp::service::NotificationContext; use rmcp::service::RequestContext; use rmcp::service::Service; +use serde::Deserialize; use serde::Serialize; +use serde_json::Map; use serde_json::Value; use crate::logging_client_handler::LoggingClientHandler; @@ -22,10 +25,21 @@ use crate::rmcp_client::ElicitationResponse; use crate::rmcp_client::SendElicitation; const MCP_PROGRESS_TOKEN_META_KEY: &str = "progressToken"; +const OPENAI_FORM_METHOD: &str = "openai/form"; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct OpenAiFormRequestParams { + #[serde(rename = "_meta")] + meta: Option, + message: String, + requested_schema: Value, +} #[derive(Clone)] pub(crate) struct ElicitationClientService { handler: LoggingClientHandler, + supports_openai_form: bool, send_elicitation: Arc, pause_state: ElicitationPauseState, } @@ -36,12 +50,18 @@ impl ElicitationClientService { send_elicitation: SendElicitation, pause_state: ElicitationPauseState, ) -> Self { + let supports_openai_form = client_info + .capabilities + .extensions + .as_ref() + .is_some_and(|extensions| extensions.contains_key(OPENAI_FORM_METHOD)); let send_elicitation = Arc::new(send_elicitation); Self { handler: LoggingClientHandler::new( client_info, clone_send_elicitation(Arc::clone(&send_elicitation)), ), + supports_openai_form, send_elicitation, pause_state, } @@ -73,11 +93,23 @@ impl Service for ElicitationClientService { ) -> Result { match request { ServerRequest::CreateElicitationRequest(request) => { - let response = self.create_elicitation(request.params, context).await?; + let response = self + .create_elicitation(Elicitation::Mcp(request.params), context) + .await?; // RMCP's typed CreateElicitationResult does not model result-level `_meta`. let result = elicitation_response_result(response)?; Ok(ClientResult::CustomResult(result)) } + ServerRequest::CustomRequest(request) + if request.method == OPENAI_FORM_METHOD && self.supports_openai_form => + { + let response = self + .create_elicitation(openai_form_elicitation(request)?, context) + .await?; + Ok(ClientResult::CustomResult(elicitation_response_result( + response, + )?)) + } request => { >::handle_request( &self.handler, @@ -107,6 +139,18 @@ impl Service for ElicitationClientService { } } +fn openai_form_elicitation(request: CustomRequest) -> Result { + let params = request + .params_as::() + .map_err(|err| rmcp::ErrorData::invalid_params(err.to_string(), None))? + .ok_or_else(|| rmcp::ErrorData::invalid_params("missing params", None))?; + Ok(Elicitation::OpenAiForm { + meta: params.meta, + message: params.message, + requested_schema: params.requested_schema, + }) +} + fn restore_context_meta(mut request: Elicitation, mut context_meta: Meta) -> Elicitation { // RMCP lifts JSON-RPC `_meta` into RequestContext before invoking services. context_meta.remove(MCP_PROGRESS_TOKEN_META_KEY); @@ -114,10 +158,20 @@ fn restore_context_meta(mut request: Elicitation, mut context_meta: Meta) -> Eli return request; } - request - .meta_mut() - .get_or_insert_with(Meta::new) - .extend(context_meta); + match &mut request { + Elicitation::Mcp(request) => request + .meta_mut() + .get_or_insert_with(Meta::new) + .extend(context_meta), + Elicitation::OpenAiForm { meta, .. } => { + let meta = meta + .get_or_insert_with(|| Value::Object(Map::new())) + .as_object_mut(); + if let Some(meta) = meta { + meta.extend(context_meta.0); + } + } + } request } @@ -165,7 +219,7 @@ mod tests { #[test] fn restore_context_meta_adds_elicitation_meta_and_removes_progress_token() { let request = restore_context_meta( - form_request(/*meta*/ None), + Elicitation::Mcp(form_request(/*meta*/ None)), meta(json!({ "progressToken": "progress-token", "persist": ["session", "always"], @@ -174,9 +228,54 @@ mod tests { assert_eq!( request, - form_request(Some(meta(json!({ + Elicitation::Mcp(form_request(Some(meta(json!({ "persist": ["session", "always"], - })))) + }))))) + ); + } + + #[test] + fn parses_openai_form_custom_requests() { + let elicitation = openai_form_elicitation(CustomRequest::new( + OPENAI_FORM_METHOD, + Some(json!({ + "message": "Select a template", + "requestedSchema": { + "type": "object", + "properties": { + "template": { + "type": "openai/imagePicker", + "items": [{ + "id": "monthly-review", + "title": "Monthly review", + "image": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=" + }] + } + } + } + })), + )) + .expect("valid openai/form request"); + + assert_eq!( + elicitation, + Elicitation::OpenAiForm { + meta: None, + message: "Select a template".to_string(), + requested_schema: json!({ + "type": "object", + "properties": { + "template": { + "type": "openai/imagePicker", + "items": [{ + "id": "monthly-review", + "title": "Monthly review", + "image": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=" + }] + } + } + }), + } ); } diff --git a/codex-rs/rmcp-client/src/logging_client_handler.rs b/codex-rs/rmcp-client/src/logging_client_handler.rs index 0c3da0fe2c..e575966cff 100644 --- a/codex-rs/rmcp-client/src/logging_client_handler.rs +++ b/codex-rs/rmcp-client/src/logging_client_handler.rs @@ -17,6 +17,7 @@ use tracing::error; use tracing::info; use tracing::warn; +use crate::rmcp_client::Elicitation; use crate::rmcp_client::SendElicitation; #[derive(Clone)] @@ -40,7 +41,7 @@ impl ClientHandler for LoggingClientHandler { request: CreateElicitationRequestParams, context: RequestContext, ) -> Result { - (self.send_elicitation)(context.id, request) + (self.send_elicitation)(context.id, Elicitation::Mcp(request)) .await .map(Into::into) .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None)) diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index e9b0148622..4a9b89de3c 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -40,6 +40,7 @@ use rmcp::model::PaginatedRequestParams; use rmcp::model::ReadResourceRequestParams; use rmcp::model::ReadResourceResult; use rmcp::model::RequestId; +use rmcp::model::RequestParamsMeta; use rmcp::model::ServerResult; use rmcp::model::Tool; use rmcp::service::RoleClient; @@ -251,7 +252,24 @@ fn remaining_operation_timeout( } } -pub type Elicitation = CreateElicitationRequestParams; +#[derive(Debug, Clone, PartialEq)] +pub enum Elicitation { + Mcp(CreateElicitationRequestParams), + OpenAiForm { + meta: Option, + message: String, + requested_schema: serde_json::Value, + }, +} + +impl Elicitation { + pub fn meta(&self) -> Option<&serde_json::Map> { + match self { + Self::Mcp(request) => request.meta().map(|meta| &meta.0), + Self::OpenAiForm { meta, .. } => meta.as_ref().and_then(serde_json::Value::as_object), + } + } +} #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index f436ac51b3..76cf56ef06 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -292,7 +292,10 @@ impl App { message: message.clone(), }, )), - codex_app_server_protocol::McpServerElicitationRequest::Url { .. } => { + codex_app_server_protocol::McpServerElicitationRequest::OpenAiForm { + .. + } + | codex_app_server_protocol::McpServerElicitationRequest::Url { .. } => { self.app_event_tx.resolve_elicitation( thread_id, params.server_name.clone(), diff --git a/codex-rs/tui/src/chatwidget/tool_requests.rs b/codex-rs/tui/src/chatwidget/tool_requests.rs index 99b0c65207..f3ff2391f6 100644 --- a/codex-rs/tui/src/chatwidget/tool_requests.rs +++ b/codex-rs/tui/src/chatwidget/tool_requests.rs @@ -376,7 +376,8 @@ impl ChatWidget { self.bottom_pane .push_approval_request(request, &self.config.features); } - McpServerElicitationRequest::Url { .. } => { + McpServerElicitationRequest::OpenAiForm { .. } + | McpServerElicitationRequest::Url { .. } => { self.app_event_tx.resolve_elicitation( thread_id, params.server_name, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index b5540f878b..55cf893076 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -399,6 +399,7 @@ async fn connect_remote_app_server( client_name: "codex-tui".to_string(), client_version: env!("CARGO_PKG_VERSION").to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: false, opt_out_notification_methods: Vec::new(), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) @@ -562,6 +563,7 @@ where client_name: "codex-tui".to_string(), client_version: env!("CARGO_PKG_VERSION").to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: false, opt_out_notification_methods: Vec::new(), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index fceb7fb059..0170ae49d4 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1055,6 +1055,7 @@ mod tests { client_name: "test".to_string(), client_version: "test".to_string(), experimental_api: true, + mcp_server_openai_form_elicitation: false, opt_out_notification_methods: Vec::new(), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) From 01a2df29479e8e982a5b6a9097d21814ad24000f Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 18 Jun 2026 12:13:31 -0700 Subject: [PATCH 040/112] [codex] Make thread store turn filter optional (#28949) Make `ListItemsParams::turn_id` optional so callers can list persisted items across an entire thread or narrow the result to one turn. This aligns the thread-store API and documentation with thread-wide item listing while preserving the optional turn-filter behavior for implementations. --- codex-rs/thread-store/src/in_memory.rs | 2 +- codex-rs/thread-store/src/store.rs | 2 +- codex-rs/thread-store/src/types.rs | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index 7eda54a9c0..a60409b740 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -79,7 +79,7 @@ mod tests { let items_err = store .list_items(ListItemsParams { thread_id, - turn_id: "turn_1".to_string(), + turn_id: None, include_archived: true, cursor: None, page_size: 10, diff --git a/codex-rs/thread-store/src/store.rs b/codex-rs/thread-store/src/store.rs index ef02e85d50..65ad1ce8a8 100644 --- a/codex-rs/thread-store/src/store.rs +++ b/codex-rs/thread-store/src/store.rs @@ -102,7 +102,7 @@ pub trait ThreadStore: Any + Send + Sync { }) } - /// Lists persisted items within a stored turn. + /// Lists persisted items within a stored thread, optionally filtered to a turn. fn list_items(&self, _params: ListItemsParams) -> ThreadStoreFuture<'_, ItemPage> { Box::pin(async { Err(ThreadStoreError::Unsupported { diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index b969c6b957..2e3c60723f 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -334,13 +334,13 @@ pub struct TurnPage { pub backwards_cursor: Option, } -/// Parameters for listing persisted items within a single turn. +/// Parameters for listing persisted items within a thread. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ListItemsParams { /// Thread id to read. pub thread_id: ThreadId, - /// Turn id to hydrate. - pub turn_id: String, + /// Optional turn id to filter by. When omitted, returns items across the thread. + pub turn_id: Option, /// Whether archived threads are eligible. pub include_archived: bool, /// Opaque cursor returned by a previous list call. @@ -361,7 +361,7 @@ pub struct StoredThreadItem { pub materialized_thread_item_json: Vec, } -/// A page of persisted items within a turn. +/// A page of persisted items within a thread, optionally filtered to a turn. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ItemPage { /// Items returned for this page. From 752ed90d78ad9444dd449fbc8e71ad2121865be0 Mon Sep 17 00:00:00 2001 From: rka-oai Date: Thu, 18 Jun 2026 12:18:42 -0700 Subject: [PATCH 041/112] current time reminders impl for system clock (varlatency 2/n) (#28824) Stacked on #28822. ## Summary - add a host-injectable current-time provider with a built-in system implementation - record UTC developer reminders in history immediately before due model requests - keep cadence state per session and force a refresh after compaction This does NOT include the app server client <-> server clock logic. This PR is only for the reminder message & system clock that will be used in prod. ## Testing - `just test -p codex-core varlatency_` - `just clippy -p codex-core -p codex-app-server -p codex-mcp-server -p codex-thread-manager-sample` - `just fmt` --- codex-rs/app-server/src/mcp_refresh.rs | 1 + codex-rs/app-server/src/message_processor.rs | 1 + codex-rs/core/src/codex_delegate.rs | 1 + .../core/src/context/current_time_reminder.rs | 35 +++ codex-rs/core/src/context/mod.rs | 2 + codex-rs/core/src/current_time.rs | 41 +++ codex-rs/core/src/lib.rs | 3 + codex-rs/core/src/prompt_debug.rs | 1 + codex-rs/core/src/session/mod.rs | 5 + codex-rs/core/src/session/session.rs | 6 + codex-rs/core/src/session/tests.rs | 5 + .../core/src/session/tests/guardian_tests.rs | 1 + codex-rs/core/src/session/time_reminder.rs | 61 ++++ codex-rs/core/src/session/turn.rs | 70 +++-- codex-rs/core/src/state/service.rs | 2 + codex-rs/core/src/state/session.rs | 3 + codex-rs/core/src/thread_manager.rs | 6 + codex-rs/core/src/thread_manager_tests.rs | 11 + .../src/tools/handlers/multi_agents_tests.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 9 + codex-rs/core/tests/suite/client.rs | 1 + .../core/tests/suite/current_time_reminder.rs | 274 ++++++++++++++++++ codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/mcp-server/src/message_processor.rs | 1 + codex-rs/thread-manager-sample/src/main.rs | 1 + 25 files changed, 516 insertions(+), 27 deletions(-) create mode 100644 codex-rs/core/src/context/current_time_reminder.rs create mode 100644 codex-rs/core/src/current_time.rs create mode 100644 codex-rs/core/src/session/time_reminder.rs create mode 100644 codex-rs/core/tests/suite/current_time_reminder.rs diff --git a/codex-rs/app-server/src/mcp_refresh.rs b/codex-rs/app-server/src/mcp_refresh.rs index a5659174f4..84a6b8e83b 100644 --- a/codex-rs/app-server/src/mcp_refresh.rs +++ b/codex-rs/app-server/src/mcp_refresh.rs @@ -250,6 +250,7 @@ mod tests { Some(state_db.clone()), "11111111-1111-4111-8111-111111111111".to_string(), /*attestation_provider*/ None, + /*external_time_provider*/ None, ) }); thread_manager.start_thread(good_config).await?; diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 9e9437bd34..0f9f6d3321 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -379,6 +379,7 @@ impl MessageProcessor { outgoing.clone(), thread_state_manager.clone(), )), + /*external_time_provider*/ None, ) }); let models_manager = thread_manager.get_models_manager(); diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index b7b1fe7580..dab902ec37 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -120,6 +120,7 @@ pub(crate) async fn run_codex_thread_interactive( analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), thread_store: Arc::clone(&parent_session.services.thread_store), attestation_provider: parent_session.services.attestation_provider.clone(), + external_time_provider: Some(Arc::clone(&parent_session.services.time_provider)), inherited_multi_agent_version: Some(MultiAgentVersion::Disabled), })) .or_cancel(&cancel_token) diff --git a/codex-rs/core/src/context/current_time_reminder.rs b/codex-rs/core/src/context/current_time_reminder.rs new file mode 100644 index 0000000000..0e3fe0b1e8 --- /dev/null +++ b/codex-rs/core/src/context/current_time_reminder.rs @@ -0,0 +1,35 @@ +use chrono::DateTime; +use chrono::Utc; + +use super::ContextualUserFragment; + +pub(crate) struct CurrentTimeReminder { + current_time: DateTime, +} + +impl CurrentTimeReminder { + pub(crate) fn new(current_time: DateTime) -> Self { + Self { current_time } + } +} + +impl ContextualUserFragment for CurrentTimeReminder { + fn role(&self) -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } + + fn body(&self) -> String { + format!( + "It is {}.", + self.current_time.format("%Y-%m-%d %H:%M:%S UTC") + ) + } +} diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index 7fc1375781..3c2f487231 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -6,6 +6,7 @@ mod available_plugins_instructions; mod available_skills_instructions; mod collaboration_mode_instructions; mod contextual_user_message; +mod current_time_reminder; mod environment_context; mod guardian_followup_review_reminder; mod hook_additional_context; @@ -44,6 +45,7 @@ pub(crate) use codex_core_skills::SkillInstructions; pub(crate) use collaboration_mode_instructions::CollaborationModeInstructions; pub(crate) use contextual_user_message::is_contextual_user_fragment; pub(crate) use contextual_user_message::parse_visible_hook_prompt_message; +pub(crate) use current_time_reminder::CurrentTimeReminder; pub(crate) use environment_context::EnvironmentContext; pub(crate) use guardian_followup_review_reminder::GuardianFollowupReviewReminder; pub(crate) use hook_additional_context::HookAdditionalContext; diff --git a/codex-rs/core/src/current_time.rs b/codex-rs/core/src/current_time.rs new file mode 100644 index 0000000000..7740552e1e --- /dev/null +++ b/codex-rs/core/src/current_time.rs @@ -0,0 +1,41 @@ +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use anyhow::Result; +use anyhow::anyhow; +use chrono::DateTime; +use chrono::Utc; +use codex_features::CurrentTimeSource; +use codex_protocol::ThreadId; + +use crate::config::CurrentTimeReminderConfig; + +pub type TimeFuture<'a> = Pin>> + Send + 'a>>; + +/// Host integration boundary for obtaining the current time. +pub trait TimeProvider: Send + Sync { + fn current_time(&self, thread_id: ThreadId) -> TimeFuture<'_>; +} + +pub(crate) struct SystemTimeProvider; + +impl TimeProvider for SystemTimeProvider { + fn current_time(&self, _thread_id: ThreadId) -> TimeFuture<'_> { + Box::pin(async { Ok(Utc::now()) }) + } +} + +pub(crate) fn resolve_time_provider( + config: Option<&CurrentTimeReminderConfig>, + external_provider: Option>, +) -> Result> { + match config.map(|config| config.clock_source).unwrap_or_default() { + CurrentTimeSource::System => Ok(Arc::new(SystemTimeProvider)), + CurrentTimeSource::External => external_provider.ok_or_else(|| { + anyhow!( + "features.current_time_reminder.clock_source is external, but no external current-time provider is available" + ) + }), + } +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 0e193baa1d..121c8d2be2 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -37,6 +37,7 @@ pub mod config; pub mod connectors; pub mod context; mod context_manager; +mod current_time; mod environment_selection; pub mod exec; pub mod exec_env; @@ -186,6 +187,8 @@ pub use client_common::ResponseEvent; pub use client_common::ResponseStream; pub use codex_prompts::REVIEW_PROMPT; pub use compact::content_items_to_text; +pub use current_time::TimeFuture; +pub use current_time::TimeProvider; pub use event_mapping::parse_turn_item; pub use exec_policy::ExecPolicyError; pub use exec_policy::check_execpolicy_for_warnings; diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index 0da42d8a4e..bd9289b770 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -60,6 +60,7 @@ pub async fn build_prompt_input( state_db.clone(), installation_id, /*attestation_provider*/ None, + /*external_time_provider*/ None, ); let thread = thread_manager.start_thread(config).await?; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index f92bd63680..b55c4b11d7 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -31,6 +31,7 @@ use crate::context::NetworkRuleSaved; use crate::context::PermissionsInstructions; use crate::context::PersonalitySpecInstructions; use crate::context::RecommendedPluginsInstructions; +use crate::current_time::TimeProvider; use crate::default_skill_metadata_budget; use crate::environment_selection::TurnEnvironmentSnapshot; use crate::exec_policy::ExecPolicyManager; @@ -215,6 +216,7 @@ mod rollout_budget; mod rollout_reconstruction; #[allow(clippy::module_inception)] pub(crate) mod session; +pub(crate) mod time_reminder; mod token_budget; pub(crate) mod turn; pub(crate) mod turn_context; @@ -439,6 +441,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) analytics_events_client: Option, pub(crate) thread_store: Arc, pub(crate) attestation_provider: Option>, + pub(crate) external_time_provider: Option>, pub(crate) inherited_multi_agent_version: Option, } @@ -522,6 +525,7 @@ impl Codex { analytics_events_client, thread_store, attestation_provider, + external_time_provider, inherited_multi_agent_version, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); @@ -669,6 +673,7 @@ impl Codex { thread_store, parent_rollout_thread_trace, attestation_provider, + external_time_provider, multi_agent_version, )) .await diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 2b61468c15..af2d1abeb8 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -492,6 +492,7 @@ impl Session { thread_store: Arc, parent_rollout_thread_trace: ThreadTraceContext, attestation_provider: Option>, + external_time_provider: Option>, multi_agent_version: Option, ) -> anyhow::Result> { debug!( @@ -516,6 +517,10 @@ impl Session { } InitialHistory::Resumed(resumed_history) => resumed_history.conversation_id, }; + let time_provider = crate::current_time::resolve_time_provider( + config.current_time_reminder.as_ref(), + external_time_provider, + )?; let mcp_thread_init = thread_extension_init.clone(); let thread_extension_data = codex_extension_api::ExtensionData::new_with_init( thread_id.to_string(), @@ -1022,6 +1027,7 @@ impl Session { live_thread: live_thread_init.as_ref().cloned(), thread_store: Arc::clone(&thread_store), attestation_provider: attestation_provider.clone(), + time_provider, model_client: ModelClient::new( Some(Arc::clone(&auth_manager)), thread_id, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index b13cd029ce..0b1cc045c4 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -4867,6 +4867,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { )), codex_rollout_trace::ThreadTraceContext::disabled(), /*attestation_provider*/ None, + /*external_time_provider*/ None, Some(config.multi_agent_version_from_features()), ) .await; @@ -5032,6 +5033,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*state_db*/ None, )), attestation_provider: None, + time_provider: Arc::new(crate::current_time::SystemTimeProvider), model_client: ModelClient::new( Some(auth_manager.clone()), thread_id, @@ -5216,6 +5218,7 @@ async fn make_session_with_config_and_rx( )), codex_rollout_trace::ThreadTraceContext::disabled(), /*attestation_provider*/ None, + /*external_time_provider*/ None, Some(config.multi_agent_version_from_features()), ) .await?; @@ -5328,6 +5331,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( )), codex_rollout_trace::ThreadTraceContext::disabled(), /*attestation_provider*/ None, + /*external_time_provider*/ None, Some(config.multi_agent_version_from_features()), ) .await?; @@ -7078,6 +7082,7 @@ where state_db, )), attestation_provider: None, + time_provider: Arc::new(crate::current_time::SystemTimeProvider), model_client: ModelClient::new( Some(Arc::clone(&auth_manager)), thread_id, diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 0bed032351..5de331ee1f 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -736,6 +736,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { analytics_events_client: None, thread_store, attestation_provider: None, + external_time_provider: None, inherited_multi_agent_version: None, }) .await diff --git a/codex-rs/core/src/session/time_reminder.rs b/codex-rs/core/src/session/time_reminder.rs new file mode 100644 index 0000000000..058132a895 --- /dev/null +++ b/codex-rs/core/src/session/time_reminder.rs @@ -0,0 +1,61 @@ +use codex_protocol::error::CodexErr; +use codex_protocol::error::Result as CodexResult; + +use super::session::Session; +use super::turn_context::TurnContext; +use crate::context::ContextualUserFragment; + +#[derive(Default)] +pub(crate) struct CurrentTimeReminderState { + model_requests_since_delivery: u64, + last_window_id: Option, +} + +impl CurrentTimeReminderState { + fn take_reminder_due(&mut self, window_id: &str, interval: u64) -> bool { + self.model_requests_since_delivery = self.model_requests_since_delivery.saturating_add(1); + let reminder_is_due = self.last_window_id.as_deref() != Some(window_id) + || self.model_requests_since_delivery >= interval; + + if reminder_is_due { + self.model_requests_since_delivery = 0; + self.last_window_id = Some(window_id.to_string()); + } + + reminder_is_due + } +} + +pub(super) async fn maybe_record_current_time_reminder( + sess: &Session, + turn_context: &TurnContext, + window_id: &str, +) -> CodexResult<()> { + let Some(config) = turn_context.config.current_time_reminder else { + return Ok(()); + }; + + let reminder_is_due = { + let mut state = sess.state.lock().await; + state + .current_time_reminder + .take_reminder_due(window_id, config.reminder_interval_model_requests) + }; + if !reminder_is_due { + return Ok(()); + } + + let current_time = sess + .services + .time_provider + .current_time(sess.thread_id) + .await + .map_err(|err| CodexErr::Fatal(format!("failed to read current time: {err:#}")))?; + + let response_item = + ContextualUserFragment::into(crate::context::CurrentTimeReminder::new(current_time)); + sess.record_conversation_items(turn_context, std::slice::from_ref(&response_item)) + .await; + + Ok(()) +} diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 657e00ff35..25e4e1a6af 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -226,34 +226,50 @@ pub(crate) async fn run_turn( ) .await; - // Construct the input that we will send to the model. - let sampling_request_input: Vec = async { - sess.clone_history() - .await - .for_prompt(&turn_context.model_info.input_modalities) - } - .instrument(trace_span!("run_turn.prepare_sampling_request_input")) - .await; + let sampling_request_result: CodexResult<_> = async { + super::time_reminder::maybe_record_current_time_reminder( + sess.as_ref(), + turn_context.as_ref(), + &window_id, + ) + .await?; - let responses_metadata = turn_context.turn_metadata_state.to_responses_metadata( - sess.installation_id.clone(), - window_id, - CodexResponsesRequestKind::Turn, - ); - let tokens_before_sampling = sess.get_total_token_usage().await; - match run_sampling_request( - Arc::clone(&sess), - Arc::clone(&turn_context), - Arc::clone(&turn_extension_data), - Arc::clone(&turn_diff_tracker), - &mut client_session, - &responses_metadata, - sampling_request_input, - cancellation_token.child_token(), - ) - .await - { - Ok((sampling_request_output, sampling_request_input)) => { + // Construct the input that we will send to the model. + let sampling_request_input: Vec = async { + sess.clone_history() + .await + .for_prompt(&turn_context.model_info.input_modalities) + } + .instrument(trace_span!("run_turn.prepare_sampling_request_input")) + .await; + + let responses_metadata = turn_context.turn_metadata_state.to_responses_metadata( + sess.installation_id.clone(), + window_id, + CodexResponsesRequestKind::Turn, + ); + let tokens_before_sampling = sess.get_total_token_usage().await; + let (sampling_request_output, sampling_request_input) = run_sampling_request( + Arc::clone(&sess), + Arc::clone(&turn_context), + Arc::clone(&turn_extension_data), + Arc::clone(&turn_diff_tracker), + &mut client_session, + &responses_metadata, + sampling_request_input, + cancellation_token.child_token(), + ) + .await?; + + Ok(( + tokens_before_sampling, + sampling_request_output, + sampling_request_input, + )) + } + .await; + match sampling_request_result { + Ok((tokens_before_sampling, sampling_request_output, sampling_request_input)) => { let SamplingRequestResult { needs_follow_up: model_needs_follow_up, last_agent_message: sampling_request_last_agent_message, diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 914fd4c32d..853b96e921 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -8,6 +8,7 @@ use crate::attestation::AttestationProvider; use crate::client::ModelClient; use crate::config::NetworkProxyAuditMetadata; use crate::config::StartedNetworkProxy; +use crate::current_time::TimeProvider; use crate::environment_selection::ThreadEnvironments; use crate::exec_policy::ExecPolicyManager; use crate::guardian::GuardianRejection; @@ -79,6 +80,7 @@ pub(crate) struct SessionServices { pub(crate) live_thread: Option, pub(crate) thread_store: Arc, pub(crate) attestation_provider: Option>, + pub(crate) time_provider: Arc, /// Session-scoped model client shared across turns. pub(crate) model_client: ModelClient, pub(crate) code_mode_service: CodeModeService, diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 269d3e0f60..2bd9da5ce3 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -13,6 +13,7 @@ use super::auto_compact_window::AutoCompactWindowSnapshot; use crate::context_manager::ContextManager; use crate::session::PreviousTurnSettings; use crate::session::session::SessionConfiguration; +use crate::session::time_reminder::CurrentTimeReminderState; use crate::session_startup_prewarm::SessionStartupPrewarmHandle; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::TokenUsage; @@ -36,6 +37,7 @@ pub(crate) struct SessionState { auto_compact_window: AutoCompactWindow, /// Startup prewarmed session prepared during session initialization. pub(crate) startup_prewarm: Option, + pub(crate) current_time_reminder: CurrentTimeReminderState, pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_sources: VecDeque, granted_permissions_by_environment_id: HashMap, @@ -56,6 +58,7 @@ impl SessionState { previous_turn_settings: None, auto_compact_window: AutoCompactWindow::new(), startup_prewarm: None, + current_time_reminder: CurrentTimeReminderState::default(), active_connector_selection: HashSet::new(), pending_session_start_sources: VecDeque::new(), granted_permissions_by_environment_id: HashMap::new(), diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 77a5520026..814c8fe764 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -4,6 +4,7 @@ use crate::attestation::AttestationProvider; use crate::codex_thread::CodexThread; use crate::config::Config; use crate::config::ThreadStoreConfig; +use crate::current_time::TimeProvider; use crate::environment_selection::TurnEnvironmentSnapshot; use crate::environment_selection::default_thread_environment_selections; use crate::mcp::McpManager; @@ -215,6 +216,7 @@ pub(crate) struct ThreadManagerState { user_instructions_provider: Arc, thread_store: Arc, attestation_provider: Option>, + external_time_provider: Option>, session_source: SessionSource, installation_id: String, analytics_events_client: Option, @@ -269,6 +271,7 @@ impl ThreadManager { state_db: Option, installation_id: String, attestation_provider: Option>, + external_time_provider: Option>, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -300,6 +303,7 @@ impl ThreadManager { user_instructions_provider, thread_store, attestation_provider, + external_time_provider, auth_manager, session_source, installation_id, @@ -405,6 +409,7 @@ impl ThreadManager { ), thread_store, attestation_provider: None, + external_time_provider: None, auth_manager, session_source: SessionSource::Exec, installation_id, @@ -1466,6 +1471,7 @@ impl ThreadManagerState { analytics_events_client: self.analytics_events_client.clone(), thread_store: Arc::clone(&self.thread_store), attestation_provider: self.attestation_provider.clone(), + external_time_provider: self.external_time_provider.clone(), inherited_multi_agent_version: multi_agent_version, })) .await?; diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index ee010b5e1d..bfb7865bb2 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -440,6 +440,7 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors() /*state_db*/ None, TEST_INSTALLATION_ID.to_string(), /*attestation_provider*/ None, + /*external_time_provider*/ None, ); let selected_root_init = |id: &str, environment_id: &str| { let mut init = codex_extension_api::ExtensionDataInit::new(); @@ -548,6 +549,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { /*state_db*/ None, TEST_INSTALLATION_ID.to_string(), /*attestation_provider*/ None, + /*external_time_provider*/ None, ); let selected_cwd = AbsolutePathBuf::try_from(config.cwd.as_path().join("selected")).expect("absolute path"); @@ -671,6 +673,7 @@ async fn explicit_installation_id_skips_codex_home_file() { state_db.clone(), installation_id.clone(), /*attestation_provider*/ None, + /*external_time_provider*/ None, ); let thread = manager @@ -711,6 +714,7 @@ async fn resume_active_thread_from_rollout_returns_running_thread() { /*state_db*/ None, TEST_INSTALLATION_ID.to_string(), /*attestation_provider*/ None, + /*external_time_provider*/ None, ); let source = manager @@ -770,6 +774,7 @@ async fn resume_stopped_thread_from_rollout_spawns_new_thread() { /*state_db*/ None, TEST_INSTALLATION_ID.to_string(), /*attestation_provider*/ None, + /*external_time_provider*/ None, ); let source = manager @@ -836,6 +841,7 @@ async fn resume_stopped_thread_from_rollout_preserves_thread_source() { state_db.clone(), TEST_INSTALLATION_ID.to_string(), /*attestation_provider*/ None, + /*external_time_provider*/ None, ); let source = manager @@ -929,6 +935,7 @@ async fn rollout_path_resume_and_fork_read_history_through_thread_store() { state_db, TEST_INSTALLATION_ID.to_string(), /*attestation_provider*/ None, + /*external_time_provider*/ None, ); let source = manager @@ -1033,6 +1040,7 @@ async fn new_uses_active_provider_for_model_refresh() { /*state_db*/ None, TEST_INSTALLATION_ID.to_string(), /*attestation_provider*/ None, + /*external_time_provider*/ None, ); let _ = manager.list_models(RefreshStrategy::Online).await; @@ -1254,6 +1262,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor state_db.clone(), TEST_INSTALLATION_ID.to_string(), /*attestation_provider*/ None, + /*external_time_provider*/ None, ); let source = manager @@ -1362,6 +1371,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { state_db.clone(), TEST_INSTALLATION_ID.to_string(), /*attestation_provider*/ None, + /*external_time_provider*/ None, ); let source = manager @@ -1460,6 +1470,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ state_db.clone(), TEST_INSTALLATION_ID.to_string(), /*attestation_provider*/ None, + /*external_time_provider*/ None, ); let source = manager diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index ed15a7483a..eefea1eb8f 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -4279,6 +4279,7 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr state_db.clone(), "11111111-1111-4111-8111-111111111111".to_string(), /*attestation_provider*/ None, + /*external_time_provider*/ None, ); let parent = manager diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index b10e26bf0c..42b9bf87ca 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -17,6 +17,7 @@ use codex_config::CloudConfigBundleLoader; use codex_core::CodexThread; use codex_core::StartThreadOptions; use codex_core::ThreadManager; +use codex_core::TimeProvider; use codex_core::config::Config; use codex_core::resolve_installation_id; use codex_core::shell::Shell; @@ -261,6 +262,7 @@ pub struct TestCodexBuilder { extensions: Arc>, user_instructions_provider: Option>, supports_openai_form_elicitation: bool, + external_time_provider: Option>, } impl TestCodexBuilder { @@ -362,6 +364,11 @@ impl TestCodexBuilder { self } + pub fn with_external_time_provider(mut self, provider: Arc) -> Self { + self.external_time_provider = Some(provider); + self + } + pub fn with_windows_cmd_shell(self) -> Self { if cfg!(windows) { self.with_user_shell(get_shell_by_model_provided_path(&PathBuf::from("cmd.exe"))) @@ -568,6 +575,7 @@ impl TestCodexBuilder { state_db.clone(), installation_id, /*attestation_provider*/ None, + /*external_time_provider*/ self.external_time_provider.clone(), ); let thread_manager = Arc::new(thread_manager); let user_shell_override = self.user_shell_override.clone(); @@ -1172,6 +1180,7 @@ pub fn test_codex() -> TestCodexBuilder { extensions: empty_extension_registry(), user_instructions_provider: None, supports_openai_form_elicitation: false, + external_time_provider: None, } } diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 54e24d49cb..e329618828 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1259,6 +1259,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { /*state_db*/ None, installation_id, /*attestation_provider*/ None, + /*external_time_provider*/ None, ); let NewThread { thread: codex, .. } = thread_manager .start_thread(config.clone()) diff --git a/codex-rs/core/tests/suite/current_time_reminder.rs b/codex-rs/core/tests/suite/current_time_reminder.rs new file mode 100644 index 0000000000..5b5970a0ee --- /dev/null +++ b/codex-rs/core/tests/suite/current_time_reminder.rs @@ -0,0 +1,274 @@ +use std::sync::Arc; +use std::sync::atomic::AtomicI64; +use std::sync::atomic::Ordering; + +use anyhow::Result; +use anyhow::anyhow; +use chrono::DateTime; +use chrono::Utc; +use codex_core::TimeFuture; +use codex_core::TimeProvider; +use codex_core::config::CurrentTimeReminderConfig; +use codex_features::CurrentTimeSource; +use codex_features::Feature; +use codex_model_provider_info::built_in_model_providers; +use codex_protocol::ThreadId; +use codex_protocol::models::PermissionProfile; +use codex_protocol::protocol::CodexErrorInfo; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::user_input::UserInput; +use core_test_support::assert_regex_match; +use core_test_support::responses::ResponsesRequest; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use serde_json::json; + +const FIRST_REMINDER: &str = "It is 2026-06-17 17:34:15 UTC."; +const SECOND_REMINDER: &str = "It is 2026-06-17 17:35:15 UTC."; +const FIRST_TIME_UNIX_SECONDS: i64 = 1_781_717_655; + +struct TestTimeProvider(AtomicI64); + +impl Default for TestTimeProvider { + fn default() -> Self { + Self(AtomicI64::new(FIRST_TIME_UNIX_SECONDS)) + } +} + +impl TimeProvider for TestTimeProvider { + fn current_time(&self, _thread_id: ThreadId) -> TimeFuture<'_> { + let timestamp = self.0.fetch_add(60, Ordering::Relaxed); + Box::pin(async move { + Ok(DateTime::::from_timestamp(timestamp, 0) + .expect("test timestamp should be valid")) + }) + } +} + +struct FailingTimeProvider; + +impl TimeProvider for FailingTimeProvider { + fn current_time(&self, _thread_id: ThreadId) -> TimeFuture<'_> { + Box::pin(async { Err(anyhow!("test clock unavailable")) }) + } +} + +fn current_time_reminders(request: &ResponsesRequest) -> Vec { + request + .message_input_texts("developer") + .into_iter() + .filter(|text| text.starts_with("It is ")) + .collect() +} + +fn enable_current_time_reminder( + config: &mut codex_core::config::Config, + interval: u64, + clock_source: CurrentTimeSource, +) { + config + .features + .enable(Feature::CurrentTimeReminder) + .expect("test config should allow current-time reminders"); + config.current_time_reminder = Some(CurrentTimeReminderConfig { + reminder_interval_model_requests: interval, + clock_source, + }); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn current_time_reminders_follow_request_interval_and_persist_in_history() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let tool_args = json!({ + "command": "echo current time", + "timeout_ms": 1_000, + }); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + "current-time-tool-call", + "shell_command", + &serde_json::to_string(&tool_args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-2", "done"), + ev_completed("resp-2"), + ]), + sse(vec![ev_response_created("resp-3"), ev_completed("resp-3")]), + ], + ) + .await; + let test = test_codex() + .with_config(|config| { + enable_current_time_reminder(config, /*interval*/ 2, CurrentTimeSource::External) + }) + .with_external_time_provider(Arc::new(TestTimeProvider::default())) + .build(&server) + .await?; + + test.submit_turn_with_permission_profile("first turn", PermissionProfile::Disabled) + .await?; + test.submit_turn("second turn").await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 3); + assert_eq!(current_time_reminders(&requests[0]), vec![FIRST_REMINDER]); + assert_eq!(current_time_reminders(&requests[1]), vec![FIRST_REMINDER]); + assert_eq!( + current_time_reminders(&requests[2]), + vec![FIRST_REMINDER, SECOND_REMINDER] + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn system_time_source_adds_current_time_reminder() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let responses = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + let test = test_codex() + .with_config(|config| { + enable_current_time_reminder(config, /*interval*/ 1, CurrentTimeSource::System) + }) + .build(&server) + .await?; + + test.submit_turn("what time is it?").await?; + + let reminders = current_time_reminders(&responses.single_request()); + assert_eq!(reminders.len(), 1); + assert_regex_match( + r"^It is \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC\.$", + &reminders[0], + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn current_time_reminder_is_refreshed_after_compaction() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + sse(vec![ + ev_response_created("resp-compact"), + ev_assistant_message("msg-compact", "compact summary"), + ev_completed("resp-compact"), + ]), + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ], + ) + .await; + let mut model_provider = built_in_model_providers(/*openai_base_url*/ None)["openai"].clone(); + model_provider.name = "OpenAI-compatible test provider".to_string(); + model_provider.base_url = Some(format!("{}/v1", server.uri())); + model_provider.supports_websockets = false; + let test = test_codex() + .with_config(move |config| { + config.model_provider = model_provider; + enable_current_time_reminder(config, /*interval*/ 50, CurrentTimeSource::External); + }) + .with_external_time_provider(Arc::new(TestTimeProvider::default())) + .build(&server) + .await?; + + test.submit_turn("before compact").await?; + test.codex.submit(Op::Compact).await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + test.submit_turn("after compact").await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 3); + assert_eq!( + current_time_reminders(&requests[2]), + vec![SECOND_REMINDER], + "a new context window should force a fresh reminder before the next model request" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn time_provider_failure_stops_before_inference() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let responses = mount_sse_once( + &server, + sse(vec![ + ev_response_created("unused-response"), + ev_completed("unused-response"), + ]), + ) + .await; + let test = test_codex() + .with_config(|config| { + enable_current_time_reminder(config, /*interval*/ 1, CurrentTimeSource::External) + }) + .with_external_time_provider(Arc::new(FailingTimeProvider)) + .build(&server) + .await?; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "fail before inference".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + + let EventMsg::Error(error) = + wait_for_event(&test.codex, |event| matches!(event, EventMsg::Error(_))).await + else { + unreachable!(); + }; + assert_eq!( + error.message, + "Fatal error: failed to read current time: test clock unavailable" + ); + assert_eq!(error.codex_error_info, Some(CodexErrorInfo::Other)); + + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + assert!(responses.requests().is_empty()); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 0fdd958b39..1e200ab486 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -48,6 +48,7 @@ mod compact; mod compact_remote; mod compact_remote_parity; mod compact_resume_fork; +mod current_time_reminder; mod deprecation_notice; mod exec; mod exec_policy; diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index ce19575a9c..5f36091fee 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -78,6 +78,7 @@ impl MessageProcessor { state_db.clone(), installation_id, /*attestation_provider*/ None, + /*external_time_provider*/ None, )); Self { outgoing, diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 1534ba86a0..26ba71ae46 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -136,6 +136,7 @@ async fn run_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { state_db, installation_id, /*attestation_provider*/ None, + /*external_time_provider*/ None, ); let NewThread { From a52a3b5197641a1c14949db88c4def5cf966f08c Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Thu, 18 Jun 2026 12:25:07 -0700 Subject: [PATCH 042/112] [codex] Cache plugin metadata for tool suggestions (#27812) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why `built_tools` runs for every sampling request, and local plugin discovery was repeatedly rereading plugin manifests, skills, MCP configuration, and app declarations to build the same tool-suggest metadata. That source-derived metadata is stable until the existing plugin manager reloads its cache. Runtime eligibility still needs to reflect the current install, disable, policy, app-overlap, and authentication state. ## What changed - Add a bounded, in-memory tool-suggest metadata cache owned by `PluginsManager`. - Key cached metadata by plugin identity and source, while applying authentication routing each time the metadata is projected. - Invalidate the metadata alongside the existing loaded-plugin cache, including its normal configuration, marketplace refresh, and remote-installed-plugin invalidation paths. - Guard against an in-flight load repopulating stale metadata after invalidation. - Keep marketplace membership and all runtime eligibility filtering live rather than introducing a separate catalog or revision model. ## Impact Repeated sampling requests reuse already-loaded plugin capability metadata while retaining the existing plugin-manager lifecycle as the single freshness boundary. ## Validation - `just test -p codex-core-plugins` — 252 passed - Added focused coverage for cache invalidation and authentication reprojection. --- codex-rs/core-plugins/src/discoverable.rs | 11 +- .../core-plugins/src/discoverable_tests.rs | 177 ++++++++++++- codex-rs/core-plugins/src/lib.rs | 1 + codex-rs/core-plugins/src/loader.rs | 56 ++++- codex-rs/core-plugins/src/manager.rs | 41 +++- codex-rs/core-plugins/src/manager_tests.rs | 100 ++++++++ codex-rs/core-plugins/src/marketplace.rs | 2 +- .../core-plugins/src/tool_suggest_metadata.rs | 232 ++++++++++++++++++ 8 files changed, 590 insertions(+), 30 deletions(-) create mode 100644 codex-rs/core-plugins/src/tool_suggest_metadata.rs diff --git a/codex-rs/core-plugins/src/discoverable.rs b/codex-rs/core-plugins/src/discoverable.rs index 0379819c47..eb1df6ff52 100644 --- a/codex-rs/core-plugins/src/discoverable.rs +++ b/codex-rs/core-plugins/src/discoverable.rs @@ -1,8 +1,8 @@ use anyhow::Context; use codex_app_server_protocol::PluginAvailability; use codex_app_server_protocol::PluginInstallPolicy; +use codex_core_skills::config_rules::skill_config_rules_from_stack; use codex_login::CodexAuth; -use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use std::collections::HashSet; use tracing::warn; @@ -93,6 +93,7 @@ impl PluginsManager { } else { None }; + let skill_config_rules = skill_config_rules_from_stack(&input.plugins.config_layer_stack); let mut discoverable_plugins = Vec::::new(); for marketplace in marketplaces { @@ -110,17 +111,15 @@ impl PluginsManager { } let plugin_id = plugin.id.clone(); - match self - .read_plugin_detail_for_marketplace_plugin( - &input.plugins, + .tool_suggest_metadata_for_marketplace_plugin( &marketplace_name, - plugin, + &plugin, + &skill_config_rules, ) .await { Ok(plugin) => { - let plugin: PluginCapabilitySummary = plugin.into(); discoverable_plugins.push(ToolSuggestDiscoverablePlugin { id: plugin.config_name, remote_plugin_id: None, diff --git a/codex-rs/core-plugins/src/discoverable_tests.rs b/codex-rs/core-plugins/src/discoverable_tests.rs index 8465d2431e..d472c76344 100644 --- a/codex-rs/core-plugins/src/discoverable_tests.rs +++ b/codex-rs/core-plugins/src/discoverable_tests.rs @@ -208,7 +208,7 @@ async fn includes_openai_curated_when_remote_enabled_without_auth() { } #[tokio::test] -async fn deduplicates_configured_marketplace_plugin() { +async fn deduplicates_and_reprojects_cached_configured_marketplace_plugin() { let codex_home = tempdir().expect("tempdir should succeed"); let plugin_name = "sample"; let marketplace_name = OPENAI_BUNDLED_MARKETPLACE_NAME; @@ -229,6 +229,12 @@ async fn deduplicates_configured_marketplace_plugin() { ), ); write_curated_plugin(&marketplace_root, plugin_name); + write_plugin_app( + &marketplace_root, + plugin_name, + "sample-docs", + "connector_sample", + ); write_file( &codex_home.path().join(CONFIG_TOML_FILE), &format!( @@ -241,18 +247,179 @@ source = "/tmp/{marketplace_name}" "# ), ); - let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); - let discoverable_plugins = list_discoverable_plugins( + assert!(plugins_manager.set_auth_mode(Some(AuthMode::Chatgpt))); + let chatgpt_projection = list_discoverable_plugins( + &plugins_manager, + discovery_input(plugins.clone(), &[plugin_id.as_str()], &[], &[]), + /*auth*/ None, + ) + .await; + let expected = ToolSuggestDiscoverablePlugin { + id: plugin_id.clone(), + remote_plugin_id: None, + name: "sample".to_string(), + description: Some( + "Plugin that includes skills, MCP servers, and app connectors".to_string(), + ), + has_skills: true, + mcp_server_names: Vec::new(), + app_connector_ids: vec!["connector_sample".to_string()], + }; + assert_eq!(chatgpt_projection, vec![expected.clone()]); + + assert!(plugins_manager.set_auth_mode(Some(AuthMode::ApiKey))); + let api_key_projection = list_discoverable_plugins( &plugins_manager, discovery_input(plugins, &[plugin_id.as_str()], &[], &[]), /*auth*/ None, ) .await; + assert_eq!( + api_key_projection, + vec![ToolSuggestDiscoverablePlugin { + mcp_server_names: vec!["sample-docs".to_string()], + app_connector_ids: Vec::new(), + ..expected + }] + ); +} - assert_eq!(discoverable_plugins.len(), 1); - assert_eq!(discoverable_plugins[0].id, plugin_id); +#[tokio::test] +async fn reprojects_cached_skill_availability_for_current_config() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["slack"]); + + let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + let expected = ToolSuggestDiscoverablePlugin { + id: "slack@openai-curated".to_string(), + remote_plugin_id: None, + name: "slack".to_string(), + description: Some( + "Plugin that includes skills, MCP servers, and app connectors".to_string(), + ), + has_skills: true, + mcp_server_names: vec!["sample-docs".to_string()], + app_connector_ids: vec!["connector_calendar".to_string()], + }; + let initial = list_discoverable_plugins( + &plugins_manager, + discovery_input(plugins, &[], &[], &[]), + /*auth*/ None, + ) + .await; + assert_eq!(initial, vec![expected.clone()]); + + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + r#"[[skills.config]] +name = "slack:sample" +enabled = false +"#, + ); + let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; + let after_skill_disabled = list_discoverable_plugins( + &plugins_manager, + discovery_input(plugins, &[], &[], &[]), + /*auth*/ None, + ) + .await; + assert_eq!( + after_skill_disabled, + vec![ToolSuggestDiscoverablePlugin { + has_skills: false, + ..expected + }] + ); +} + +#[tokio::test] +async fn does_not_advertise_skills_when_skill_loading_fails() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["slack"]); + write_file( + &curated_root.join("plugins/slack/skills/SKILL.md"), + "---\nname: bad", + ); + + let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + let discoverable_plugins = list_discoverable_plugins( + &plugins_manager, + discovery_input(plugins, &[], &[], &[]), + /*auth*/ None, + ) + .await; + + assert_eq!( + discoverable_plugins, + vec![ToolSuggestDiscoverablePlugin { + id: "slack@openai-curated".to_string(), + remote_plugin_id: None, + name: "slack".to_string(), + description: Some( + "Plugin that includes skills, MCP servers, and app connectors".to_string(), + ), + has_skills: false, + mcp_server_names: vec!["sample-docs".to_string()], + app_connector_ids: vec!["connector_calendar".to_string()], + }] + ); +} + +#[tokio::test] +async fn clear_cache_invalidates_cached_tool_suggest_metadata() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["slack"]); + let plugin_manifest = curated_root.join("plugins/slack/.codex-plugin/plugin.json"); + write_file( + &plugin_manifest, + r#"{ + "name": "slack", + "description": "Before reload" +}"#, + ); + + let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + let input = discovery_input(plugins, &[], &[], &[]); + let expected_cached = vec![ToolSuggestDiscoverablePlugin { + id: "slack@openai-curated".to_string(), + remote_plugin_id: None, + name: "slack".to_string(), + description: Some("Before reload".to_string()), + has_skills: true, + mcp_server_names: vec!["sample-docs".to_string()], + app_connector_ids: vec!["connector_calendar".to_string()], + }]; + let initial = list_discoverable_plugins(&plugins_manager, input.clone(), /*auth*/ None).await; + assert_eq!(initial, expected_cached); + + write_file( + &plugin_manifest, + r#"{ + "name": "slack", + "description": "After reload" +}"#, + ); + let before_reload = + list_discoverable_plugins(&plugins_manager, input.clone(), /*auth*/ None).await; + assert_eq!(before_reload, expected_cached); + + plugins_manager.clear_cache(); + let after_reload = list_discoverable_plugins(&plugins_manager, input, /*auth*/ None).await; + assert_eq!( + after_reload, + vec![ToolSuggestDiscoverablePlugin { + description: Some("After reload".to_string()), + ..expected_cached[0].clone() + }] + ); } #[tokio::test] diff --git a/codex-rs/core-plugins/src/lib.rs b/codex-rs/core-plugins/src/lib.rs index 356c0f8bdc..08e77606fc 100644 --- a/codex-rs/core-plugins/src/lib.rs +++ b/codex-rs/core-plugins/src/lib.rs @@ -18,6 +18,7 @@ pub mod store; #[cfg(test)] mod test_support; pub mod toggles; +mod tool_suggest_metadata; pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated"; pub const OPENAI_API_CURATED_MARKETPLACE_NAME: &str = "openai-api-curated"; diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index 029be960b6..43b561b3be 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -768,6 +768,29 @@ fn apply_plugin_mcp_server_policy(config: &mut McpServerConfig, policy: &PluginM } } +pub(crate) struct PluginSkillInventory { + skills: Vec, + had_errors: bool, +} + +impl PluginSkillInventory { + pub(crate) fn has_enabled_skills(&self, skill_config_rules: &SkillConfigRules) -> bool { + contains_enabled_skill( + &self.skills, + &resolve_disabled_skill_paths(&self.skills, skill_config_rules), + ) + } + + fn resolve(self, skill_config_rules: &SkillConfigRules) -> ResolvedPluginSkills { + let disabled_skill_paths = resolve_disabled_skill_paths(&self.skills, skill_config_rules); + ResolvedPluginSkills { + skills: self.skills, + disabled_skill_paths, + had_errors: self.had_errors, + } + } +} + #[derive(Debug, Clone)] pub struct ResolvedPluginSkills { pub skills: Vec, @@ -777,14 +800,19 @@ pub struct ResolvedPluginSkills { impl ResolvedPluginSkills { pub fn has_enabled_skills(&self) -> bool { - self.had_errors - || self - .skills - .iter() - .any(|skill| !self.disabled_skill_paths.contains(&skill.path_to_skills_md)) + self.had_errors || contains_enabled_skill(&self.skills, &self.disabled_skill_paths) } } +fn contains_enabled_skill( + skills: &[SkillMetadata], + disabled_skill_paths: &HashSet, +) -> bool { + skills + .iter() + .any(|skill| !disabled_skill_paths.contains(&skill.path_to_skills_md)) +} + pub async fn load_plugin_skills( plugin_root: &AbsolutePathBuf, plugin_id: &PluginId, @@ -792,6 +820,17 @@ pub async fn load_plugin_skills( restriction_product: Option, skill_config_rules: &SkillConfigRules, ) -> ResolvedPluginSkills { + load_plugin_skill_inventory(plugin_root, plugin_id, manifest, restriction_product) + .await + .resolve(skill_config_rules) +} + +pub(crate) async fn load_plugin_skill_inventory( + plugin_root: &AbsolutePathBuf, + plugin_id: &PluginId, + manifest: &PluginManifest, + restriction_product: Option, +) -> PluginSkillInventory { let roots = plugin_skill_roots(plugin_root, &manifest.paths) .into_iter() .map(|path| SkillRoot { @@ -810,13 +849,8 @@ pub async fn load_plugin_skills( .into_iter() .filter(|skill| skill.matches_product_restriction_for_product(restriction_product)) .collect::>(); - let disabled_skill_paths = resolve_disabled_skill_paths(&skills, skill_config_rules); - ResolvedPluginSkills { - skills, - disabled_skill_paths, - had_errors, - } + PluginSkillInventory { skills, had_errors } } fn plugin_skill_roots( diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index b1bb508eeb..c31040ebf1 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -51,6 +51,7 @@ use crate::startup_sync::sync_openai_plugins_repo; use crate::store::PluginInstallResult as StorePluginInstallResult; use crate::store::PluginStore; use crate::store::PluginStoreError; +use crate::tool_suggest_metadata::ToolSuggestMetadataCache; use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::AuthMode; use codex_config::ConfigLayerStack; @@ -354,6 +355,7 @@ pub struct PluginsManager { // Keep the cache auth-independent so auth changes only need to resolve capabilities again. loaded_plugins_cache: RwLock, loaded_plugins_load_semaphore: Semaphore, + tool_suggest_metadata_cache: ToolSuggestMetadataCache, remote_installed_plugins_cache: RwLock>>, remote_installed_plugins_cache_refresh_state: RwLock, global_remote_catalog_cache_refresh_state: RwLock, @@ -410,6 +412,7 @@ impl PluginsManager { non_curated_cache_refresh_state: RwLock::new(NonCuratedCacheRefreshState::default()), loaded_plugins_cache: RwLock::new(LoadedPluginsCache::default()), loaded_plugins_load_semaphore: Semaphore::new(/*permits*/ 1), + tool_suggest_metadata_cache: ToolSuggestMetadataCache::new(), remote_installed_plugins_cache: RwLock::new(None), remote_installed_plugins_cache_refresh_state: RwLock::new( RemoteInstalledPluginsCacheRefreshState::default(), @@ -551,6 +554,7 @@ impl PluginsManager { } fn clear_loaded_plugins_cache(&self) { + self.tool_suggest_metadata_cache.clear(); let mut cache = match self.loaded_plugins_cache.write() { Ok(cache) => cache, Err(err) => err.into_inner(), @@ -559,6 +563,17 @@ impl PluginsManager { cache.entry = None; } + fn clear_caches_after_marketplace_source_refresh( + &self, + installed_plugin_cache_refreshed: bool, + ) { + if installed_plugin_cache_refreshed { + self.clear_cache(); + } else { + self.tool_suggest_metadata_cache.clear(); + } + } + /// Load plugins for a config layer stack without touching the plugins cache. pub async fn plugins_for_layer_stack( &self, @@ -1444,6 +1459,19 @@ impl PluginsManager { )) } + pub(crate) async fn tool_suggest_metadata_for_marketplace_plugin( + &self, + marketplace_name: &str, + plugin: &ConfiguredMarketplacePlugin, + skill_config_rules: &SkillConfigRules, + ) -> Result { + let fragment = self + .tool_suggest_metadata_cache + .metadata_for_plugin(marketplace_name, plugin, self.restriction_product) + .await?; + Ok(fragment.project(skill_config_rules, self.auth_mode())) + } + pub async fn read_plugin_for_config( &self, config: &PluginsConfigInput, @@ -1795,9 +1823,7 @@ impl PluginsManager { &outcome.upgraded_roots, ) { Ok(cache_refreshed) => { - if cache_refreshed { - self.clear_cache(); - } + self.clear_caches_after_marketplace_source_refresh(cache_refreshed); } Err(err) => { self.clear_cache(); @@ -1977,9 +2003,8 @@ impl PluginsManager { &configured_curated_plugin_ids, ) { Ok(cache_refreshed) => { - if cache_refreshed { - manager.clear_cache(); - } + manager + .clear_caches_after_marketplace_source_refresh(cache_refreshed); } Err(err) => { manager.clear_cache(); @@ -2220,7 +2245,9 @@ impl PluginsManager { } } -fn remote_plugin_install_required_description(source: &MarketplacePluginSource) -> String { +pub(crate) fn remote_plugin_install_required_description( + source: &MarketplacePluginSource, +) -> String { let source_description = match source { MarketplacePluginSource::Git { url, diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index f5073f68b0..1aa11c6120 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -3,6 +3,8 @@ use crate::LoadedPlugin; use crate::OPENAI_API_CURATED_MARKETPLACE_NAME; use crate::OPENAI_CURATED_MARKETPLACE_NAME; use crate::PluginLoadOutcome; +use crate::ToolSuggestDiscoverablePlugin; +use crate::ToolSuggestPluginDiscoveryInput; use crate::installed_marketplaces::marketplace_install_root; use crate::loader::load_plugin_skills; use crate::loader::load_plugins_from_layer_stack; @@ -18,6 +20,7 @@ use crate::startup_sync::curated_plugins_repo_path; use crate::test_support::TEST_CURATED_PLUGIN_CACHE_VERSION; use crate::test_support::TEST_CURATED_PLUGIN_SHA; use crate::test_support::load_plugins_config as load_plugins_config_input; +use crate::test_support::write_curated_plugin; use crate::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha; use crate::test_support::write_file; use crate::test_support::write_openai_api_curated_marketplace; @@ -3497,6 +3500,103 @@ source = "/tmp/debug" ); } +#[tokio::test] +async fn configured_marketplace_upgrade_invalidates_cached_tool_suggest_metadata() { + let tmp = tempfile::tempdir().unwrap(); + let remote_repo = tmp.path().join("remote-marketplace"); + let remote_repo_url = url::Url::from_directory_path(&remote_repo) + .unwrap() + .to_string(); + write_file( + &remote_repo.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample", + "source": { + "source": "local", + "path": "./plugins/sample" + } + } + ] +}"#, + ); + write_curated_plugin(&remote_repo, "sample"); + write_file( + &remote_repo.join("plugins/sample/.codex-plugin/plugin.json"), + r#"{"name":"sample","description":"Before upgrade"}"#, + ); + init_git_repo(&remote_repo); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + &format!( + r#"[features] +plugins = true + +[marketplaces.debug] +source_type = "git" +source = "{remote_repo_url}" +"# + ), + ); + + let manager = PluginsManager::new(tmp.path().to_path_buf()); + let config = load_config(tmp.path(), tmp.path()).await; + let initial_upgrade = manager + .upgrade_configured_marketplaces_for_config(&config, /*marketplace_name*/ None) + .expect("initial marketplace install should succeed"); + assert_eq!(initial_upgrade.errors, Vec::new()); + assert_eq!(initial_upgrade.upgraded_roots.len(), 1); + + let config = load_config(tmp.path(), tmp.path()).await; + let input = ToolSuggestPluginDiscoveryInput { + plugins: config.clone(), + configured_plugin_ids: HashSet::from(["sample@debug".to_string()]), + disabled_plugin_ids: HashSet::new(), + loaded_plugin_app_connector_ids: HashSet::new(), + }; + let expected = ToolSuggestDiscoverablePlugin { + id: "sample@debug".to_string(), + remote_plugin_id: None, + name: "sample".to_string(), + description: Some("Before upgrade".to_string()), + has_skills: true, + mcp_server_names: vec!["sample-docs".to_string()], + app_connector_ids: vec!["connector_calendar".to_string()], + }; + assert_eq!( + manager + .list_tool_suggest_discoverable_plugins(&input, /*auth*/ None) + .await + .expect("initial tool-suggest metadata should load"), + vec![expected.clone()] + ); + + write_file( + &remote_repo.join("plugins/sample/.codex-plugin/plugin.json"), + r#"{"name":"sample","description":"After upgrade"}"#, + ); + run_git(&remote_repo, &["add", "."]); + run_git(&remote_repo, &["commit", "-m", "update plugin"]); + let upgrade = manager + .upgrade_configured_marketplaces_for_config(&config, /*marketplace_name*/ None) + .expect("marketplace upgrade should succeed"); + assert_eq!(upgrade.errors, Vec::new()); + assert_eq!(upgrade.upgraded_roots.len(), 1); + + assert_eq!( + manager + .list_tool_suggest_discoverable_plugins(&input, /*auth*/ None) + .await + .expect("refreshed tool-suggest metadata should load"), + vec![ToolSuggestDiscoverablePlugin { + description: Some("After upgrade".to_string()), + ..expected + }] + ); +} + #[tokio::test] async fn list_marketplaces_uses_config_when_known_registry_is_malformed() { let tmp = tempfile::tempdir().unwrap(); diff --git a/codex-rs/core-plugins/src/marketplace.rs b/codex-rs/core-plugins/src/marketplace.rs index ce4fcd2ffc..82cbcf1415 100644 --- a/codex-rs/core-plugins/src/marketplace.rs +++ b/codex-rs/core-plugins/src/marketplace.rs @@ -66,7 +66,7 @@ pub struct MarketplacePlugin { pub keywords: Vec, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum MarketplacePluginSource { Local { path: AbsolutePathBuf, diff --git a/codex-rs/core-plugins/src/tool_suggest_metadata.rs b/codex-rs/core-plugins/src/tool_suggest_metadata.rs new file mode 100644 index 0000000000..2a667405e8 --- /dev/null +++ b/codex-rs/core-plugins/src/tool_suggest_metadata.rs @@ -0,0 +1,232 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::RwLock; + +use codex_app_server_protocol::AuthMode; +use codex_core_skills::config_rules::SkillConfigRules; +use codex_plugin::AppDeclaration; +use codex_plugin::PluginCapabilitySummary; +use codex_plugin::PluginId; +use codex_plugin::PluginIdError; +use codex_plugin::app_connector_ids_from_declarations; +use codex_plugin::prompt_safe_plugin_description; +use codex_protocol::protocol::Product; +use tokio::sync::Semaphore; + +use crate::app_mcp_routing::apply_app_mcp_routing_policy; +use crate::loader::PluginSkillInventory; +use crate::loader::load_plugin_apps; +use crate::loader::load_plugin_mcp_servers; +use crate::loader::load_plugin_skill_inventory; +use crate::manager::ConfiguredMarketplacePlugin; +use crate::manager::remote_plugin_install_required_description; +use crate::manifest::load_plugin_manifest; +use crate::marketplace::MarketplaceError; +use crate::marketplace::MarketplacePluginSource; + +const MAX_TOOL_SUGGEST_METADATA_CACHE_ENTRIES: usize = 1024; + +type ToolSuggestMetadataEntry = Result, String>; + +/// Source-derived plugin metadata cached for tool suggestions. +/// +/// `PluginsManager` clears these entries alongside its loaded-plugin cache. Current skill config +/// and auth routing are projected after each lookup and are not part of this cache. +pub(crate) struct ToolSuggestMetadataCache { + state: RwLock, + load_semaphore: Semaphore, +} + +#[derive(Default)] +struct ToolSuggestMetadataCacheState { + generation: u64, + entries: HashMap, +} + +#[derive(Clone, PartialEq, Eq, Hash)] +struct PluginArtifactIdentity { + plugin_id: String, + source: MarketplacePluginSource, +} + +pub(crate) struct ToolSuggestMetadataFragment { + config_name: String, + display_name: String, + description: Option, + mcp_server_names: Vec, + app_declarations: Vec, + skill_inventory: Option, +} + +impl ToolSuggestMetadataFragment { + pub(crate) fn project( + &self, + skill_config_rules: &SkillConfigRules, + auth_mode: Option, + ) -> PluginCapabilitySummary { + let mut app_declarations = self.app_declarations.clone(); + let mut mcp_servers = self + .mcp_server_names + .iter() + .cloned() + .map(|name| (name, ())) + .collect::>(); + if auth_mode.is_some() { + apply_app_mcp_routing_policy( + &mut app_declarations, + &mut mcp_servers, + auth_mode, + /*plugin_active*/ true, + ); + } + let mut mcp_server_names = mcp_servers.into_keys().collect::>(); + mcp_server_names.sort_unstable(); + + PluginCapabilitySummary { + config_name: self.config_name.clone(), + display_name: self.display_name.clone(), + description: self.description.clone(), + has_skills: self + .skill_inventory + .as_ref() + .is_some_and(|inventory| inventory.has_enabled_skills(skill_config_rules)), + mcp_server_names, + app_connector_ids: app_connector_ids_from_declarations(&app_declarations), + } + } +} + +impl ToolSuggestMetadataCache { + pub(crate) fn new() -> Self { + Self { + state: RwLock::new(ToolSuggestMetadataCacheState::default()), + load_semaphore: Semaphore::new(/*permits*/ 1), + } + } + + pub(crate) fn clear(&self) { + let mut state = match self.state.write() { + Ok(state) => state, + Err(err) => err.into_inner(), + }; + state.generation = state.generation.wrapping_add(1); + state.entries.clear(); + } + + pub(crate) async fn metadata_for_plugin( + &self, + marketplace_name: &str, + plugin: &ConfiguredMarketplacePlugin, + restriction_product: Option, + ) -> Result, MarketplaceError> { + let artifact = PluginArtifactIdentity { + plugin_id: plugin.id.clone(), + source: plugin.source.clone(), + }; + loop { + if let Some(entry) = self.cached_entry(&artifact) { + return entry.map_err(MarketplaceError::InvalidPlugin); + } + + let _load_permit = self.load_semaphore.acquire().await.map_err(|_| { + MarketplaceError::InvalidPlugin( + "tool-suggest metadata cache loader closed".to_string(), + ) + })?; + if let Some(entry) = self.cached_entry(&artifact) { + return entry.map_err(MarketplaceError::InvalidPlugin); + } + + let generation = self.generation(); + let entry = load_plugin_metadata(marketplace_name, plugin, restriction_product).await; + if self.cache_entry_if_current(generation, artifact.clone(), entry.clone()) { + return entry.map_err(MarketplaceError::InvalidPlugin); + } + } + } + + fn cached_entry(&self, artifact: &PluginArtifactIdentity) -> Option { + match self.state.read() { + Ok(state) => state.entries.get(artifact).cloned(), + Err(err) => err.into_inner().entries.get(artifact).cloned(), + } + } + + fn generation(&self) -> u64 { + match self.state.read() { + Ok(state) => state.generation, + Err(err) => err.into_inner().generation, + } + } + + fn cache_entry_if_current( + &self, + generation: u64, + artifact: PluginArtifactIdentity, + entry: ToolSuggestMetadataEntry, + ) -> bool { + let mut state = match self.state.write() { + Ok(state) => state, + Err(err) => err.into_inner(), + }; + if state.generation != generation { + return false; + } + if state.entries.len() >= MAX_TOOL_SUGGEST_METADATA_CACHE_ENTRIES + && !state.entries.contains_key(&artifact) + { + state.entries.clear(); + } + state.entries.insert(artifact, entry); + true + } +} + +async fn load_plugin_metadata( + marketplace_name: &str, + plugin: &ConfiguredMarketplacePlugin, + restriction_product: Option, +) -> ToolSuggestMetadataEntry { + let plugin_id = PluginId::new(plugin.name.clone(), marketplace_name.to_string()).map_err( + |err| match err { + PluginIdError::Invalid(message) => message, + }, + )?; + + let MarketplacePluginSource::Local { path: plugin_root } = &plugin.source else { + return Ok(Arc::new(ToolSuggestMetadataFragment { + config_name: plugin.id.clone(), + display_name: plugin.name.clone(), + description: prompt_safe_plugin_description(Some( + &remote_plugin_install_required_description(&plugin.source), + )), + mcp_server_names: Vec::new(), + app_declarations: Vec::new(), + skill_inventory: None, + })); + }; + if !plugin_root.as_path().is_dir() { + return Err("path does not exist or is not a directory".to_string()); + } + let manifest = load_plugin_manifest(plugin_root.as_path()) + .ok_or_else(|| "missing or invalid plugin.json".to_string())?; + let skill_inventory = + load_plugin_skill_inventory(plugin_root, &plugin_id, &manifest, restriction_product).await; + let mut mcp_server_names = + load_plugin_mcp_servers(plugin_root.as_path(), /*auth_mode*/ None) + .await + .into_keys() + .collect::>(); + mcp_server_names.sort_unstable(); + mcp_server_names.dedup(); + let app_declarations = load_plugin_apps(plugin_root.as_path()).await; + + Ok(Arc::new(ToolSuggestMetadataFragment { + config_name: plugin.id.clone(), + display_name: plugin.name.clone(), + description: prompt_safe_plugin_description(manifest.description.as_deref()), + mcp_server_names, + app_declarations, + skill_inventory: Some(skill_inventory), + })) +} From 0f89dd768c16549162f0005c4d77654e29f33f6e Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Thu, 18 Jun 2026 12:31:19 -0700 Subject: [PATCH 043/112] apply-patch: carry paths as PathUri (#28854) ## Why Allows the model to edit files that are hosted on a different OS than where app-server is running. ## What * Use `PathUri` for apply_patch-internal data structures * Limit `PathUri` -> `AbsolutePathBuf` conversion to cases where the inferred path convention matches the host OS, allows requiring valid paths to pass to perms check * Adds `PathConvention::path_segments()` for iterating over path segments regardless of OS * Handle cross-platform relative paths in path filename parsing for sniffing a shell * Ensure we can apply patches in the wine e2e test --- codex-rs/apply-patch/src/invocation.rs | 206 ++++++++------ codex-rs/apply-patch/src/lib.rs | 253 ++++++++++-------- codex-rs/apply-patch/src/parser.rs | 23 +- .../apply-patch/src/standalone_executable.rs | 2 + codex-rs/arg0/src/lib.rs | 1 + codex-rs/core/src/apply_patch.rs | 19 +- codex-rs/core/src/apply_patch_tests.rs | 10 +- codex-rs/core/src/safety.rs | 37 ++- codex-rs/core/src/safety_tests.rs | 71 +++-- codex-rs/core/src/tools/events.rs | 8 +- .../core/src/tools/handlers/apply_patch.rs | 88 +++--- .../src/tools/handlers/apply_patch_tests.rs | 2 + codex-rs/core/src/tools/handlers/shell.rs | 4 +- .../handlers/unified_exec/exec_command.rs | 26 +- .../core/src/tools/runtimes/apply_patch.rs | 20 +- .../src/tools/runtimes/apply_patch_tests.rs | 40 ++- codex-rs/core/src/turn_diff_tracker_tests.rs | 4 +- .../remote_env_windows_test.rs | 74 ++++- codex-rs/utils/path-uri/src/lib.rs | 38 ++- codex-rs/utils/path-uri/src/tests.rs | 70 ++++- 20 files changed, 626 insertions(+), 370 deletions(-) diff --git a/codex-rs/apply-patch/src/invocation.rs b/codex-rs/apply-patch/src/invocation.rs index 0e0261c3b5..de9c735c59 100644 --- a/codex-rs/apply-patch/src/invocation.rs +++ b/codex-rs/apply-patch/src/invocation.rs @@ -1,9 +1,7 @@ use std::collections::HashMap; -use std::path::Path; use std::sync::LazyLock; use codex_exec_server::ExecutorFileSystem; -use codex_utils_absolute_path::AbsolutePathBuf; use tree_sitter::Parser; use tree_sitter::Query; use tree_sitter::QueryCursor; @@ -21,6 +19,7 @@ use crate::parser::Hunk; use crate::parser::ParseError; use crate::parser::parse_patch; use crate::unified_diff_from_chunks; +use codex_utils_path_uri::PathConvention; use codex_utils_path_uri::PathUri; use std::str::Utf8Error; use tree_sitter::LanguageError; @@ -51,15 +50,17 @@ pub enum ExtractHeredocError { FailedToFindHeredocBody, } -fn classify_shell_name(shell: &str) -> Option { - std::path::Path::new(shell) - .file_stem() - .and_then(|name| name.to_str()) - .map(str::to_ascii_lowercase) +fn classify_shell_name(shell: &str, convention: PathConvention) -> Option { + let basename = convention.path_segments(shell).next_back()?; + let stem = basename + .rsplit_once('.') + .and_then(|(stem, _extension)| (!stem.is_empty()).then_some(stem)) + .unwrap_or(basename); + Some(stem.to_ascii_lowercase()) } -fn classify_shell(shell: &str, flag: &str) -> Option { - classify_shell_name(shell).and_then(|name| match name.as_str() { +fn classify_shell(shell: &str, flag: &str, convention: PathConvention) -> Option { + classify_shell_name(shell, convention).and_then(|name| match name.as_str() { "bash" | "zsh" | "sh" if matches!(flag, "-lc" | "-c") => Some(ApplyPatchShell::Unix), "pwsh" | "powershell" if flag.eq_ignore_ascii_case("-command") => { Some(ApplyPatchShell::PowerShell) @@ -69,20 +70,24 @@ fn classify_shell(shell: &str, flag: &str) -> Option { }) } -fn can_skip_flag(shell: &str, flag: &str) -> bool { - classify_shell_name(shell).is_some_and(|name| { +fn can_skip_flag(shell: &str, flag: &str, convention: PathConvention) -> bool { + classify_shell_name(shell, convention).is_some_and(|name| { matches!(name.as_str(), "pwsh" | "powershell") && flag.eq_ignore_ascii_case("-noprofile") }) } -fn parse_shell_script(argv: &[String]) -> Option<(ApplyPatchShell, &str)> { +fn parse_shell_script<'a>(argv: &'a [String], cwd: &PathUri) -> Option<(ApplyPatchShell, &'a str)> { + let convention = cwd.infer_path_convention()?; match argv { - [shell, flag, script] => classify_shell(shell, flag).map(|shell_type| { + [shell, flag, script] => classify_shell(shell, flag, convention).map(|shell_type| { let script = script.as_str(); (shell_type, script) }), - [shell, skip_flag, flag, script] if can_skip_flag(shell, skip_flag) => { - classify_shell(shell, flag).map(|shell_type| { + [shell, skip_flag, flag, script] => { + if !can_skip_flag(shell, skip_flag, convention) { + return None; + } + classify_shell(shell, flag, convention).map(|shell_type| { let script = script.as_str(); (shell_type, script) }) @@ -103,7 +108,8 @@ fn extract_apply_patch_from_shell( } // TODO: make private once we remove tests in lib.rs -pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch { +/// `cwd` supplies the path convention used to interpret the shell executable in `argv`. +pub fn maybe_parse_apply_patch(argv: &[String], cwd: &PathUri) -> MaybeApplyPatch { match argv { // Direct invocation: apply_patch [cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => match parse_patch(body) { @@ -111,7 +117,7 @@ pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch { Err(e) => MaybeApplyPatch::PatchParseError(e), }, // Shell heredoc form: (optional `cd &&`) apply_patch <<'EOF' ... - _ => match parse_shell_script(argv) { + _ => match parse_shell_script(argv, cwd) { Some((shell, script)) => match extract_apply_patch_from_shell(shell, script) { Ok((body, workdir)) => match parse_patch(&body) { Ok(mut source) => { @@ -130,11 +136,11 @@ pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch { } } -/// cwd must be an absolute path so that we can resolve relative paths in the -/// patch. +/// `cwd` must identify an absolute environment-native path so relative patch paths can be +/// resolved without projecting them onto the app-server or exec-server host. pub async fn maybe_parse_apply_patch_verified( argv: &[String], - cwd: &AbsolutePathBuf, + cwd: &PathUri, fs: &dyn ExecutorFileSystem, sandbox: Option<&codex_exec_server::FileSystemSandboxContext>, ) -> MaybeApplyPatchVerified { @@ -145,13 +151,13 @@ pub async fn maybe_parse_apply_patch_verified( { return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation); } - if let Some((_, script)) = parse_shell_script(argv) + if let Some((_, script)) = parse_shell_script(argv, cwd) && parse_patch(script).is_ok() { return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation); } - match maybe_parse_apply_patch(argv) { + match maybe_parse_apply_patch(argv, cwd) { MaybeApplyPatch::Body(args) => verify_apply_patch_args(args, cwd, fs, sandbox).await, MaybeApplyPatch::ShellParseError(e) => MaybeApplyPatchVerified::ShellParseError(e), MaybeApplyPatch::PatchParseError(e) => MaybeApplyPatchVerified::CorrectnessError(e.into()), @@ -161,10 +167,22 @@ pub async fn maybe_parse_apply_patch_verified( pub async fn verify_apply_patch_args( args: ApplyPatchArgs, - cwd: &AbsolutePathBuf, + cwd: &PathUri, fs: &dyn ExecutorFileSystem, sandbox: Option<&codex_exec_server::FileSystemSandboxContext>, ) -> MaybeApplyPatchVerified { + match try_verify_apply_patch_args(args, cwd, fs, sandbox).await { + Ok(action) => MaybeApplyPatchVerified::Body(action), + Err(err) => MaybeApplyPatchVerified::CorrectnessError(err), + } +} + +async fn try_verify_apply_patch_args( + args: ApplyPatchArgs, + cwd: &PathUri, + fs: &dyn ExecutorFileSystem, + sandbox: Option<&codex_exec_server::FileSystemSandboxContext>, +) -> Result { let ApplyPatchArgs { patch, hunks, @@ -173,35 +191,24 @@ pub async fn verify_apply_patch_args( } = args; let effective_cwd = workdir .as_ref() - .map(|dir| cwd.join(Path::new(dir))) + .map(|dir| cwd.join(dir)) + .transpose()? .unwrap_or_else(|| cwd.clone()); let mut changes = HashMap::new(); for hunk in hunks { - let path = hunk.resolve_path(&effective_cwd); + let path = hunk.resolve_path(&effective_cwd)?; match hunk { Hunk::AddFile { contents, .. } => { - changes.insert( - path.into_path_buf(), - ApplyPatchFileChange::Add { content: contents }, - ); + changes.insert(path, ApplyPatchFileChange::Add { content: contents }); } Hunk::DeleteFile { .. } => { - let path_uri = PathUri::from_abs_path(&path); - let content = match fs.read_file_text(&path_uri, sandbox).await { - Ok(content) => content, - Err(e) => { - return MaybeApplyPatchVerified::CorrectnessError( - ApplyPatchError::IoError(IoError { - context: format!("Failed to read {}", path.display()), - source: e, - }), - ); - } - }; - changes.insert( - path.into_path_buf(), - ApplyPatchFileChange::Delete { content }, - ); + let content = fs.read_file_text(&path, sandbox).await.map_err(|source| { + ApplyPatchError::IoError(IoError { + context: format!("Failed to read {}", path.inferred_native_path_string()), + source, + }) + })?; + changes.insert(path, ApplyPatchFileChange::Delete { content }); } Hunk::UpdateFile { move_path, chunks, .. @@ -210,27 +217,24 @@ pub async fn verify_apply_patch_args( unified_diff, content: contents, .. - } = match unified_diff_from_chunks(&path, &chunks, fs, sandbox).await { - Ok(diff) => diff, - Err(e) => { - return MaybeApplyPatchVerified::CorrectnessError(e); - } - }; + } = unified_diff_from_chunks(&path, &chunks, fs, sandbox).await?; changes.insert( - path.into_path_buf(), + path, ApplyPatchFileChange::Update { unified_diff, - move_path: move_path.map(|p| effective_cwd.join(p).into_path_buf()), + move_path: move_path + .map(|path| effective_cwd.join(&path.to_string_lossy())) + .transpose()?, new_content: contents, }, ); } } } - MaybeApplyPatchVerified::Body(ApplyPatchAction { + Ok(ApplyPatchAction { changes, patch, - cwd: effective_cwd.into(), + cwd: effective_cwd, }) } @@ -392,7 +396,6 @@ mod tests { use crate::unified_diff_from_chunks; use assert_matches::assert_matches; use codex_exec_server::LOCAL_FS; - use codex_utils_absolute_path::test_support::PathExt; use pretty_assertions::assert_eq; use std::fs; use std::path::PathBuf; @@ -448,8 +451,22 @@ mod tests { }] } + #[track_caller] fn assert_match_args(args: Vec, expected_workdir: Option<&str>) { - match maybe_parse_apply_patch(&args) { + assert_match_args_with_cwd( + args, + &PathUri::parse("file:///workspace").expect("valid POSIX test cwd"), + expected_workdir, + ); + } + + #[track_caller] + fn assert_match_args_with_cwd( + args: Vec, + cwd: &PathUri, + expected_workdir: Option<&str>, + ) { + match maybe_parse_apply_patch(&args, cwd) { MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => { assert_eq!(workdir.as_deref(), expected_workdir); assert_eq!(hunks, expected_single_add()); @@ -458,6 +475,7 @@ mod tests { } } + #[track_caller] fn assert_match(script: &str, expected_workdir: Option<&str>) { let args = args_bash(script); assert_match_args(args, expected_workdir); @@ -466,7 +484,10 @@ mod tests { fn assert_not_match(script: &str) { let args = args_bash(script); assert_matches!( - maybe_parse_apply_patch(&args), + maybe_parse_apply_patch( + &args, + &PathUri::parse("file:///workspace").expect("valid POSIX test cwd"), + ), MaybeApplyPatch::NotApplyPatch ); } @@ -479,7 +500,7 @@ mod tests { assert_matches!( maybe_parse_apply_patch_verified( &args, - &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &PathUri::from_path(dir.path()).expect("absolute test path"), LOCAL_FS.as_ref(), /*sandbox*/ None, ) @@ -496,7 +517,7 @@ mod tests { assert_matches!( maybe_parse_apply_patch_verified( &args, - &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &PathUri::from_path(dir.path()).expect("absolute test path"), LOCAL_FS.as_ref(), /*sandbox*/ None, ) @@ -516,7 +537,10 @@ mod tests { "#, ]); - match maybe_parse_apply_patch(&args) { + match maybe_parse_apply_patch( + &args, + &PathUri::parse("file:///workspace").expect("valid POSIX test cwd"), + ) { MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => { assert_eq!( hunks, @@ -541,7 +565,10 @@ mod tests { "#, ]); - match maybe_parse_apply_patch(&args) { + match maybe_parse_apply_patch( + &args, + &PathUri::parse("file:///workspace").expect("valid POSIX test cwd"), + ) { MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => { assert_eq!( hunks, @@ -580,7 +607,10 @@ mod tests { PATCH"#, ]); - match maybe_parse_apply_patch(&args) { + match maybe_parse_apply_patch( + &args, + &PathUri::parse("file:///workspace").expect("valid POSIX test cwd"), + ) { MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => { assert_eq!(workdir, None); assert_eq!( @@ -614,6 +644,21 @@ PATCH"#, assert_match_args(args_pwsh(&script), /*expected_workdir*/ None); } + #[tokio::test] + async fn test_apply_patch_interception_uses_cwd_convention_for_windows_pwsh_path() { + let script = heredoc_script(""); + assert_match_args_with_cwd( + strs_to_strings(&[ + r"C:\Program Files\PowerShell\7\pwsh.exe", + "-NoProfile", + "-Command", + &script, + ]), + &PathUri::parse("file:///C:/windows").expect("valid Windows test cwd"), + /*expected_workdir*/ None, + ); + } + #[tokio::test] async fn test_cmd_heredoc_with_cd() { let script = heredoc_script("cd foo && "); @@ -707,9 +752,9 @@ PATCH"#, _ => panic!("Expected a single UpdateFile hunk"), }; - let path_abs = path.as_path().abs(); + let path_uri = PathUri::from_path(&path).expect("absolute test path"); let diff = - unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) + unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) .await .unwrap(); let expected_diff = r#"@@ -2,2 +2,2 @@ @@ -747,9 +792,9 @@ PATCH"#, _ => panic!("Expected a single UpdateFile hunk"), }; - let path_abs = path.as_path().abs(); + let path_uri = PathUri::from_path(&path).expect("absolute test path"); let diff = - unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) + unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) .await .unwrap(); let expected_diff = r#"@@ -3 +3,2 @@ @@ -787,7 +832,7 @@ PATCH"#, let result = maybe_parse_apply_patch_verified( &argv, - &AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(), + &PathUri::from_path(session_dir.path()).expect("absolute test path"), LOCAL_FS.as_ref(), /*sandbox*/ None, ) @@ -799,7 +844,8 @@ PATCH"#, result, MaybeApplyPatchVerified::Body(ApplyPatchAction { changes: HashMap::from([( - session_dir.path().join(relative_path), + PathUri::from_path(session_dir.path().join(relative_path)) + .expect("absolute test path"), ApplyPatchFileChange::Update { unified_diff: r#"@@ -1 +1 @@ -session directory content @@ -811,9 +857,7 @@ PATCH"#, }, )]), patch: argv[1].clone(), - cwd: AbsolutePathBuf::from_absolute_path(session_dir.path()) - .unwrap() - .into(), + cwd: PathUri::from_path(session_dir.path()).expect("absolute test path"), }) ); } @@ -843,7 +887,7 @@ PATCH"#, let result = maybe_parse_apply_patch_verified( &argv, - &AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(), + &PathUri::from_path(session_dir.path()).expect("absolute test path"), LOCAL_FS.as_ref(), /*sandbox*/ None, ) @@ -858,18 +902,18 @@ PATCH"#, worktree_dir.as_path() ); - let source_path = worktree_dir.join(source_name); + let source_path = + PathUri::from_path(worktree_dir.join(source_name)).expect("absolute test path"); let change = action .changes() - .get(source_path.as_path()) + .get(&source_path) .expect("source file change present"); match change { ApplyPatchFileChange::Update { move_path, .. } => { - assert_eq!( - move_path.as_deref(), - Some(worktree_dir.join(dest_name).as_path()) - ); + let expected_move_path = + PathUri::from_path(worktree_dir.join(dest_name)).expect("absolute test path"); + assert_eq!(move_path.as_ref(), Some(&expected_move_path)); } other => panic!("expected update change, got {other:?}"), } @@ -879,7 +923,7 @@ PATCH"#, async fn test_unreadable_destinations_still_verify() { let session_dir = tempdir().unwrap(); fs::write(session_dir.path().join("binary.dat"), [0xff, 0xfe, 0xfd]).unwrap(); - let cwd = AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(); + let cwd = PathUri::from_path(session_dir.path()).expect("absolute test path"); let add_argv = vec![ "apply_patch".to_string(), "*** Begin Patch\n*** Add File: binary.dat\n+text\n*** End Patch".to_string(), @@ -922,7 +966,7 @@ PATCH"#, let result = maybe_parse_apply_patch_verified( &argv, - &AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(), + &PathUri::from_path(session_dir.path()).expect("absolute test path"), LOCAL_FS.as_ref(), /*sandbox*/ None, ) diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index ff11c43242..f27610d6cc 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -6,7 +6,6 @@ mod streaming_parser; use std::collections::HashMap; use std::io; -use std::path::Path; use std::path::PathBuf; use anyhow::Context; @@ -15,8 +14,8 @@ use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::FileSystemSandboxContext; use codex_exec_server::RemoveOptions; -use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; +use codex_utils_path_uri::PathUriParseError; pub use parser::Hunk; pub use parser::ParseError; use parser::ParseError::*; @@ -50,6 +49,9 @@ pub enum ApplyPatchError { /// Error that occurs while computing replacements when applying patch chunks #[error("{0}")] ComputeReplacements(String), + /// A patch path could not be resolved as a path URI. + #[error(transparent)] + PathUri(#[from] PathUriParseError), /// A raw patch body was provided without an explicit `apply_patch` invocation. #[error( "patch detected without explicit call to apply_patch. Rerun as [\"apply_patch\", \"\"]" @@ -109,7 +111,7 @@ pub enum ApplyPatchFileChange { }, Update { unified_diff: String, - move_path: Option, + move_path: Option, /// new_content that will result after the unified_diff is applied. new_content: String, }, @@ -134,7 +136,7 @@ pub enum MaybeApplyPatchVerified { /// construction, all paths should be absolute paths. #[derive(Debug, PartialEq)] pub struct ApplyPatchAction { - changes: HashMap, + changes: HashMap, /// The raw patch argument that can be used to apply the patch. i.e., if the /// original arg was parsed in "lenient" mode with a @@ -151,18 +153,15 @@ impl ApplyPatchAction { } /// Returns the changes that would be made by applying the patch. - pub fn changes(&self) -> &HashMap { + pub fn changes(&self) -> &HashMap { &self.changes } /// Should be used exclusively for testing. (Not worth the overhead of /// creating a feature flag for this.) - pub fn new_add_for_test(path: &AbsolutePathBuf, content: String) -> Self { + pub fn new_add_for_test(path: &PathUri, content: String) -> Self { #[expect(clippy::expect_used)] - let filename = path - .file_name() - .expect("path should not be empty") - .to_string_lossy(); + let filename = path.basename().expect("path should not be empty"); let patch = format!( r#"*** Begin Patch *** Update File: {filename} @@ -170,11 +169,11 @@ impl ApplyPatchAction { + {content} *** End Patch"#, ); - let changes = HashMap::from([(path.to_path_buf(), ApplyPatchFileChange::Add { content })]); + let changes = HashMap::from([(path.clone(), ApplyPatchFileChange::Add { content })]); #[expect(clippy::expect_used)] Self { changes, - cwd: path.parent().expect("path should have parent").into(), + cwd: path.parent().expect("path should have parent"), patch, } } @@ -276,7 +275,7 @@ impl ApplyPatchFailure { /// Applies the patch and prints the result to stdout/stderr. pub async fn apply_patch( patch: &str, - cwd: &AbsolutePathBuf, + cwd: &PathUri, stdout: &mut impl std::io::Write, stderr: &mut impl std::io::Write, fs: &dyn ExecutorFileSystem, @@ -315,7 +314,7 @@ pub async fn apply_patch( /// Applies hunks and continues to update stdout/stderr pub async fn apply_hunks( hunks: &[Hunk], - cwd: &AbsolutePathBuf, + cwd: &PathUri, stdout: &mut impl std::io::Write, stderr: &mut impl std::io::Write, fs: &dyn ExecutorFileSystem, @@ -361,7 +360,7 @@ pub struct AffectedPaths { /// Returns an error if the patch could not be applied. async fn apply_hunks_to_files( hunks: &[Hunk], - cwd: &AbsolutePathBuf, + cwd: &PathUri, fs: &dyn ExecutorFileSystem, sandbox: Option<&FileSystemSandboxContext>, delta: &mut AppliedPatchDelta, @@ -388,26 +387,26 @@ async fn apply_hunks_to_files( }; } + // TODO(anp): Carry PathUri through committed patch deltas and the turn diff tracker. for hunk in hunks { let affected_path = hunk.path().to_path_buf(); - let path_abs = hunk.resolve_path(cwd); - let path_uri = PathUri::from_abs_path(&path_abs); + let path_uri = hunk.resolve_path(cwd)?; match hunk { Hunk::AddFile { contents, .. } => { let overwritten_content = - read_optional_file_text_for_delta(&path_abs, fs, sandbox, &mut delta.exact) + read_optional_file_text_for_delta(&path_uri, fs, sandbox, &mut delta.exact) .await; try_write!( write_file_with_missing_parent_retry( fs, - &path_abs, + &path_uri, contents.clone().into_bytes(), sandbox, ) .await ); delta.changes.push(AppliedPatchChange { - path: path_abs.into_path_buf(), + path: path_uri.to_path_buf(), change: AppliedPatchFileChange::Add { content: contents.clone(), overwritten_content, @@ -416,14 +415,19 @@ async fn apply_hunks_to_files( added.push(affected_path); } Hunk::DeleteFile { .. } => { - note_existing_path_delta_support(&path_abs, fs, sandbox, &mut delta.exact).await; + note_existing_path_delta_support(&path_uri, fs, sandbox, &mut delta.exact).await; let deleted_content = fs.read_file_text(&path_uri, sandbox).await.ok(); if deleted_content.is_none() { delta.exact = false; } - ensure_not_directory(&path_abs, fs, sandbox) + ensure_not_directory(&path_uri, fs, sandbox) .await - .with_context(|| format!("Failed to delete file {}", path_abs.display()))?; + .with_context(|| { + format!( + "Failed to delete file {}", + path_uri.inferred_native_path_string() + ) + })?; if let Err(error) = fs .remove( &path_uri, @@ -434,10 +438,15 @@ async fn apply_hunks_to_files( sandbox, ) .await - .with_context(|| format!("Failed to delete file {}", path_abs.display())) + .with_context(|| { + format!( + "Failed to delete file {}", + path_uri.inferred_native_path_string() + ) + }) { delta.exact &= remove_failure_was_side_effect_free( - &path_abs, + &path_uri, deleted_content.as_deref(), fs, sandbox, @@ -447,7 +456,7 @@ async fn apply_hunks_to_files( } if let Some(content) = deleted_content { delta.changes.push(AppliedPatchChange { - path: path_abs.into_path_buf(), + path: path_uri.to_path_buf(), change: AppliedPatchFileChange::Delete { content }, }); } @@ -456,20 +465,20 @@ async fn apply_hunks_to_files( Hunk::UpdateFile { move_path, chunks, .. } => { - note_existing_path_delta_support(&path_abs, fs, sandbox, &mut delta.exact).await; + note_existing_path_delta_support(&path_uri, fs, sandbox, &mut delta.exact).await; let AppliedPatch { original_contents, new_contents, - } = derive_new_contents_from_chunks(&path_abs, chunks, fs, sandbox).await?; + } = derive_new_contents_from_chunks(&path_uri, chunks, fs, sandbox).await?; if let Some(dest) = move_path { - let dest_abs = AbsolutePathBuf::resolve_path_against_base(dest, cwd); + let dest_uri = cwd.join(&dest.to_string_lossy())?; let overwritten_move_content = - read_optional_file_text_for_delta(&dest_abs, fs, sandbox, &mut delta.exact) + read_optional_file_text_for_delta(&dest_uri, fs, sandbox, &mut delta.exact) .await; try_write!( write_file_with_missing_parent_retry( fs, - &dest_abs, + &dest_uri, new_contents.clone().into_bytes(), sandbox, ) @@ -477,16 +486,19 @@ async fn apply_hunks_to_files( ); let dest_write_change_index = delta.changes.len(); delta.changes.push(AppliedPatchChange { - path: dest_abs.to_path_buf(), + path: dest_uri.to_path_buf(), change: AppliedPatchFileChange::Add { content: new_contents.clone(), overwritten_content: overwritten_move_content.clone(), }, }); - ensure_not_directory(&path_abs, fs, sandbox) + ensure_not_directory(&path_uri, fs, sandbox) .await .with_context(|| { - format!("Failed to remove original {}", path_abs.display()) + format!( + "Failed to remove original {}", + path_uri.inferred_native_path_string() + ) })?; if let Err(error) = fs .remove( @@ -499,11 +511,14 @@ async fn apply_hunks_to_files( ) .await .with_context(|| { - format!("Failed to remove original {}", path_abs.display()) + format!( + "Failed to remove original {}", + path_uri.inferred_native_path_string() + ) }) { delta.exact &= remove_failure_was_side_effect_free( - &path_abs, + &path_uri, Some(&original_contents), fs, sandbox, @@ -512,9 +527,9 @@ async fn apply_hunks_to_files( return Err(error); } delta.changes[dest_write_change_index] = AppliedPatchChange { - path: path_abs.into_path_buf(), + path: path_uri.to_path_buf(), change: AppliedPatchFileChange::Update { - move_path: Some(dest_abs.into_path_buf()), + move_path: Some(dest_uri.to_path_buf()), old_content: original_contents, overwritten_move_content, new_content: new_contents, @@ -527,11 +542,11 @@ async fn apply_hunks_to_files( .await .with_context(|| format!( "Failed to write file {}", - path_abs.display() + path_uri.inferred_native_path_string() )) ); delta.changes.push(AppliedPatchChange { - path: path_abs.into_path_buf(), + path: path_uri.to_path_buf(), change: AppliedPatchFileChange::Update { move_path: None, old_content: original_contents, @@ -552,12 +567,11 @@ async fn apply_hunks_to_files( } async fn ensure_not_directory( - path: &AbsolutePathBuf, + path: &PathUri, fs: &dyn ExecutorFileSystem, sandbox: Option<&FileSystemSandboxContext>, ) -> io::Result<()> { - let path_uri = PathUri::from_abs_path(path); - let metadata = fs.get_metadata(&path_uri, sandbox).await?; + let metadata = fs.get_metadata(path, sandbox).await?; if metadata.is_directory { return Err(io::Error::new( io::ErrorKind::InvalidInput, @@ -568,15 +582,14 @@ async fn ensure_not_directory( } async fn remove_failure_was_side_effect_free( - path: &AbsolutePathBuf, + path: &PathUri, expected_content: Option<&str>, fs: &dyn ExecutorFileSystem, sandbox: Option<&FileSystemSandboxContext>, ) -> bool { - let path_uri = PathUri::from_abs_path(path); match expected_content { Some(expected_content) => fs - .read_file_text(&path_uri, sandbox) + .read_file_text(path, sandbox) .await .is_ok_and(|content| content == expected_content), None => false, @@ -584,14 +597,13 @@ async fn remove_failure_was_side_effect_free( } async fn read_optional_file_text_for_delta( - path: &AbsolutePathBuf, + path: &PathUri, fs: &dyn ExecutorFileSystem, sandbox: Option<&FileSystemSandboxContext>, exact: &mut bool, ) -> Option { note_existing_path_delta_support(path, fs, sandbox, exact).await; - let path_uri = PathUri::from_abs_path(path); - match fs.read_file_text(&path_uri, sandbox).await { + match fs.read_file_text(path, sandbox).await { Ok(content) => Some(content), Err(source) if source.kind() == io::ErrorKind::NotFound => None, Err(_) => { @@ -602,13 +614,12 @@ async fn read_optional_file_text_for_delta( } async fn note_existing_path_delta_support( - path: &AbsolutePathBuf, + path: &PathUri, fs: &dyn ExecutorFileSystem, sandbox: Option<&FileSystemSandboxContext>, exact: &mut bool, ) { - let path_uri = PathUri::from_abs_path(path); - match fs.get_metadata(&path_uri, sandbox).await { + match fs.get_metadata(path, sandbox).await { Ok(metadata) if metadata.is_file && !metadata.is_symlink => {} Ok(_) => *exact = false, Err(source) if source.kind() == io::ErrorKind::NotFound => {} @@ -618,37 +629,39 @@ async fn note_existing_path_delta_support( async fn write_file_with_missing_parent_retry( fs: &dyn ExecutorFileSystem, - path_abs: &AbsolutePathBuf, + path: &PathUri, contents: Vec, sandbox: Option<&FileSystemSandboxContext>, ) -> anyhow::Result<()> { - let path_uri = PathUri::from_abs_path(path_abs); - match fs.write_file(&path_uri, contents.clone(), sandbox).await { + match fs.write_file(path, contents.clone(), sandbox).await { Ok(()) => Ok(()), Err(err) if err.kind() == io::ErrorKind::NotFound => { - if let Some(parent_abs) = path_abs.parent() { - let parent_uri = PathUri::from_abs_path(&parent_abs); - fs.create_directory( - &parent_uri, - CreateDirectoryOptions { recursive: true }, - sandbox, - ) + if let Some(parent) = path.parent() { + fs.create_directory(&parent, CreateDirectoryOptions { recursive: true }, sandbox) + .await + .with_context(|| { + format!( + "Failed to create parent directories for {}", + path.inferred_native_path_string() + ) + })?; + } + fs.write_file(path, contents, sandbox) .await .with_context(|| { format!( - "Failed to create parent directories for {}", - path_abs.display() + "Failed to write file {}", + path.inferred_native_path_string() ) })?; - } - fs.write_file(&path_uri, contents, sandbox) - .await - .with_context(|| format!("Failed to write file {}", path_abs.display()))?; Ok(()) } - Err(err) => { - Err(err).with_context(|| format!("Failed to write file {}", path_abs.display())) - } + Err(err) => Err(err).with_context(|| { + format!( + "Failed to write file {}", + path.inferred_native_path_string() + ) + }), } } @@ -660,15 +673,17 @@ struct AppliedPatch { /// Return *only* the new file contents (joined into a single `String`) after /// applying the chunks to the file at `path`. async fn derive_new_contents_from_chunks( - path_abs: &AbsolutePathBuf, + path: &PathUri, chunks: &[UpdateFileChunk], fs: &dyn ExecutorFileSystem, sandbox: Option<&FileSystemSandboxContext>, ) -> std::result::Result { - let path_uri = PathUri::from_abs_path(path_abs); - let original_contents = fs.read_file_text(&path_uri, sandbox).await.map_err(|err| { + let original_contents = fs.read_file_text(path, sandbox).await.map_err(|err| { ApplyPatchError::IoError(IoError { - context: format!("Failed to read file to update {}", path_abs.display()), + context: format!( + "Failed to read file to update {}", + path.inferred_native_path_string() + ), source: err, }) })?; @@ -681,7 +696,8 @@ async fn derive_new_contents_from_chunks( original_lines.pop(); } - let replacements = compute_replacements(&original_lines, path_abs.as_path(), chunks)?; + let path_text = path.inferred_native_path_string(); + let replacements = compute_replacements(&original_lines, &path_text, chunks)?; let new_lines = apply_replacements(original_lines, &replacements); let mut new_lines = new_lines; if !new_lines.last().is_some_and(String::is_empty) { @@ -699,7 +715,7 @@ async fn derive_new_contents_from_chunks( /// `(start_index, old_len, new_lines)`. fn compute_replacements( original_lines: &[String], - path: &Path, + path: &str, chunks: &[UpdateFileChunk], ) -> std::result::Result)>, ApplyPatchError> { let mut replacements: Vec<(usize, usize, Vec)> = Vec::new(); @@ -718,9 +734,7 @@ fn compute_replacements( line_index = idx + 1; } else { return Err(ApplyPatchError::ComputeReplacements(format!( - "Failed to find context '{}' in {}", - ctx_line, - path.display() + "Failed to find context '{ctx_line}' in {path}" ))); } } @@ -776,7 +790,7 @@ fn compute_replacements( } else { return Err(ApplyPatchError::ComputeReplacements(format!( "Failed to find expected lines in {}:\n{}", - path.display(), + path, chunk.old_lines.join("\n"), ))); } @@ -824,16 +838,16 @@ pub struct ApplyPatchFileUpdate { } pub async fn unified_diff_from_chunks( - path_abs: &AbsolutePathBuf, + path: &PathUri, chunks: &[UpdateFileChunk], fs: &dyn ExecutorFileSystem, sandbox: Option<&FileSystemSandboxContext>, ) -> std::result::Result { - unified_diff_from_chunks_with_context(path_abs, chunks, /*context*/ 1, fs, sandbox).await + unified_diff_from_chunks_with_context(path, chunks, /*context*/ 1, fs, sandbox).await } pub async fn unified_diff_from_chunks_with_context( - path_abs: &AbsolutePathBuf, + path: &PathUri, chunks: &[UpdateFileChunk], context: usize, fs: &dyn ExecutorFileSystem, @@ -842,7 +856,7 @@ pub async fn unified_diff_from_chunks_with_context( let AppliedPatch { original_contents, new_contents, - } = derive_new_contents_from_chunks(path_abs, chunks, fs, sandbox).await?; + } = derive_new_contents_from_chunks(path, chunks, fs, sandbox).await?; let text_diff = TextDiff::from_lines(&original_contents, &new_contents); let unified_diff = text_diff.unified_diff().context_radius(context).to_string(); Ok(ApplyPatchFileUpdate { @@ -875,7 +889,6 @@ pub fn print_summary( mod tests { use super::*; use codex_exec_server::LOCAL_FS; - use codex_utils_absolute_path::test_support::PathExt; use pretty_assertions::assert_eq; use std::fs; use std::string::ToString; @@ -900,7 +913,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &PathUri::from_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -924,7 +937,7 @@ mod tests { #[tokio::test] async fn test_apply_patch_hunks_accept_relative_and_absolute_paths() { let dir = tempdir().unwrap(); - let cwd = dir.path().abs(); + let cwd = PathUri::from_path(dir.path()).expect("absolute test path"); let relative_add = dir.path().join("relative-add.txt"); let absolute_add = dir.path().join("absolute-add.txt"); let relative_delete = dir.path().join("relative-delete.txt"); @@ -1003,7 +1016,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &PathUri::from_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1039,7 +1052,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &PathUri::from_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1079,7 +1092,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &PathUri::from_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1123,7 +1136,7 @@ mod tests { let mut stderr = Vec::new(); let failure = apply_patch( &patch, - &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &PathUri::from_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1183,7 +1196,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &PathUri::from_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1241,7 +1254,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &PathUri::from_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1285,7 +1298,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &PathUri::from_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1328,7 +1341,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &PathUri::from_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1378,9 +1391,9 @@ mod tests { [Hunk::UpdateFile { chunks, .. }] => chunks, _ => panic!("Expected a single UpdateFile hunk"), }; - let path_abs = path.as_path().abs(); + let path_uri = PathUri::from_path(&path).expect("absolute test path"); let diff = unified_diff_from_chunks( - &path_abs, + &path_uri, update_file_chunks, LOCAL_FS.as_ref(), /*sandbox*/ None, @@ -1426,11 +1439,15 @@ mod tests { _ => panic!("Expected a single UpdateFile hunk"), }; - let path_abs = path.as_path().abs(); - let diff = - unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) - .await - .unwrap(); + let resolved_path = PathUri::from_path(&path).expect("absolute test path"); + let diff = unified_diff_from_chunks( + &resolved_path, + chunks, + LOCAL_FS.as_ref(), + /*sandbox*/ None, + ) + .await + .unwrap(); let expected_diff = r#"@@ -1,2 +1,2 @@ -foo +FOO @@ -1468,11 +1485,15 @@ mod tests { _ => panic!("Expected a single UpdateFile hunk"), }; - let path_abs = path.as_path().abs(); - let diff = - unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) - .await - .unwrap(); + let resolved_path = PathUri::from_path(&path).expect("absolute test path"); + let diff = unified_diff_from_chunks( + &resolved_path, + chunks, + LOCAL_FS.as_ref(), + /*sandbox*/ None, + ) + .await + .unwrap(); let expected_diff = r#"@@ -2,2 +2,2 @@ bar -baz @@ -1508,9 +1529,9 @@ mod tests { _ => panic!("Expected a single UpdateFile hunk"), }; - let path_abs = path.as_path().abs(); + let path_uri = PathUri::from_path(&path).expect("absolute test path"); let diff = - unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) + unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) .await .unwrap(); let expected_diff = r#"@@ -3 +3,2 @@ @@ -1559,9 +1580,9 @@ mod tests { _ => panic!("Expected a single UpdateFile hunk"), }; - let path_abs = path.as_path().abs(); + let path_uri = PathUri::from_path(&path).expect("absolute test path"); let diff = - unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) + unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) .await .unwrap(); @@ -1589,7 +1610,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &PathUri::from_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1627,7 +1648,7 @@ g let mut stderr = Vec::new(); let result = apply_patch( &patch, - &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &PathUri::from_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1646,7 +1667,7 @@ g let dir = tempdir().unwrap(); let path = dir.path().join("binary.dat"); fs::write(dir.path().join("source.txt"), "before\n").unwrap(); - let cwd = AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(); + let cwd = PathUri::from_path(dir.path()).expect("absolute test path"); for patch in [ wrap_patch("*** Add File: binary.dat\n+text"), @@ -1684,7 +1705,7 @@ g let mut stderr = Vec::new(); let delta = apply_patch( &patch, - &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &PathUri::from_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), diff --git a/codex-rs/apply-patch/src/parser.rs b/codex-rs/apply-patch/src/parser.rs index 5854f2616f..04e0053a3a 100644 --- a/codex-rs/apply-patch/src/parser.rs +++ b/codex-rs/apply-patch/src/parser.rs @@ -25,9 +25,10 @@ //! leading/trailing whitespace around patch markers. use crate::ApplyPatchArgs; use crate::streaming_parser::StreamingPatchParser; -use codex_utils_absolute_path::AbsolutePathBuf; #[cfg(test)] use codex_utils_absolute_path::test_support::PathBufExt; +use codex_utils_path_uri::PathUri; +use codex_utils_path_uri::PathUriParseError; use std::path::Path; use std::path::PathBuf; @@ -81,12 +82,12 @@ pub enum Hunk { } impl Hunk { - pub fn resolve_path(&self, cwd: &AbsolutePathBuf) -> AbsolutePathBuf { + pub fn resolve_path(&self, cwd: &PathUri) -> Result { let path = match self { Hunk::UpdateFile { path, .. } => path, Hunk::AddFile { .. } | Hunk::DeleteFile { .. } => self.path(), }; - AbsolutePathBuf::resolve_path_against_base(path, cwd) + cwd.join(&path.to_string_lossy()) } /// Returns the path affected by this hunk, using the move destination for rename hunks. @@ -479,7 +480,7 @@ fn test_parse_patch_accepts_relative_and_absolute_hunk_paths() { #[test] fn test_hunk_resolve_path_accepts_relative_and_absolute_paths() { let cwd_dir = tempfile::tempdir().unwrap(); - let cwd = cwd_dir.path().to_path_buf().abs(); + let cwd = PathUri::from_path(cwd_dir.path()).unwrap(); let absolute_dir = tempfile::tempdir().unwrap(); let absolute_add = absolute_dir.path().join("absolute-add.py").abs(); let absolute_delete = absolute_dir.path().join("absolute-delete.py").abs(); @@ -491,13 +492,13 @@ fn test_hunk_resolve_path_accepts_relative_and_absolute_paths() { path: PathBuf::from("relative-add.py"), contents: String::new(), }, - cwd.join("relative-add.py"), + cwd.join("relative-add.py").unwrap(), ), ( DeleteFile { path: PathBuf::from("relative-delete.py"), }, - cwd.join("relative-delete.py"), + cwd.join("relative-delete.py").unwrap(), ), ( UpdateFile { @@ -505,20 +506,20 @@ fn test_hunk_resolve_path_accepts_relative_and_absolute_paths() { move_path: None, chunks: Vec::new(), }, - cwd.join("relative-update.py"), + cwd.join("relative-update.py").unwrap(), ), ( AddFile { path: absolute_add.to_path_buf(), contents: String::new(), }, - absolute_add, + PathUri::from_abs_path(&absolute_add), ), ( DeleteFile { path: absolute_delete.to_path_buf(), }, - absolute_delete, + PathUri::from_abs_path(&absolute_delete), ), ( UpdateFile { @@ -526,10 +527,10 @@ fn test_hunk_resolve_path_accepts_relative_and_absolute_paths() { move_path: None, chunks: Vec::new(), }, - absolute_update, + PathUri::from_abs_path(&absolute_update), ), ] { - assert_eq!(hunk.resolve_path(&cwd), expected_path); + assert_eq!(hunk.resolve_path(&cwd), Ok(expected_path)); } } diff --git a/codex-rs/apply-patch/src/standalone_executable.rs b/codex-rs/apply-patch/src/standalone_executable.rs index 45ca0d0619..384b2ecee2 100644 --- a/codex-rs/apply-patch/src/standalone_executable.rs +++ b/codex-rs/apply-patch/src/standalone_executable.rs @@ -65,6 +65,8 @@ pub fn run_main() -> i32 { return 1; } }; + // TODO(anp): Discover the standalone executable cwd as PathUri directly. + let cwd = codex_utils_path_uri::PathUri::from_abs_path(&cwd); match runtime.block_on(crate::apply_patch( &patch_arg, &cwd, diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index 2102eaf875..1c28f81d8c 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -122,6 +122,7 @@ pub fn arg0_dispatch() -> Option { Ok(runtime) => runtime, Err(_) => std::process::exit(1), }; + let cwd = cwd.into(); match runtime.block_on(codex_apply_patch::apply_patch( &patch_arg, &cwd, diff --git a/codex-rs/core/src/apply_patch.rs b/codex-rs/core/src/apply_patch.rs index 208db44d9c..5e9a3f6e5a 100644 --- a/codex-rs/core/src/apply_patch.rs +++ b/codex-rs/core/src/apply_patch.rs @@ -7,6 +7,7 @@ use codex_apply_patch::ApplyPatchAction; use codex_apply_patch::ApplyPatchFileChange; use codex_protocol::protocol::FileChange; use codex_protocol::protocol::FileSystemSandboxPolicy; +use codex_utils_path_uri::PathUri; use std::collections::HashMap; use std::path::PathBuf; @@ -35,24 +36,12 @@ pub(crate) async fn apply_patch( file_system_sandbox_policy: &FileSystemSandboxPolicy, action: ApplyPatchAction, ) -> InternalApplyPatchInvocation { - // TODO(anp): Migrate patch safety checks to PathUri. - let cwd = match action.cwd.to_abs_path() { - Ok(cwd) => cwd, - Err(err) => { - return InternalApplyPatchInvocation::Output(Err(FunctionCallError::RespondToModel( - format!( - "patch cwd `{}` is not native to the Codex host: {err}", - action.cwd - ), - ))); - } - }; match assess_patch_safety( &action, turn_context.approval_policy.value(), &turn_context.permission_profile(), file_system_sandbox_policy, - &cwd, + &action.cwd, turn_context.windows_sandbox_level, ) { SafetyCheck::AutoApprove { @@ -103,9 +92,11 @@ pub(crate) fn convert_apply_patch_to_protocol( new_content: _new_content, } => FileChange::Update { unified_diff: unified_diff.clone(), - move_path: move_path.clone(), + move_path: move_path.as_ref().map(PathUri::to_path_buf), }, }; + // TODO(anp): Carry PathUri through patch protocol events once app-server and rollout + // compatibility no longer require path-flavored strings. result.insert(path.to_path_buf(), protocol_change); } result diff --git a/codex-rs/core/src/apply_patch_tests.rs b/codex-rs/core/src/apply_patch_tests.rs index c0190c3708..dfc48f8a63 100644 --- a/codex-rs/core/src/apply_patch_tests.rs +++ b/codex-rs/core/src/apply_patch_tests.rs @@ -1,5 +1,5 @@ use super::*; -use core_test_support::PathBufExt; +use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; use tempfile::tempdir; @@ -7,14 +7,14 @@ use tempfile::tempdir; #[test] fn convert_apply_patch_maps_add_variant() { let tmp = tempdir().expect("tmp"); - let p = tmp.path().join("a.txt").abs(); - // Create an action with a single Add change - let action = ApplyPatchAction::new_add_for_test(&p, "hello".to_string()); + let path = tmp.path().join("a.txt"); + let path_uri = PathUri::from_path(&path).expect("absolute test path"); + let action = ApplyPatchAction::new_add_for_test(&path_uri, "hello".to_string()); let got = convert_apply_patch_to_protocol(&action); assert_eq!( - got.get(p.as_path()), + got.get(path.as_path()), Some(&FileChange::Add { content: "hello".to_string() }) diff --git a/codex-rs/core/src/safety.rs b/codex-rs/core/src/safety.rs index dbae1c0c68..916379705b 100644 --- a/codex-rs/core/src/safety.rs +++ b/codex-rs/core/src/safety.rs @@ -2,7 +2,6 @@ use std::path::Component; use std::path::Path; use std::path::PathBuf; -use crate::util::resolve_path; use codex_apply_patch::ApplyPatchAction; use codex_apply_patch::ApplyPatchFileChange; use codex_protocol::config_types::WindowsSandboxLevel; @@ -11,7 +10,7 @@ use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_sandboxing::SandboxType; use codex_sandboxing::get_platform_sandbox; -use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; const PATCH_REJECTED_OUTSIDE_PROJECT_REASON: &str = "writing outside of the project; rejected by user approval settings"; @@ -35,7 +34,7 @@ pub fn assess_patch_safety( policy: AskForApproval, permission_profile: &PermissionProfile, file_system_sandbox_policy: &FileSystemSandboxPolicy, - cwd: &AbsolutePathBuf, + cwd: &PathUri, windows_sandbox_level: WindowsSandboxLevel, ) -> SafetyCheck { if action.is_empty() { @@ -118,14 +117,17 @@ pub fn assess_patch_safety( fn patch_rejection_reason( permission_profile: &PermissionProfile, file_system_sandbox_policy: &FileSystemSandboxPolicy, - cwd: &AbsolutePathBuf, + cwd: &PathUri, ) -> &'static str { + let has_no_writable_roots = cwd.to_abs_path().is_ok_and(|cwd| { + file_system_sandbox_policy + .get_writable_roots_with_cwd(cwd.as_path()) + .is_empty() + }); match permission_profile { PermissionProfile::Managed { .. } if !file_system_sandbox_policy.has_full_disk_write_access() - && file_system_sandbox_policy - .get_writable_roots_with_cwd(cwd.as_path()) - .is_empty() => + && has_no_writable_roots => { PATCH_REJECTED_READ_ONLY_REASON } @@ -138,8 +140,17 @@ fn patch_rejection_reason( fn is_write_patch_constrained_to_writable_paths( action: &ApplyPatchAction, file_system_sandbox_policy: &FileSystemSandboxPolicy, - cwd: &AbsolutePathBuf, + cwd: &PathUri, ) -> bool { + // A full-disk policy permits every patch target, so no per-path writable-root check can + // further constrain the result. + if file_system_sandbox_policy.has_full_disk_write_access() { + return true; + } + // TODO(anp): Make filesystem sandbox policies operate on PathUri. + let Ok(native_cwd) = cwd.to_abs_path() else { + return false; + }; // Normalize a path by removing `.` and resolving `..` without touching the // filesystem (works even if the file does not exist). fn normalize(path: &Path) -> Option { @@ -159,14 +170,18 @@ fn is_write_patch_constrained_to_writable_paths( // Determine whether `path` is inside **any** writable root. Both `path` // and roots are converted to absolute, normalized forms before the // prefix check. - let is_path_writable = |p: &Path| { - let abs = resolve_path(cwd, &p.to_path_buf()); + let is_path_writable = |path: &PathUri| { + // TODO(anp): Make sandbox policy path checks accept PathUri without host projection. + let Ok(path) = path.to_abs_path() else { + return false; + }; + let abs = path.into_path_buf(); let abs = match normalize(&abs) { Some(v) => v, None => return false, }; - file_system_sandbox_policy.can_write_path_with_cwd(&abs, cwd) + file_system_sandbox_policy.can_write_path_with_cwd(&abs, &native_cwd) }; for (path, change) in action.changes() { diff --git a/codex-rs/core/src/safety_tests.rs b/codex-rs/core/src/safety_tests.rs index ca15d0fe42..6f7efe893b 100644 --- a/codex-rs/core/src/safety_tests.rs +++ b/codex-rs/core/src/safety_tests.rs @@ -7,6 +7,7 @@ use codex_protocol::protocol::FileSystemSandboxEntry; use codex_protocol::protocol::FileSystemSpecialPath; use codex_protocol::protocol::GranularApprovalConfig; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use core_test_support::PathExt; use pretty_assertions::assert_eq; use tempfile::TempDir; @@ -17,11 +18,13 @@ fn test_writable_roots_constraint() { // the real current working directory. let tmp = TempDir::new().unwrap(); let cwd = tmp.path().abs(); + let cwd_uri = PathUri::from_abs_path(&cwd); let parent = cwd.parent().unwrap(); // Helper to build a single‑entry patch that adds a file at `p`. - let make_add_change = - |p: AbsolutePathBuf| ApplyPatchAction::new_add_for_test(&p, "".to_string()); + let make_add_change = |p: AbsolutePathBuf| { + ApplyPatchAction::new_add_for_test(&PathUri::from_abs_path(&p), "".to_string()) + }; let add_inside = make_add_change(cwd.join("inner.txt")); let add_outside = make_add_change(parent.join("outside.txt")); @@ -37,13 +40,13 @@ fn test_writable_roots_constraint() { assert!(is_write_patch_constrained_to_writable_paths( &add_inside, &workspace_only_file_system_policy, - &cwd, + &cwd_uri, )); assert!(!is_write_patch_constrained_to_writable_paths( &add_outside, &workspace_only_file_system_policy, - &cwd, + &cwd_uri, )); // With the parent dir explicitly added as a writable root, the @@ -56,7 +59,7 @@ fn test_writable_roots_constraint() { assert!(is_write_patch_constrained_to_writable_paths( &add_outside, &file_system_policy_with_parent, - &cwd, + &cwd_uri, )); } @@ -64,8 +67,12 @@ fn test_writable_roots_constraint() { fn external_sandbox_auto_approves_in_on_request() { let tmp = TempDir::new().unwrap(); let cwd = tmp.path().abs(); + let cwd_uri = PathUri::from_abs_path(&cwd); let add_inside_path = cwd.join("inner.txt"); - let add_inside = ApplyPatchAction::new_add_for_test(&add_inside_path, "".to_string()); + let add_inside = ApplyPatchAction::new_add_for_test( + &PathUri::from_abs_path(&add_inside_path), + "".to_string(), + ); let permission_profile = PermissionProfile::External { network: NetworkSandboxPolicy::Enabled, @@ -78,7 +85,7 @@ fn external_sandbox_auto_approves_in_on_request() { AskForApproval::OnRequest, &permission_profile, &file_system_sandbox_policy, - &cwd, + &cwd_uri, WindowsSandboxLevel::Disabled ), SafetyCheck::AutoApprove { @@ -92,9 +99,11 @@ fn external_sandbox_auto_approves_in_on_request() { fn granular_with_all_flags_true_matches_on_request_for_out_of_root_patch() { let tmp = TempDir::new().unwrap(); let cwd = tmp.path().abs(); + let cwd_uri = PathUri::from_abs_path(&cwd); let parent = cwd.parent().unwrap(); let outside_path = parent.join("outside.txt"); - let add_outside = ApplyPatchAction::new_add_for_test(&outside_path, "".to_string()); + let add_outside = + ApplyPatchAction::new_add_for_test(&PathUri::from_abs_path(&outside_path), "".to_string()); let permission_profile = PermissionProfile::workspace_write_with( &[], NetworkSandboxPolicy::Restricted, @@ -109,7 +118,7 @@ fn granular_with_all_flags_true_matches_on_request_for_out_of_root_patch() { AskForApproval::OnRequest, &permission_profile, &file_system_sandbox_policy, - &cwd, + &cwd_uri, WindowsSandboxLevel::Disabled, ), SafetyCheck::AskUser, @@ -126,7 +135,7 @@ fn granular_with_all_flags_true_matches_on_request_for_out_of_root_patch() { }), &permission_profile, &file_system_sandbox_policy, - &cwd, + &cwd_uri, WindowsSandboxLevel::Disabled, ), SafetyCheck::AskUser, @@ -137,9 +146,11 @@ fn granular_with_all_flags_true_matches_on_request_for_out_of_root_patch() { fn granular_sandbox_approval_false_rejects_out_of_root_patch() { let tmp = TempDir::new().unwrap(); let cwd = tmp.path().abs(); + let cwd_uri = PathUri::from_abs_path(&cwd); let parent = cwd.parent().unwrap(); let outside_path = parent.join("outside.txt"); - let add_outside = ApplyPatchAction::new_add_for_test(&outside_path, "".to_string()); + let add_outside = + ApplyPatchAction::new_add_for_test(&PathUri::from_abs_path(&outside_path), "".to_string()); let permission_profile = PermissionProfile::workspace_write_with( &[], NetworkSandboxPolicy::Restricted, @@ -160,7 +171,7 @@ fn granular_sandbox_approval_false_rejects_out_of_root_patch() { }), &permission_profile, &file_system_sandbox_policy, - &cwd, + &cwd_uri, WindowsSandboxLevel::Disabled, ), SafetyCheck::Reject { @@ -173,15 +184,17 @@ fn granular_sandbox_approval_false_rejects_out_of_root_patch() { fn read_only_policy_rejects_patch_with_read_only_reason() { let tmp = TempDir::new().unwrap(); let cwd = tmp.path().abs(); + let cwd_uri = PathUri::from_abs_path(&cwd); let inside_path = cwd.join("inside.txt"); - let action = ApplyPatchAction::new_add_for_test(&inside_path, "".to_string()); + let action = + ApplyPatchAction::new_add_for_test(&PathUri::from_abs_path(&inside_path), "".to_string()); let permission_profile = PermissionProfile::read_only(); let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); assert!(!is_write_patch_constrained_to_writable_paths( &action, &file_system_sandbox_policy, - &cwd, + &cwd_uri, )); assert_eq!( assess_patch_safety( @@ -189,7 +202,7 @@ fn read_only_policy_rejects_patch_with_read_only_reason() { AskForApproval::Never, &permission_profile, &file_system_sandbox_policy, - &cwd, + &cwd_uri, WindowsSandboxLevel::Disabled, ), SafetyCheck::Reject { @@ -201,9 +214,13 @@ fn read_only_policy_rejects_patch_with_read_only_reason() { fn explicit_unreadable_paths_prevent_auto_approval_for_external_sandbox() { let tmp = TempDir::new().unwrap(); let cwd = tmp.path().abs(); + let cwd_uri = PathUri::from_abs_path(&cwd); let blocked_path = cwd.join("blocked.txt"); let blocked_absolute = blocked_path; - let action = ApplyPatchAction::new_add_for_test(&blocked_absolute, "".to_string()); + let action = ApplyPatchAction::new_add_for_test( + &PathUri::from_abs_path(&blocked_absolute), + "".to_string(), + ); let permission_profile = PermissionProfile::External { network: NetworkSandboxPolicy::Restricted, }; @@ -225,7 +242,7 @@ fn explicit_unreadable_paths_prevent_auto_approval_for_external_sandbox() { assert!(!is_write_patch_constrained_to_writable_paths( &action, &file_system_sandbox_policy, - &cwd, + &cwd_uri, )); assert_eq!( assess_patch_safety( @@ -233,7 +250,7 @@ fn explicit_unreadable_paths_prevent_auto_approval_for_external_sandbox() { AskForApproval::OnRequest, &permission_profile, &file_system_sandbox_policy, - &cwd, + &cwd_uri, WindowsSandboxLevel::Disabled, ), SafetyCheck::AskUser, @@ -244,10 +261,14 @@ fn explicit_unreadable_paths_prevent_auto_approval_for_external_sandbox() { fn explicit_read_only_subpaths_prevent_auto_approval_for_external_sandbox() { let tmp = TempDir::new().unwrap(); let cwd = tmp.path().abs(); + let cwd_uri = PathUri::from_abs_path(&cwd); let blocked_path = cwd.join("docs").join("blocked.txt"); let blocked_absolute = blocked_path; let docs_absolute = AbsolutePathBuf::resolve_path_against_base("docs", &cwd); - let action = ApplyPatchAction::new_add_for_test(&blocked_absolute, "".to_string()); + let action = ApplyPatchAction::new_add_for_test( + &PathUri::from_abs_path(&blocked_absolute), + "".to_string(), + ); let permission_profile = PermissionProfile::External { network: NetworkSandboxPolicy::Restricted, }; @@ -269,7 +290,7 @@ fn explicit_read_only_subpaths_prevent_auto_approval_for_external_sandbox() { assert!(!is_write_patch_constrained_to_writable_paths( &action, &file_system_sandbox_policy, - &cwd, + &cwd_uri, )); assert_eq!( assess_patch_safety( @@ -277,7 +298,7 @@ fn explicit_read_only_subpaths_prevent_auto_approval_for_external_sandbox() { AskForApproval::OnRequest, &permission_profile, &file_system_sandbox_policy, - &cwd, + &cwd_uri, WindowsSandboxLevel::Disabled, ), SafetyCheck::AskUser, @@ -288,8 +309,10 @@ fn explicit_read_only_subpaths_prevent_auto_approval_for_external_sandbox() { fn missing_project_dot_codex_config_requires_approval() { let tmp = TempDir::new().unwrap(); let cwd = tmp.path().abs(); + let cwd_uri = PathUri::from_abs_path(&cwd); let config_path = cwd.join(".codex").join("config.toml"); - let action = ApplyPatchAction::new_add_for_test(&config_path, "".to_string()); + let action = + ApplyPatchAction::new_add_for_test(&PathUri::from_abs_path(&config_path), "".to_string()); let permission_profile = PermissionProfile::workspace_write_with( &[], NetworkSandboxPolicy::Restricted, @@ -309,7 +332,7 @@ fn missing_project_dot_codex_config_requires_approval() { assert!(!is_write_patch_constrained_to_writable_paths( &action, &file_system_sandbox_policy, - &cwd, + &cwd_uri, )); assert_eq!( assess_patch_safety( @@ -317,7 +340,7 @@ fn missing_project_dot_codex_config_requires_approval() { AskForApproval::OnRequest, &permission_profile, &file_system_sandbox_policy, - &cwd, + &cwd_uri, WindowsSandboxLevel::Disabled, ), SafetyCheck::AskUser, diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index 8034dfd627..34c41d835d 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -632,7 +632,7 @@ mod tests { use codex_protocol::exec_output::ExecToolCallOutput; use codex_protocol::items::TurnItem; use codex_protocol::protocol::PatchApplyStatus; - use codex_utils_absolute_path::AbsolutePathBuf; + use codex_utils_path_uri::PathUri; use std::sync::Arc; use tempfile::tempdir; use tokio::sync::Mutex; @@ -645,7 +645,7 @@ mod tests { make_session_and_context_with_dynamic_tools_and_rx(Vec::new()).await; let tracker = Arc::new(Mutex::new(TurnDiffTracker::new())); let dir = tempdir().expect("tempdir"); - let cwd = AbsolutePathBuf::from_absolute_path(dir.path()).expect("absolute cwd"); + let cwd = PathUri::from_path(dir.path()).expect("absolute cwd"); let mut stdout = Vec::new(); let mut stderr = Vec::new(); let delta = codex_apply_patch::apply_patch( @@ -729,7 +729,7 @@ mod tests { make_session_and_context_with_dynamic_tools_and_rx(Vec::new()).await; let tracker = Arc::new(Mutex::new(TurnDiffTracker::new())); let dir = tempdir().expect("tempdir"); - let cwd = AbsolutePathBuf::from_absolute_path(dir.path()).expect("absolute cwd"); + let cwd = PathUri::from_path(dir.path()).expect("absolute cwd"); for patch in [ "*** Begin Patch\n*** Add File: a.txt\n+one\n*** End Patch", @@ -782,7 +782,7 @@ mod tests { make_session_and_context_with_dynamic_tools_and_rx(Vec::new()).await; let tracker = Arc::new(Mutex::new(TurnDiffTracker::new())); let dir = tempdir().expect("tempdir"); - let cwd = AbsolutePathBuf::from_absolute_path(dir.path()).expect("absolute cwd"); + let cwd = PathUri::from_path(dir.path()).expect("absolute cwd"); let mut stdout = Vec::new(); let mut stderr = Vec::new(); let delta = codex_apply_patch::apply_patch( diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 9a00fac94d..5bb2b013c3 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -203,33 +203,21 @@ fn format_update_chunks_for_progress(chunks: &[codex_apply_patch::UpdateFileChun unified_diff } -fn file_paths_for_action(action: &ApplyPatchAction) -> Vec { +fn file_paths_for_action(action: &ApplyPatchAction) -> Vec { let mut keys = Vec::new(); - // TODO(anp): Migrate permission path accounting to PathUri. - let Ok(cwd) = action.cwd.to_abs_path() else { - return keys; - }; - for (path, change) in action.changes() { - if let Some(key) = to_abs_path(&cwd, path) { - keys.push(key); - } + keys.push(path.clone()); if let ApplyPatchFileChange::Update { move_path, .. } = change && let Some(dest) = move_path - && let Some(key) = to_abs_path(&cwd, dest) { - keys.push(key); + keys.push(dest.clone()); } } keys } -fn to_abs_path(cwd: &AbsolutePathBuf, path: &Path) -> Option { - Some(AbsolutePathBuf::resolve_path_against_base(path, cwd)) -} - fn write_permissions_for_paths( file_paths: &[AbsolutePathBuf], file_system_sandbox_policy: &codex_protocol::permissions::FileSystemSandboxPolicy, @@ -275,13 +263,14 @@ async fn effective_patch_permissions( turn: &TurnContext, environment_id: &str, action: &ApplyPatchAction, - cwd: &AbsolutePathBuf, -) -> ( - Vec, + cwd: &PathUri, +) -> std::io::Result<( + Vec, crate::tools::handlers::EffectiveAdditionalPermissions, codex_protocol::permissions::FileSystemSandboxPolicy, -) { +)> { let file_paths = file_paths_for_action(action); + let native_cwd = cwd.to_abs_path()?; let granted_permissions = merge_permission_profiles( session .granted_session_permissions(environment_id) @@ -297,19 +286,43 @@ async fn effective_patch_permissions( &base_file_system_sandbox_policy, granted_permissions.as_ref(), ); + let native_file_paths = file_paths + .iter() + .map(PathUri::to_abs_path) + .collect::, _>>()?; let effective_additional_permissions = apply_granted_turn_permissions( session, environment_id, - cwd.as_path(), + native_cwd.as_path(), crate::sandboxing::SandboxPermissions::UseDefault, - write_permissions_for_paths(&file_paths, &file_system_sandbox_policy, cwd), + write_permissions_for_paths(&native_file_paths, &file_system_sandbox_policy, &native_cwd), ) .await; - ( + Ok(( file_paths, effective_additional_permissions, file_system_sandbox_policy, + )) +} + +fn patch_permissions_without_path_matching( + action: &ApplyPatchAction, +) -> ( + Vec, + crate::tools::handlers::EffectiveAdditionalPermissions, + codex_protocol::permissions::FileSystemSandboxPolicy, +) { + // TODO(anp): Make permission matching operate on PathUri. Until then, foreign paths skip + // permission matching; a managed turn still fails closed at the platform sandbox boundary. + ( + file_paths_for_action(action), + crate::tools::handlers::EffectiveAdditionalPermissions { + sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, + additional_permissions: None, + permissions_preapproved: false, + }, + codex_protocol::permissions::FileSystemSandboxPolicy::unrestricted(), ) } @@ -366,21 +379,18 @@ impl ApplyPatchHandler { "apply_patch is unavailable in this session".to_string(), )); }; - // TODO(anp): Migrate apply-patch verification and permission accounting to PathUri so - // patches can target environment-native foreign paths without host projection. - let cwd = turn_environment.cwd().to_abs_path().map_err(|err| { - FunctionCallError::RespondToModel(format!( - "apply_patch cwd `{}` is not native to the Codex host: {err}", - turn_environment.cwd() - )) - })?; let fs = turn_environment.environment.get_filesystem(); let sandbox = turn.file_system_sandbox_context( /*additional_permissions*/ None, turn_environment.cwd(), ); - match codex_apply_patch::verify_apply_patch_args(args, &cwd, fs.as_ref(), Some(&sandbox)) - .await + match codex_apply_patch::verify_apply_patch_args( + args, + turn_environment.cwd(), + fs.as_ref(), + Some(&sandbox), + ) + .await { codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => { let (file_paths, effective_additional_permissions, file_system_sandbox_policy) = @@ -389,9 +399,10 @@ impl ApplyPatchHandler { turn.as_ref(), &turn_environment.environment_id, &changes, - &cwd, + turn_environment.cwd(), ) - .await; + .await + .unwrap_or_else(|_| patch_permissions_without_path_matching(&changes)); match apply_patch::apply_patch(turn.as_ref(), &file_system_sandbox_policy, changes) .await { @@ -531,7 +542,7 @@ impl CoreToolRuntime for ApplyPatchHandler { #[allow(clippy::too_many_arguments)] pub(crate) async fn intercept_apply_patch( command: &[String], - cwd: &AbsolutePathBuf, + cwd: &PathUri, fs: &dyn ExecutorFileSystem, turn_environment: TurnEnvironment, session: Arc, @@ -540,9 +551,7 @@ pub(crate) async fn intercept_apply_patch( call_id: &str, tool_name: &str, ) -> Result, FunctionCallError> { - let sandbox_cwd = PathUri::from_abs_path(cwd); - let sandbox = - turn.file_system_sandbox_context(/*additional_permissions*/ None, &sandbox_cwd); + let sandbox = turn.file_system_sandbox_context(/*additional_permissions*/ None, cwd); match codex_apply_patch::maybe_parse_apply_patch_verified(command, cwd, fs, Some(&sandbox)) .await { @@ -555,7 +564,8 @@ pub(crate) async fn intercept_apply_patch( &changes, cwd, ) - .await; + .await + .unwrap_or_else(|_| patch_permissions_without_path_matching(&changes)); match apply_patch::apply_patch(turn.as_ref(), &file_system_sandbox_policy, changes) .await { diff --git a/codex-rs/core/src/tools/handlers/apply_patch_tests.rs b/codex-rs/core/src/tools/handlers/apply_patch_tests.rs index e9a118641a..f8d5f9710e 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch_tests.rs @@ -225,6 +225,8 @@ async fn approval_keys_include_move_destination() { +new content *** End Patch"#; let argv = vec!["apply_patch".to_string(), patch.to_string()]; + // TODO(anp): Keep apply_patch handler test cwd values as PathUri. + let cwd = PathUri::from_abs_path(&cwd); let action = match codex_apply_patch::maybe_parse_apply_patch_verified( &argv, &cwd, diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 3e1f160e9c..131bcdba95 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -26,6 +26,7 @@ use crate::tools::sandboxing::ToolCtx; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::protocol::ExecCommandSource; use codex_tools::ToolName; +use codex_utils_path_uri::PathUri; mod shell_command; @@ -139,9 +140,10 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result, + pub file_paths: Vec, pub changes: std::collections::HashMap, pub exec_approval_requirement: ExecApprovalRequirement, pub additional_permissions: Option, @@ -81,10 +80,15 @@ impl ApplyPatchRuntime { ) -> std::io::Result { // TODO(anp): Remove this conversion once the guardian API supports PathUri. let cwd = req.action.cwd.to_abs_path()?; + let files = req + .file_paths + .iter() + .map(PathUri::to_abs_path) + .collect::>>()?; Ok(GuardianApprovalRequest::ApplyPatch { id: call_id.to_string(), cwd, - files: req.file_paths.clone(), + files, patch: req.action.patch.clone(), }) } @@ -245,15 +249,9 @@ impl ToolRuntime for ApplyPatchRunti let sandbox = Self::file_system_sandbox_context_for_attempt(req, attempt); let mut stdout = Vec::new(); let mut stderr = Vec::new(); - // TODO(anp): Teach apply_patch to operate on PathUri directly. - let cwd = req - .action - .cwd - .to_abs_path() - .map_err(|err| ToolError::Rejected(err.to_string()))?; let result = codex_apply_patch::apply_patch( &req.action.patch, - &cwd, + &req.action.cwd, &mut stdout, &mut stderr, fs.as_ref(), diff --git a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs index 0dc1ed9a67..054f2c7c15 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -53,13 +53,14 @@ async fn guardian_review_request_includes_patch_context() { let path = std::env::temp_dir() .join("guardian-apply-patch-test.txt") .abs(); - let action = ApplyPatchAction::new_add_for_test(&path, "hello".to_string()); + let action = + ApplyPatchAction::new_add_for_test(&PathUri::from_abs_path(&path), "hello".to_string()); let expected_cwd = action.cwd.to_abs_path().expect("native patch cwd"); let expected_patch = action.patch.clone(); let request = ApplyPatchRequest { turn_environment: test_turn_environment(codex_exec_server::LOCAL_ENVIRONMENT_ID), action, - file_paths: vec![path.clone()], + file_paths: vec![PathUri::from_abs_path(&path)], changes: HashMap::from([( path.to_path_buf(), FileChange::Add { @@ -82,7 +83,7 @@ async fn guardian_review_request_includes_patch_context() { GuardianApprovalRequest::ApplyPatch { id: "call-1".to_string(), cwd: expected_cwd, - files: request.file_paths, + files: vec![path], patch: expected_patch, } ); @@ -94,12 +95,13 @@ async fn permission_request_payload_uses_apply_patch_hook_name_and_aliases() { let path = std::env::temp_dir() .join("apply-patch-permission-request-payload.txt") .abs(); - let action = ApplyPatchAction::new_add_for_test(&path, "hello".to_string()); + let action = + ApplyPatchAction::new_add_for_test(&PathUri::from_abs_path(&path), "hello".to_string()); let expected_patch = action.patch.clone(); let req = ApplyPatchRequest { turn_environment: test_turn_environment(codex_exec_server::LOCAL_ENVIRONMENT_ID), action, - file_paths: vec![path], + file_paths: vec![PathUri::from_abs_path(&path)], changes: HashMap::new(), exec_approval_requirement: ExecApprovalRequirement::NeedsApproval { reason: None, @@ -130,10 +132,11 @@ async fn approval_keys_include_environment_id() { let path = std::env::temp_dir() .join("apply-patch-approval-key.txt") .abs(); + let path_uri = PathUri::from_abs_path(&path); let req = ApplyPatchRequest { turn_environment: test_turn_environment("remote"), - action: ApplyPatchAction::new_add_for_test(&path, "hello".to_string()), - file_paths: vec![path.clone()], + action: ApplyPatchAction::new_add_for_test(&path_uri, "hello".to_string()), + file_paths: vec![path_uri.clone()], changes: HashMap::new(), exec_approval_requirement: ExecApprovalRequirement::Skip { bypass_sandbox: false, @@ -150,7 +153,7 @@ async fn approval_keys_include_environment_id() { serde_json::json!([ { "environment_id": "remote", - "path": path, + "path": path_uri, } ]) ); @@ -164,8 +167,11 @@ async fn sandbox_cwd_uses_patch_action_cwd() { .abs(); let req = ApplyPatchRequest { turn_environment: test_turn_environment(codex_exec_server::LOCAL_ENVIRONMENT_ID), - action: ApplyPatchAction::new_add_for_test(&path, "hello".to_string()), - file_paths: vec![path.clone()], + action: ApplyPatchAction::new_add_for_test( + &PathUri::from_abs_path(&path), + "hello".to_string(), + ), + file_paths: vec![PathUri::from_abs_path(&path)], changes: HashMap::new(), exec_approval_requirement: ExecApprovalRequirement::Skip { bypass_sandbox: false, @@ -192,8 +198,11 @@ async fn file_system_sandbox_context_uses_active_attempt() { }; let req = ApplyPatchRequest { turn_environment: test_turn_environment(codex_exec_server::LOCAL_ENVIRONMENT_ID), - action: ApplyPatchAction::new_add_for_test(&path, "hello".to_string()), - file_paths: vec![path.clone()], + action: ApplyPatchAction::new_add_for_test( + &PathUri::from_abs_path(&path), + "hello".to_string(), + ), + file_paths: vec![PathUri::from_abs_path(&path)], changes: HashMap::new(), exec_approval_requirement: ExecApprovalRequirement::Skip { bypass_sandbox: false, @@ -259,8 +268,11 @@ async fn no_sandbox_attempt_has_no_file_system_context() { .abs(); let req = ApplyPatchRequest { turn_environment: test_turn_environment(codex_exec_server::LOCAL_ENVIRONMENT_ID), - action: ApplyPatchAction::new_add_for_test(&path, "hello".to_string()), - file_paths: vec![path.clone()], + action: ApplyPatchAction::new_add_for_test( + &PathUri::from_abs_path(&path), + "hello".to_string(), + ), + file_paths: vec![PathUri::from_abs_path(&path)], changes: HashMap::new(), exec_approval_requirement: ExecApprovalRequirement::Skip { bypass_sandbox: false, diff --git a/codex-rs/core/src/turn_diff_tracker_tests.rs b/codex-rs/core/src/turn_diff_tracker_tests.rs index a7be3d2b0c..9c58de913b 100644 --- a/codex-rs/core/src/turn_diff_tracker_tests.rs +++ b/codex-rs/core/src/turn_diff_tracker_tests.rs @@ -4,7 +4,7 @@ use codex_apply_patch::MaybeApplyPatchVerified; use codex_exec_server::LOCAL_FS; use codex_git_utils::ApplyGitRequest; use codex_git_utils::apply_git_patch; -use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; use std::fs; use std::path::Path; @@ -18,7 +18,7 @@ fn git_blob_sha1_hex(data: &str) -> String { } async fn apply_verified_patch(root: &Path, patch: &str) -> AppliedPatchDelta { - let cwd = AbsolutePathBuf::from_absolute_path(root).expect("absolute tempdir path"); + let cwd = PathUri::from_path(root).expect("absolute tempdir path"); let argv = vec!["apply_patch".to_string(), patch.to_string()]; match codex_apply_patch::maybe_parse_apply_patch_verified( &argv, diff --git a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs index ae3c9406a9..cff8ded739 100644 --- a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs +++ b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs @@ -50,7 +50,11 @@ const APP_SERVER_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_s #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { const CALL_ID: &str = "wine-cmd-smoke"; + const PATCH_CALL_ID: &str = "wine-apply-patch"; + const VERIFY_CALL_ID: &str = "wine-verify-patch"; + const PATCH_FILE: &str = "codex-apply-patch-smoke.txt"; const COMMAND: &str = r#"if ((Get-Location).Path -ne 'C:\windows') { exit 1 }"#; + const VERIFY_COMMAND: &str = r#"$path = Join-Path (Get-Location) 'codex-apply-patch-smoke.txt'; if (-not (Test-Path $path)) { exit 1 }; if ([IO.File]::ReadAllText($path) -ne "patched through unified exec`n") { exit 2 }; Remove-Item $path; Write-Output 'PATCH_VERIFIED'"#; WineExecServer .scope(|exec_server_url| async move { @@ -63,6 +67,22 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { "workdir": r"C:\windows", "yield_time_ms": 10_000, }))?; + let patch = format!( + "*** Begin Patch\n*** Add File: {PATCH_FILE}\n+patched through unified exec\n*** End Patch" + ); + let patch_arguments = serde_json::to_string(&json!({ + "cmd": format!("apply_patch <<'EOF'\n{patch}\nEOF\n"), + "login": false, + // Resolve this relative workdir using the selected Windows environment cwd. + "workdir": r"apply-patch-smoke\nested", + "yield_time_ms": 10_000, + }))?; + let verify_arguments = serde_json::to_string(&json!({ + "cmd": VERIFY_COMMAND, + "login": false, + "workdir": r"apply-patch-smoke\nested", + "yield_time_ms": 10_000, + }))?; let response_mock = mount_sse_sequence( &server, vec![ @@ -73,9 +93,19 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { ]), sse(vec![ ev_response_created("resp-2"), - ev_assistant_message("msg-1", "done"), + ev_function_call(PATCH_CALL_ID, "exec_command", &patch_arguments), ev_completed("resp-2"), ]), + sse(vec![ + ev_response_created("resp-3"), + ev_function_call(VERIFY_CALL_ID, "exec_command", &verify_arguments), + ev_completed("resp-3"), + ]), + sse(vec![ + ev_response_created("resp-4"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-4"), + ]), ], ) .await; @@ -97,7 +127,7 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { test.config.cwd.clone(), vec![TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), - cwd: PathUri::parse("file:///C:/windows")?, + cwd: PathUri::parse("file:///C:/codex-home")?, }], ); @@ -130,6 +160,7 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { let mut begin = None; let mut end = None; + let mut patch_end = None; let mut turn_complete = false; loop { match wait_for_event(&test.codex, |_| true).await { @@ -139,6 +170,9 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { EventMsg::ExecCommandEnd(event) if event.call_id == CALL_ID => { end = Some(event) } + EventMsg::PatchApplyEnd(event) if event.call_id == PATCH_CALL_ID => { + patch_end = Some(event) + } EventMsg::TurnComplete(_) => turn_complete = true, _ => {} } @@ -162,14 +196,48 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { assert_eq!((&begin.cwd, &end.cwd), (&expected_cwd, &expected_cwd)); assert_eq!((end.exit_code, end.status), (0, ExecCommandStatus::Completed)); + let patch_end = patch_end.context("intercepted apply_patch should emit an end event")?; + assert!( + patch_end.success, + "intercepted apply_patch failed: stdout={:?} stderr={:?}", + patch_end.stdout, patch_end.stderr + ); + assert!( + patch_end + .changes + .contains_key(&std::path::PathBuf::from(format!( + r"C:\codex-home\apply-patch-smoke\nested\{PATCH_FILE}" + ))), + "apply_patch should retain the Windows cwd: {:?}", + patch_end.changes + ); let request = response_mock .last_request() .context("model should receive the command output")?; + let (verify_output, verify_success) = request + .function_call_output_content_and_success(VERIFY_CALL_ID) + .context("verification output should be present")?; + anyhow::ensure!( + verify_success != Some(false), + "verification command failed: {verify_output:?}" + ); + anyhow::ensure!( + verify_output + .as_deref() + .is_some_and(|output| output.contains("PATCH_VERIFIED")), + "verification command did not confirm the patched file: {verify_output:?}" + ); + let (_output, success) = request .function_call_output_content_and_success(CALL_ID) .context("command output should be present")?; assert_ne!(success, Some(false)); - + let (patch_output, patch_success) = request + .function_call_output_content_and_success(PATCH_CALL_ID) + .context("apply_patch output should be present")?; + let patch_output = patch_output.context("apply_patch output should contain text")?; + assert!(patch_output.contains(PATCH_FILE)); + assert_ne!(patch_success, Some(false)); Ok(()) }) .await diff --git a/codex-rs/utils/path-uri/src/lib.rs b/codex-rs/utils/path-uri/src/lib.rs index 5e8b5b2cbc..6bc80b5561 100644 --- a/codex-rs/utils/path-uri/src/lib.rs +++ b/codex-rs/utils/path-uri/src/lib.rs @@ -12,6 +12,7 @@ use serde::Serializer; use std::fmt; use std::io; use std::path::Path; +use std::path::PathBuf; use std::str::FromStr; use thiserror::Error; use ts_rs::TS; @@ -96,6 +97,7 @@ impl PathUri { Self::from_opaque_path_bytes(&path_bytes) } + /// Parses an absolute native path using the specified path convention. pub(crate) fn from_absolute_native_path( path: &str, convention: PathConvention, @@ -202,6 +204,11 @@ impl PathUri { .map(decode_uri_path) } + /// Renders this URI as a path-flavored string using its inferred convention. + pub fn to_path_buf(&self) -> PathBuf { + PathBuf::from(self.inferred_native_path_string()) + } + /// Returns the parent URI, or `None` for the URI root or an opaque fallback /// URI created by [`Self::from_abs_path`]. pub fn parent(&self) -> Option { @@ -310,15 +317,19 @@ impl PathUri { /// Converts this file URI to a path using the current host's path rules. /// - /// Conversion should succeed when the URI was created from an - /// [`AbsolutePathBuf`] on the current host, including fallback URIs created - /// by [`Self::from_abs_path`]. It may fail when the URI came from a different - /// operating system and its `file:` URI form cannot be represented using - /// the current host's path rules, such as a UNC authority on POSIX or a - /// POSIX root on Windows. Because a `file:` URI does not record its source - /// operating system, callers should only use this method when the URI is - /// known to identify a path on the current host. + /// The URI's inferred path convention must match the current host. Conversion should succeed + /// when the URI was created from an [`AbsolutePathBuf`] on the current host, including fallback + /// URIs created by [`Self::from_abs_path`]. Foreign conventions are rejected rather than being + /// projected onto a syntactically valid but unrelated host path. pub fn to_abs_path(&self) -> io::Result { + if self.infer_path_convention() != Some(PathConvention::native()) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + PathUriParseError::InvalidFileUriPath { + path: self.to_string(), + }, + )); + } if let Some(path_bytes) = decode_bad_path_uri(&self.0) { #[cfg(unix)] let decoded_path = { @@ -688,6 +699,17 @@ impl PathConvention { pub const fn native() -> Self { Self::Posix } + + /// Splits absolute or relative native path text into lexical segments. + /// + /// This does not validate the path or require it to be absolute. POSIX paths split on `/`, + /// while Windows paths split on both `\\` and `/`. Empty segments are retained. + pub fn path_segments(self, path: &str) -> impl DoubleEndedIterator { + path.split(move |character| match self { + Self::Posix => character == '/', + Self::Windows => matches!(character, '/' | '\\'), + }) + } } impl fmt::Display for PathConvention { diff --git a/codex-rs/utils/path-uri/src/tests.rs b/codex-rs/utils/path-uri/src/tests.rs index 4ccb9e9c81..328142dc6d 100644 --- a/codex-rs/utils/path-uri/src/tests.rs +++ b/codex-rs/utils/path-uri/src/tests.rs @@ -34,21 +34,24 @@ fn file_uri_round_trips_an_absolute_path() { #[test] fn non_native_uri_io_conversion_is_invalid_input() { #[cfg(unix)] - let uri = PathUri::parse("file://server/share/file.txt").expect("valid file URI"); + let uris = ["file://server/share/file.txt", "file:///C:/workspace"]; #[cfg(windows)] - let uri = PathUri::parse("file:///usr/local/file.txt").expect("valid file URI"); + let uris = ["file:///usr/local/file.txt"]; - let error = uri - .to_abs_path() - .expect_err("URI should not be host-native"); + for uri in uris { + let uri = PathUri::parse(uri).expect("valid file URI"); + let error = uri + .to_abs_path() + .expect_err("URI should not be host-native"); - assert_eq!( - (error.kind(), error.to_string()), - ( - io::ErrorKind::InvalidInput, - format!("'{uri}' is invalid on '{}'", std::env::consts::OS), - ) - ); + assert_eq!( + (error.kind(), error.to_string()), + ( + io::ErrorKind::InvalidInput, + format!("'{uri}' is invalid on '{}'", std::env::consts::OS), + ) + ); + } } #[test] @@ -90,6 +93,35 @@ fn infers_path_conventions_from_uri_shape() { } } +#[test] +fn path_convention_splits_absolute_relative_and_bare_path_text() { + for (convention, path, expected) in [ + ( + PathConvention::Posix, + "/usr/local/bin/bash", + vec!["", "usr", "local", "bin", "bash"], + ), + ( + PathConvention::Posix, + r"tools\pwsh.exe", + vec![r"tools\pwsh.exe"], + ), + ( + PathConvention::Windows, + r"C:\Program Files\PowerShell\7\pwsh.exe", + vec!["C:", "Program Files", "PowerShell", "7", "pwsh.exe"], + ), + ( + PathConvention::Windows, + "tools/pwsh.exe", + vec!["tools", "pwsh.exe"], + ), + (PathConvention::Windows, "cmd.exe", vec!["cmd.exe"]), + ] { + assert_eq!(convention.path_segments(path).collect::>(), expected); + } +} + #[test] fn drive_shaped_posix_uri_is_intentionally_inferred_as_windows() { let path = PathUri::parse("file:///C:/actually/a/posix/path").expect("valid path URI"); @@ -475,6 +507,20 @@ fn basename_uses_decoded_uri_segments() { } } +#[test] +fn path_buf_uses_the_inferred_native_spelling() { + let windows = PathUri::parse("file:///C:/Program%20Files/pwsh.exe").expect("Windows URI"); + let posix = PathUri::parse("file:///usr/local/bin/bash").expect("POSIX URI"); + + assert_eq!( + (windows.to_path_buf(), posix.to_path_buf()), + ( + PathBuf::from(r"C:\Program Files\pwsh.exe"), + PathBuf::from("/usr/local/bin/bash"), + ) + ); +} + #[test] fn parent_uses_uri_hierarchy_and_preserves_authority() { for (input, expected) in [ From f4602b7516a28c09c89349300799ef347bbf7668 Mon Sep 17 00:00:00 2001 From: rka-oai Date: Thu, 18 Jun 2026 13:12:11 -0700 Subject: [PATCH 044/112] Add app-server current-time impl (varlatency 3/n) (#28835) ## What Server should request: ``` { "id": 42, "method": "currentTime/read", "params": { "threadId": "11111111-1111-1111-1111-aaaaafdc2c11" } } ``` Client should respond with something like: ```rust { "id": 42, "result": { "currentTimeAt": 1781717655 } } ``` ## Why Sessions configured with `clock_source = "external"` need a thread-specific external time source before inference. The system clock remains the default production provider. ## Validation - `cargo test -p codex-app-server-protocol` - `cargo test -p codex-app-server --test all current_time_read_round_trip_adds_reminder_to_model_input` - `cargo test -p codex-app-server first_attestation_capable_connection_for_thread_only_uses_thread_subscribers` - `cargo test -p codex-analytics` - `just fix -p codex-app-server-protocol` - `just fix -p codex-app-server` Stacked on #28824. --- .../schema/typescript/ServerRequest.ts | 2 +- codex-rs/app-server-protocol/src/export.rs | 50 ++++-- .../src/protocol/common.rs | 56 ++++++- .../src/protocol/v2/current_time.rs | 20 +++ .../src/protocol/v2/mod.rs | 2 + codex-rs/app-server/README.md | 4 + codex-rs/app-server/src/current_time.rs | 146 ++++++++++++++++++ codex-rs/app-server/src/lib.rs | 1 + codex-rs/app-server/src/message_processor.rs | 7 +- .../thread_processor_tests.rs | 25 +++ codex-rs/app-server/src/thread_state.rs | 17 ++ .../app-server/tests/suite/v2/current_time.rs | 130 ++++++++++++++++ codex-rs/app-server/tests/suite/v2/mod.rs | 1 + codex-rs/exec/src/lib.rs | 9 ++ .../tui/src/app/app_server_event_targets.rs | 3 + codex-rs/tui/src/app/app_server_requests.rs | 7 + codex-rs/tui/src/app/side.rs | 1 + .../tui/src/chatwidget/protocol_requests.rs | 1 + 18 files changed, 463 insertions(+), 19 deletions(-) create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/current_time.rs create mode 100644 codex-rs/app-server/src/current_time.rs create mode 100644 codex-rs/app-server/tests/suite/v2/current_time.rs diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts index 80e9ffc116..89a5440056 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts @@ -16,4 +16,4 @@ import type { ToolRequestUserInputParams } from "./v2/ToolRequestUserInputParams /** * Request initiated from the server and sent to the client. */ -export type ServerRequest = { "method": "item/commandExecution/requestApproval", id: RequestId, params: CommandExecutionRequestApprovalParams, } | { "method": "item/fileChange/requestApproval", id: RequestId, params: FileChangeRequestApprovalParams, } | { "method": "item/tool/requestUserInput", id: RequestId, params: ToolRequestUserInputParams, } | { "method": "mcpServer/elicitation/request", id: RequestId, params: McpServerElicitationRequestParams, } | { "method": "item/permissions/requestApproval", id: RequestId, params: PermissionsRequestApprovalParams, } | { "method": "item/tool/call", id: RequestId, params: DynamicToolCallParams, } | { "method": "account/chatgptAuthTokens/refresh", id: RequestId, params: ChatgptAuthTokensRefreshParams, } | { "method": "attestation/generate", id: RequestId, params: AttestationGenerateParams, } | { "method": "applyPatchApproval", id: RequestId, params: ApplyPatchApprovalParams, } | { "method": "execCommandApproval", id: RequestId, params: ExecCommandApprovalParams, }; +export type ServerRequest ={ "method": "item/commandExecution/requestApproval", id: RequestId, params: CommandExecutionRequestApprovalParams, } | { "method": "item/fileChange/requestApproval", id: RequestId, params: FileChangeRequestApprovalParams, } | { "method": "item/tool/requestUserInput", id: RequestId, params: ToolRequestUserInputParams, } | { "method": "mcpServer/elicitation/request", id: RequestId, params: McpServerElicitationRequestParams, } | { "method": "item/permissions/requestApproval", id: RequestId, params: PermissionsRequestApprovalParams, } | { "method": "item/tool/call", id: RequestId, params: DynamicToolCallParams, } | { "method": "account/chatgptAuthTokens/refresh", id: RequestId, params: ChatgptAuthTokensRefreshParams, } | { "method": "attestation/generate", id: RequestId, params: AttestationGenerateParams, } | { "method": "applyPatchApproval", id: RequestId, params: ApplyPatchApprovalParams, } | { "method": "execCommandApproval", id: RequestId, params: ExecCommandApprovalParams, }; diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index 5e6c2ad015..91eca690f1 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -14,6 +14,9 @@ use crate::export_server_responses; use crate::protocol::common::EXPERIMENTAL_CLIENT_METHOD_PARAM_TYPES; use crate::protocol::common::EXPERIMENTAL_CLIENT_METHOD_RESPONSE_TYPES; use crate::protocol::common::EXPERIMENTAL_CLIENT_METHODS; +use crate::protocol::common::EXPERIMENTAL_SERVER_METHOD_PARAM_TYPES; +use crate::protocol::common::EXPERIMENTAL_SERVER_METHOD_RESPONSE_TYPES; +use crate::protocol::common::EXPERIMENTAL_SERVER_METHODS; use anyhow::Context; use anyhow::Result; use anyhow::anyhow; @@ -249,10 +252,10 @@ fn filter_experimental_ts(out_dir: &Path) -> Result<()> { let registered_fields = experimental_fields(); let experimental_method_types = experimental_method_types(); // Most generated TS files are filtered by schema processing, but - // `ClientRequest.ts` and any type with `#[experimental(...)]` fields need - // direct post-processing because they encode method/field information in - // file-local unions/interfaces. - filter_client_request_ts(out_dir, EXPERIMENTAL_CLIENT_METHODS)?; + // Request unions and types with `#[experimental(...)]` fields need direct + // post-processing because they encode method/field information locally. + filter_request_ts(out_dir, "ClientRequest.ts", EXPERIMENTAL_CLIENT_METHODS)?; + filter_request_ts(out_dir, "ServerRequest.ts", EXPERIMENTAL_SERVER_METHODS)?; filter_experimental_type_fields_ts(out_dir, ®istered_fields)?; remove_generated_type_files(out_dir, &experimental_method_types, "ts")?; Ok(()) @@ -261,10 +264,13 @@ fn filter_experimental_ts(out_dir: &Path) -> Result<()> { pub(crate) fn filter_experimental_ts_tree(tree: &mut BTreeMap) -> Result<()> { let registered_fields = experimental_fields(); let experimental_method_types = experimental_method_types(); - if let Some(content) = tree.get_mut(Path::new("ClientRequest.ts")) { - let filtered = - filter_client_request_ts_contents(std::mem::take(content), EXPERIMENTAL_CLIENT_METHODS); - *content = filtered; + for (file_name, experimental_methods) in [ + ("ClientRequest.ts", EXPERIMENTAL_CLIENT_METHODS), + ("ServerRequest.ts", EXPERIMENTAL_SERVER_METHODS), + ] { + if let Some(content) = tree.get_mut(Path::new(file_name)) { + *content = filter_request_ts_contents(std::mem::take(content), experimental_methods); + } } let mut fields_by_type_name: HashMap> = HashMap::new(); @@ -293,21 +299,21 @@ pub(crate) fn filter_experimental_ts_tree(tree: &mut BTreeMap) Ok(()) } -/// Removes union arms from `ClientRequest.ts` for methods marked experimental. -fn filter_client_request_ts(out_dir: &Path, experimental_methods: &[&str]) -> Result<()> { - let path = out_dir.join("ClientRequest.ts"); +/// Removes union arms from a generated request type for methods marked experimental. +fn filter_request_ts(out_dir: &Path, file_name: &str, experimental_methods: &[&str]) -> Result<()> { + let path = out_dir.join(file_name); if !path.exists() { return Ok(()); } let mut content = fs::read_to_string(&path).with_context(|| format!("Failed to read {}", path.display()))?; - content = filter_client_request_ts_contents(content, experimental_methods); + content = filter_request_ts_contents(content, experimental_methods); fs::write(&path, content).with_context(|| format!("Failed to write {}", path.display()))?; Ok(()) } -fn filter_client_request_ts_contents(mut content: String, experimental_methods: &[&str]) -> String { +fn filter_request_ts_contents(mut content: String, experimental_methods: &[&str]) -> String { let Some((prefix, body, suffix)) = split_type_alias(&content) else { return content; }; @@ -404,6 +410,7 @@ fn filter_experimental_schema(bundle: &mut Value) -> Result<()> { filter_experimental_fields_in_root(bundle, ®istered_fields); filter_experimental_fields_in_definitions(bundle, ®istered_fields); prune_experimental_methods(bundle, EXPERIMENTAL_CLIENT_METHODS); + prune_experimental_methods(bundle, EXPERIMENTAL_SERVER_METHODS); remove_experimental_method_type_definitions(bundle); Ok(()) } @@ -560,6 +567,8 @@ fn experimental_method_types() -> HashSet { collect_experimental_type_names(EXPERIMENTAL_CLIENT_METHOD_PARAM_TYPES, &mut type_names); collect_experimental_type_names(EXPERIMENTAL_CLIENT_METHOD_RESPONSE_TYPES, &mut type_names); collect_experimental_type_names(EXPERIMENTAL_CLIENT_METHOD_DEPENDENCY_TYPES, &mut type_names); + collect_experimental_type_names(EXPERIMENTAL_SERVER_METHOD_PARAM_TYPES, &mut type_names); + collect_experimental_type_names(EXPERIMENTAL_SERVER_METHOD_RESPONSE_TYPES, &mut type_names); type_names } @@ -2118,6 +2127,13 @@ mod tests { client_request_ts.contains("MockExperimentalMethodParams"), false ); + let server_request_ts = std::str::from_utf8( + fixture_tree + .get(Path::new("ServerRequest.ts")) + .ok_or_else(|| anyhow::anyhow!("missing ServerRequest.ts fixture"))?, + )?; + assert_eq!(server_request_ts.contains("currentTime/read"), false); + assert_eq!(server_request_ts.contains("CurrentTimeReadParams"), false); let typescript_index = std::str::from_utf8( fixture_tree .get(Path::new("index.ts")) @@ -2138,6 +2154,14 @@ mod tests { fixture_tree.contains_key(Path::new("v2/MockExperimentalMethodResponse.ts")), false ); + assert_eq!( + fixture_tree.contains_key(Path::new("v2/CurrentTimeReadParams.ts")), + false + ); + assert_eq!( + fixture_tree.contains_key(Path::new("v2/CurrentTimeReadResponse.ts")), + false + ); assert_eq!( fixture_tree.contains_key(Path::new("v2/RemoteControlClient.ts")), false diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index c1fac138a8..e5a882b3b5 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1184,7 +1184,8 @@ client_request_definitions! { macro_rules! server_request_definitions { ( $( - $(#[$variant_meta:meta])* + $(#[experimental($reason:expr)])? + $(#[doc = $variant_doc:literal])* $variant:ident $(=> $wire:literal)? { params: $params:ty, response: $response:ty, @@ -1197,7 +1198,7 @@ macro_rules! server_request_definitions { #[serde(tag = "method", rename_all = "camelCase")] pub enum ServerRequest { $( - $(#[$variant_meta])* + $(#[doc = $variant_doc])* $(#[serde(rename = $wire)] #[ts(rename = $wire)])? $variant { #[serde(rename = "id")] @@ -1237,7 +1238,7 @@ macro_rules! server_request_definitions { #[serde(tag = "method", rename_all = "camelCase")] pub enum ServerResponse { $( - $(#[$variant_meta])* + $(#[doc = $variant_doc])* $(#[serde(rename = $wire)])? $variant { #[serde(rename = "id")] @@ -1281,6 +1282,22 @@ macro_rules! server_request_definitions { } } + pub(crate) const EXPERIMENTAL_SERVER_METHODS: &[&str] = &[ + $( + experimental_method_entry!($(#[experimental($reason)])? $(=> $wire)?), + )* + ]; + pub(crate) const EXPERIMENTAL_SERVER_METHOD_PARAM_TYPES: &[&str] = &[ + $( + experimental_type_entry!($(#[experimental($reason)])? $params), + )* + ]; + pub(crate) const EXPERIMENTAL_SERVER_METHOD_RESPONSE_TYPES: &[&str] = &[ + $( + experimental_type_entry!($(#[experimental($reason)])? $response), + )* + ]; + pub fn export_server_responses( out_dir: &::std::path::Path, ) -> ::std::result::Result<(), ::ts_rs::ExportError> { @@ -1470,6 +1487,13 @@ server_request_definitions! { response: v2::AttestationGenerateResponse, }, + #[experimental("currentTime/read")] + /// Read the current time from an external clock owned by the client. + CurrentTimeRead => "currentTime/read" { + params: v2::CurrentTimeReadParams, + response: v2::CurrentTimeReadResponse, + }, + /// DEPRECATED APIs below /// Request to approve a patch. /// This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage). @@ -2372,6 +2396,32 @@ mod tests { Ok(()) } + #[test] + fn serialize_current_time_read_request() -> Result<()> { + let params = v2::CurrentTimeReadParams { + thread_id: "thread-123".to_string(), + }; + let request = ServerRequest::CurrentTimeRead { + request_id: RequestId::Integer(10), + params: params.clone(), + }; + assert_eq!( + json!({ + "method": "currentTime/read", + "id": 10, + "params": { + "threadId": "thread-123" + } + }), + serde_json::to_value(&request)?, + ); + + let payload = ServerRequestPayload::CurrentTimeRead(params); + assert_eq!(request.id(), &RequestId::Integer(10)); + assert_eq!(payload.request_with_id(RequestId::Integer(10)), request); + Ok(()) + } + #[test] fn serialize_server_response() -> Result<()> { let response = ServerResponse::CommandExecutionRequestApproval { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/current_time.rs b/codex-rs/app-server-protocol/src/protocol/v2/current_time.rs new file mode 100644 index 0000000000..9fd23f7981 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/current_time.rs @@ -0,0 +1,20 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CurrentTimeReadParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CurrentTimeReadResponse { + /// Current time as whole Unix seconds. + #[ts(type = "number")] + pub current_time_at: i64, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs index b5fa9fdc65..1097b03811 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs @@ -6,6 +6,7 @@ mod attestation; mod collaboration_mode; mod command_exec; mod config; +mod current_time; mod environment; mod experimental_feature; mod feedback; @@ -32,6 +33,7 @@ pub use attestation::*; pub use collaboration_mode::*; pub use command_exec::*; pub use config::*; +pub use current_time::*; pub use environment::*; pub use experimental_feature::*; pub use feedback::*; diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 183c7c6007..b9c7899fc8 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1469,6 +1469,10 @@ When the client responds to `item/tool/requestUserInput`, the server emits `serv Desktop hosts that provide upstream attestation should set `capabilities.requestAttestation` during `initialize` and handle the server-initiated `attestation/generate` request. App-server issues it just in time before ChatGPT Codex requests that forward `x-oai-attestation`; the client responds with `{ "token": "v1." }`, where `token` is an opaque client-owned value. When app-server receives a client response, it forwards a consistent outer envelope such as `{ "v": 1, "s": 0, "t": "v1." }`, where `t` contains the client token unchanged. If app-server attempts attestation but fails within its own boundary, it sends the same envelope shape with an app-server status code and without `t` (`1 = timeout`, `2 = request failed`, `3 = request canceled`, `4 = malformed response`). If no initialized client opted into attestation, app-server omits `x-oai-attestation` for that upstream request. +### Current time + +When `[features.current_time_reminder]` is enabled with `clock_source = "external"`, app-server sends the client subscribed to the thread an experimental `currentTime/read` request with `{ "threadId": "thr_123" }` when a time reminder is due. The client responds with `{ "currentTimeAt": 1781717655 }`, where `currentTimeAt` is an integer Unix timestamp in seconds. A failed, canceled, timed-out, or malformed response stops the turn before the model request is sent. + ### MCP server elicitations MCP servers can interrupt a turn and ask the client for structured input via `mcpServer/elicitation/request`. diff --git a/codex-rs/app-server/src/current_time.rs b/codex-rs/app-server/src/current_time.rs new file mode 100644 index 0000000000..9b76d337a2 --- /dev/null +++ b/codex-rs/app-server/src/current_time.rs @@ -0,0 +1,146 @@ +use std::sync::Arc; +use std::sync::Weak; + +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use anyhow::bail; +use chrono::DateTime; +use chrono::Utc; +use codex_app_server_protocol::CurrentTimeReadParams; +use codex_app_server_protocol::CurrentTimeReadResponse; +use codex_app_server_protocol::ServerRequestPayload; +use codex_core::TimeFuture; +use codex_core::TimeProvider; +use codex_protocol::ThreadId; +use tokio::time::Duration; +use tokio::time::Instant; +use tokio::time::timeout_at; + +use crate::outgoing_message::ConnectionId; +use crate::outgoing_message::OutgoingMessageSender; +use crate::thread_state::ThreadStateManager; + +const CURRENT_TIME_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); + +pub(crate) fn app_server_time_provider( + outgoing: Arc, + thread_state_manager: ThreadStateManager, +) -> Arc { + Arc::new(AppServerTimeProvider { + outgoing: Arc::downgrade(&outgoing), + thread_state_manager, + }) +} + +struct AppServerTimeProvider { + outgoing: Weak, + thread_state_manager: ThreadStateManager, +} + +impl TimeProvider for AppServerTimeProvider { + fn current_time(&self, thread_id: ThreadId) -> TimeFuture<'_> { + let outgoing = self.outgoing.clone(); + let thread_state_manager = self.thread_state_manager.clone(); + Box::pin(async move { + let outgoing = outgoing + .upgrade() + .context("app-server current-time provider is unavailable")?; + request_current_time(outgoing, thread_state_manager, thread_id).await + }) + } +} + +async fn request_current_time( + outgoing: Arc, + thread_state_manager: ThreadStateManager, + thread_id: ThreadId, +) -> Result> { + let deadline = Instant::now() + CURRENT_TIME_REQUEST_TIMEOUT; + timeout_at( + deadline, + thread_state_manager.wait_for_thread_subscriber(thread_id), + ) + .await + .map_err(|_| { + anyhow!( + "timed out waiting for a client to subscribe to the thread after {}s", + CURRENT_TIME_REQUEST_TIMEOUT.as_secs() + ) + })?; + let connection_ids = thread_state_manager + .subscribed_connection_ids(thread_id) + .await; + let connection_id = require_single_current_time_connection(&connection_ids)?; + let connection_ids = [connection_id]; + let (request_id, rx) = outgoing + .send_request_to_connections( + Some(&connection_ids), + ServerRequestPayload::CurrentTimeRead(CurrentTimeReadParams { + thread_id: thread_id.to_string(), + }), + /*thread_id*/ None, + ) + .await; + + let result = match timeout_at(deadline, rx).await { + Ok(Ok(Ok(result))) => result, + Ok(Ok(Err(err))) => { + bail!( + "current-time request failed: code={} message={}", + err.code, + err.message + ); + } + Ok(Err(err)) => bail!("current-time request was canceled: {err}"), + Err(_) => { + let _canceled = outgoing.cancel_request(&request_id).await; + bail!( + "current-time request timed out after {}s", + CURRENT_TIME_REQUEST_TIMEOUT.as_secs() + ); + } + }; + let response: CurrentTimeReadResponse = + serde_json::from_value(result).context("invalid current-time response")?; + + DateTime::from_timestamp(response.current_time_at, 0) + .ok_or_else(|| anyhow!("current-time response is outside the supported range")) +} + +fn require_single_current_time_connection(connection_ids: &[ConnectionId]) -> Result { + // External clocks are not interchangeable, so do not choose one silently. + match connection_ids { + [connection_id] => Ok(*connection_id), + _ => bail!( + "expected exactly one client subscribed to the thread, found {}", + connection_ids.len() + ), + } +} + +#[cfg(test)] +mod tests { + use super::require_single_current_time_connection; + use crate::outgoing_message::ConnectionId; + + #[test] + fn current_time_connection_must_be_unambiguous() { + assert_eq!( + require_single_current_time_connection(&[ConnectionId(7)]).unwrap(), + ConnectionId(7) + ); + assert_eq!( + require_single_current_time_connection(&[]) + .unwrap_err() + .to_string(), + "expected exactly one client subscribed to the thread, found 0" + ); + assert_eq!( + require_single_current_time_connection(&[ConnectionId(7), ConnectionId(8)]) + .unwrap_err() + .to_string(), + "expected exactly one client subscribed to the thread, found 2" + ); + } +} diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index cac2db54eb..f4cd83d732 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -91,6 +91,7 @@ mod config_manager; mod config_manager_service; mod connection_cleanup; mod connection_rpc_gate; +mod current_time; mod dynamic_tools; mod error_code; mod extensions; diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 0f9f6d3321..ad29e05010 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -7,6 +7,7 @@ use std::sync::atomic::AtomicBool; use crate::attestation::app_server_attestation_provider; use crate::config_manager::ConfigManager; use crate::connection_rpc_gate::ConnectionRpcGate; +use crate::current_time::app_server_time_provider; use crate::error_code::invalid_request; use crate::extensions::ThreadExtensionDependencies; use crate::extensions::app_server_extension_event_sink; @@ -280,7 +281,6 @@ impl ConnectionSessionState { .get() .is_some_and(|session| session.supports_openai_form_elicitation) } - pub(crate) fn initialize(&self, session: InitializedConnectionSessionState) -> Result<(), ()> { self.initialized.set(session).map_err(|_| ()) } @@ -379,7 +379,10 @@ impl MessageProcessor { outgoing.clone(), thread_state_manager.clone(), )), - /*external_time_provider*/ None, + Some(app_server_time_provider( + outgoing.clone(), + thread_state_manager.clone(), + )), ) }); let models_manager = thread_manager.get_models_manager(); diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 1f333f1eb1..3e336cc2a5 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -1381,6 +1381,31 @@ mod thread_processor_behavior_tests { Ok(()) } + #[tokio::test] + async fn wait_for_thread_subscriber_unblocks_after_connection_attaches() -> Result<()> { + let manager = ThreadStateManager::new(); + let thread_id = ThreadId::from_string("ba62fd70-2ec2-4b1b-9d94-355694332dd2")?; + let connection = ConnectionId(1); + manager + .connection_initialized(connection, ConnectionCapabilities::default()) + .await; + + let wait_for_subscriber = manager.wait_for_thread_subscriber(thread_id); + let attach_connection = async { + tokio::task::yield_now().await; + manager + .try_add_connection_to_thread(thread_id, connection) + .await + }; + let ((), attached) = tokio::time::timeout(Duration::from_secs(1), async { + tokio::join!(wait_for_subscriber, attach_connection) + }) + .await?; + + assert!(attached); + Ok(()) + } + #[tokio::test] async fn closed_connection_cannot_be_reintroduced_by_auto_subscribe() -> Result<()> { let manager = ThreadStateManager::new(); diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 6d2b48a4c8..890d4da5eb 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -329,6 +329,23 @@ impl ThreadStateManager { .min_by_key(|connection_id| connection_id.0) } + pub(crate) async fn wait_for_thread_subscriber(&self, thread_id: ThreadId) { + let mut has_connections = { + let mut state = self.state.lock().await; + state + .threads + .entry(thread_id) + .or_default() + .has_connections_watcher + .subscribe() + }; + while !*has_connections.borrow_and_update() { + if has_connections.changed().await.is_err() { + break; + } + } + } + pub(crate) async fn subscribed_connection_ids(&self, thread_id: ThreadId) -> Vec { let state = self.state.lock().await; state diff --git a/codex-rs/app-server/tests/suite/v2/current_time.rs b/codex-rs/app-server/tests/suite/v2/current_time.rs new file mode 100644 index 0000000000..74e734ae39 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/current_time.rs @@ -0,0 +1,130 @@ +use std::path::Path; + +use anyhow::Result; +use app_test_support::TestAppServer; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::to_response; +use codex_app_server_protocol::CurrentTimeReadResponse; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput; +use core_test_support::responses; +use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::Duration; +use tokio::time::timeout; + +#[cfg(windows)] +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(25); +#[cfg(not(windows))] +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); +const CURRENT_TIME_AT: i64 = 1_781_717_655; +const CURRENT_TIME_REMINDER: &str = "It is 2026-06-17 17:34:15 UTC."; + +#[tokio::test] +async fn current_time_read_round_trip_adds_reminder_to_model_input() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_once( + &server, + create_final_assistant_message_sse_response("Done")?, + ) + .await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut app_server = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; + + let thread_request_id = app_server + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let thread_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(thread_request_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_response)?; + + let turn_request_id = app_server + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "What time is it?".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(turn_request_id)), + ) + .await??; + let _: TurnStartResponse = to_response(turn_response)?; + + let server_request = timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::CurrentTimeRead { request_id, params } = server_request else { + panic!("expected CurrentTimeRead request, got: {server_request:?}"); + }; + assert_eq!(params.thread_id, thread.id); + app_server + .send_response( + request_id, + serde_json::to_value(CurrentTimeReadResponse { + current_time_at: CURRENT_TIME_AT, + })?, + ) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + assert!( + response_mock + .single_request() + .message_input_texts("developer") + .iter() + .any(|text| text == CURRENT_TIME_REMINDER) + ); + Ok(()) +} + +fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +model_provider = "mock_provider" + +[features.current_time_reminder] +enabled = true +reminder_interval_model_requests = 1 +clock_source = "external" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 9d07da0660..2745e8ee00 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -11,6 +11,7 @@ mod config_rpc; mod connection_handling_websocket; #[cfg(unix)] mod connection_handling_websocket_unix; +mod current_time; mod dynamic_tools; #[cfg(not(target_os = "windows"))] mod executor_mcp; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index a1f4b95b50..9745fa878e 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1719,6 +1719,15 @@ async fn handle_server_request( ) .await } + ServerRequest::CurrentTimeRead { request_id, .. } => { + reject_server_request( + client, + request_id, + &method, + "external current time is not supported in exec mode".to_string(), + ) + .await + } ServerRequest::ApplyPatchApproval { request_id, params } => { reject_server_request( client, diff --git a/codex-rs/tui/src/app/app_server_event_targets.rs b/codex-rs/tui/src/app/app_server_event_targets.rs index 90f688f7ed..e29d0e9d5d 100644 --- a/codex-rs/tui/src/app/app_server_event_targets.rs +++ b/codex-rs/tui/src/app/app_server_event_targets.rs @@ -24,6 +24,9 @@ pub(super) fn server_request_thread_id(request: &ServerRequest) -> Option { ThreadId::from_string(¶ms.thread_id).ok() } + ServerRequest::CurrentTimeRead { params, .. } => { + ThreadId::from_string(¶ms.thread_id).ok() + } ServerRequest::ChatgptAuthTokensRefresh { .. } | ServerRequest::AttestationGenerate { .. } | ServerRequest::ApplyPatchApproval { .. } diff --git a/codex-rs/tui/src/app/app_server_requests.rs b/codex-rs/tui/src/app/app_server_requests.rs index d61e84dcec..0b72897685 100644 --- a/codex-rs/tui/src/app/app_server_requests.rs +++ b/codex-rs/tui/src/app/app_server_requests.rs @@ -153,6 +153,12 @@ impl PendingAppServerRequests { message: "Attestation generation is not available in TUI.".to_string(), }) } + ServerRequest::CurrentTimeRead { request_id, .. } => { + Some(UnsupportedAppServerRequest { + request_id: request_id.clone(), + message: "External current time is not available in TUI.".to_string(), + }) + } ServerRequest::ApplyPatchApproval { request_id, .. } => { Some(UnsupportedAppServerRequest { request_id: request_id.clone(), @@ -352,6 +358,7 @@ impl PendingAppServerRequests { ServerRequest::DynamicToolCall { .. } | ServerRequest::ChatgptAuthTokensRefresh { .. } | ServerRequest::AttestationGenerate { .. } + | ServerRequest::CurrentTimeRead { .. } | ServerRequest::ApplyPatchApproval { .. } | ServerRequest::ExecCommandApproval { .. } => true, } diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index 167fc5c7cb..0b69153fa5 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -97,6 +97,7 @@ impl SideParentStatus { | ServerRequest::ExecCommandApproval { .. } => Some(SideParentStatus::NeedsApproval), ServerRequest::DynamicToolCall { .. } | ServerRequest::AttestationGenerate { .. } + | ServerRequest::CurrentTimeRead { .. } | ServerRequest::ChatgptAuthTokensRefresh { .. } => None, } } diff --git a/codex-rs/tui/src/chatwidget/protocol_requests.rs b/codex-rs/tui/src/chatwidget/protocol_requests.rs index 2f33c9809a..5729c073fd 100644 --- a/codex-rs/tui/src/chatwidget/protocol_requests.rs +++ b/codex-rs/tui/src/chatwidget/protocol_requests.rs @@ -46,6 +46,7 @@ impl ChatWidget { } ServerRequest::DynamicToolCall { .. } | ServerRequest::AttestationGenerate { .. } + | ServerRequest::CurrentTimeRead { .. } | ServerRequest::ChatgptAuthTokensRefresh { .. } | ServerRequest::ApplyPatchApproval { .. } | ServerRequest::ExecCommandApproval { .. } => { From d9dace8a593080572fdef4a8fbdad7f2c7fe226f Mon Sep 17 00:00:00 2001 From: maja-openai <163171781+maja-openai@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:16:14 -0700 Subject: [PATCH 045/112] Make auto-review on-request prompt more proactive (#26496) ## Why `on-request` approval policy text is currently tuned for user-reviewed approvals. For auto-reviewed productivity runs, likely sandbox blocks should be escalated earlier so commands that need remote services, authentication, or other out-of-sandbox access do not first fail or hang inside the sandbox. ## What changed - Adds a separate `on_request_auto_review.md` permissions prompt selected for `AskForApproval::OnRequest` with `ApprovalsReviewer::AutoReview`. - Keeps the normal user-reviewed `on-request` wording unchanged. - Makes the `When to request escalation` bullets more explicit about likely sandbox blocks, network access, remote auth/cluster/cloud/database access, out-of-sandbox environment access, git operations that may write lock files, and short-timeout reruns after likely sandbox-blocked attempts. - Omits approved command prefix and `prefix_rule` guidance for the auto-review on-request prompt. - Adds prompt tests covering the auto-review path, normal on-request wording, and inline permission request behavior. --- .../prompts/src/permissions_instructions.rs | 4 + .../src/permissions_instructions_tests.rs | 85 ++++++++++++++++++- .../approval_policy/on_request_auto_review.md | 45 ++++++++++ 3 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 codex-rs/prompts/templates/permissions/approval_policy/on_request_auto_review.md diff --git a/codex-rs/prompts/src/permissions_instructions.rs b/codex-rs/prompts/src/permissions_instructions.rs index f360a987db..a4a043f24f 100644 --- a/codex-rs/prompts/src/permissions_instructions.rs +++ b/codex-rs/prompts/src/permissions_instructions.rs @@ -22,6 +22,8 @@ const APPROVAL_POLICY_ON_FAILURE: &str = include_str!("../templates/permissions/approval_policy/on_failure.md"); const APPROVAL_POLICY_ON_REQUEST_RULE: &str = include_str!("../templates/permissions/approval_policy/on_request.md"); +const APPROVAL_POLICY_ON_REQUEST_AUTO_REVIEW: &str = + include_str!("../templates/permissions/approval_policy/on_request_auto_review.md"); const APPROVAL_POLICY_ON_REQUEST_RULE_REQUEST_PERMISSION: &str = include_str!("../templates/permissions/approval_policy/on_request_rule_request_permission.md"); const AUTO_REVIEW_APPROVAL_SUFFIX: &str = "`approvals_reviewer` is `auto_review`: Sandbox escalations with require_escalated will be reviewed for compliance with the policy. If a rejection happens, you should proceed only with a materially safer alternative, or inform the user of the risk and send a final message to ask for approval."; @@ -208,6 +210,8 @@ fn approval_text( let on_request_instructions = || { let on_request_rule = if exec_permission_approvals_enabled { APPROVAL_POLICY_ON_REQUEST_RULE_REQUEST_PERMISSION.to_string() + } else if approvals_reviewer == ApprovalsReviewer::AutoReview { + APPROVAL_POLICY_ON_REQUEST_AUTO_REVIEW.to_string() } else { APPROVAL_POLICY_ON_REQUEST_RULE.to_string() }; diff --git a/codex-rs/prompts/src/permissions_instructions_tests.rs b/codex-rs/prompts/src/permissions_instructions_tests.rs index 580e9781ce..8564a7cf1e 100644 --- a/codex-rs/prompts/src/permissions_instructions_tests.rs +++ b/codex-rs/prompts/src/permissions_instructions_tests.rs @@ -257,17 +257,98 @@ fn on_request_includes_tool_guidance_alongside_inline_permission_guidance_when_b #[test] fn auto_review_approvals_append_auto_review_specific_guidance() { + let mut exec_policy = Policy::empty(); + exec_policy + .add_prefix_rule(&["git".to_string(), "pull".to_string()], Decision::Allow) + .expect("add rule"); let text = approval_text( AskForApproval::OnRequest, ApprovalsReviewer::AutoReview, - &Policy::empty(), + &exec_policy, /*exec_permission_approvals_enabled*/ false, /*request_permissions_tool_enabled*/ false, ); assert!(text.contains("`approvals_reviewer` is `auto_review`")); - assert!(!text.contains("`approvals_reviewer` is `guardian_subagent`")); assert!(text.contains("materially safer alternative")); + assert!(!text.contains("`approvals_reviewer` is `guardian_subagent`")); + assert!(text.contains( + "When the sandbox is likely to block a command needed for the task, request escalation up front" + )); + assert!(text.contains( + "When unsure, prefer requesting escalation unnecessarily over failing to request it when needed" + )); + assert!( + text.contains( + "Request escalation for commands that need write access outside writable roots" + ) + ); + assert!(text.contains( + "Request escalation for git operations that may write lock files, such as updating the index or refs" + )); + assert!(!text.contains("Request escalation for GUI commands")); + assert!(!text.contains("Use escalation when it is the direct or most reliable way")); + assert!(!text.contains("Do not spend extra turns running likely-to-fail sandbox probes first")); + assert!(text.contains("Do not include a `prefix_rule` parameter.")); + assert!(text.contains( + "Request escalation for commands that may need network access, including HTTP calls, package registries, internal services, data-service APIs, remote queries, data fetches, or live probes" + )); + assert!(text.contains( + "Request escalation for commands that may need remote authentication, cluster, cloud, or database access" + )); + assert!(text.contains( + "Request escalation for commands that may need process, cache, or other environment access outside the sandbox" + )); + assert!( + text.contains( + "including DNS, connection, authentication, retry, or service endpoint errors" + ) + ); + assert!(text.contains( + "Request escalation before potentially destructive actions, such as `rm` or `git reset`" + )); + assert!(text.contains( + "If a command may be hanging on sandbox-blocked access, stop after a short timeout and rerun with `require_escalated`" + )); + assert!(!text.contains("Be judicious with escalating")); + assert!(text.contains("Approved command prefixes")); + assert!(text.contains(r#"["git", "pull"]"#)); + assert!(!text.contains("prefix_rule guidance")); +} + +#[test] +fn normal_on_request_keeps_user_approval_wording() { + let text = approval_text( + AskForApproval::OnRequest, + ApprovalsReviewer::User, + &Policy::empty(), + /*exec_permission_approvals_enabled*/ false, + /*request_permissions_tool_enabled*/ false, + ); + + assert!(text.contains("approved by the user")); + assert!(text.contains("Be judicious with escalating")); + assert!(!text.contains( + "When the sandbox is likely to block a command needed for the task, request escalation up front" + )); +} + +#[test] +fn auto_review_on_request_with_inline_permission_requests_keeps_suffix() { + let text = approval_text( + AskForApproval::OnRequest, + ApprovalsReviewer::AutoReview, + &Policy::empty(), + /*exec_permission_approvals_enabled*/ true, + /*request_permissions_tool_enabled*/ false, + ); + + assert!(text.contains("with_additional_permissions")); + assert!(text.contains("`approvals_reviewer` is `auto_review`")); + assert!(text.contains("materially safer alternative")); + assert!(!text.contains( + "When the sandbox is likely to block a command needed for the task, request escalation up front" + )); } #[test] diff --git a/codex-rs/prompts/templates/permissions/approval_policy/on_request_auto_review.md b/codex-rs/prompts/templates/permissions/approval_policy/on_request_auto_review.md new file mode 100644 index 0000000000..82bb24e963 --- /dev/null +++ b/codex-rs/prompts/templates/permissions/approval_policy/on_request_auto_review.md @@ -0,0 +1,45 @@ +# Escalation Requests + +Commands are run outside the sandbox after approval. The command string is split into independent command segments at shell control operators, including but not limited to: + +- Pipes: | +- Logical operators: &&, || +- Command separators: ; +- Subshell boundaries: (...), $() + +Each resulting segment is evaluated independently for sandbox restrictions and approval requirements. + +Example: + +git pull | tee output.txt + +This is treated as two command segments: + +["git", "pull"] + +["tee", "output.txt"] + +Commands that use more advanced shell features like redirection (>, >>, <), substitutions ($(...), ...), environment variables (FOO=bar), or wildcard patterns (*, ?) require care because each independent command segment is evaluated separately. + +## How to request escalation + +IMPORTANT: To request approval to execute a command that will require escalated privileges: + +- Provide the `sandbox_permissions` parameter with the value `"require_escalated"` +- Include a concise `justification` parameter that explains why escalated privileges are needed. +- Do not include a `prefix_rule` parameter. + +## When to request escalation + +While commands are running inside the sandbox, here are some scenarios that justify escalation: + +- When the sandbox is likely to block a command needed for the task, request escalation up front. +- Request escalation for commands that need write access outside writable roots, such as tests that write to `/var`. +- Request escalation for git operations that may write lock files, such as updating the index or refs. +- Request escalation for commands that may need network access, including HTTP calls, package registries, internal services, data-service APIs, remote queries, data fetches, or live probes. +- Request escalation for commands that may need remote authentication, cluster, cloud, or database access. +- Request escalation for commands that may need process, cache, or other environment access outside the sandbox. +- If a sandboxed attempt fails with sandboxing or likely network symptoms, including DNS, connection, authentication, retry, or service endpoint errors, rerun with `sandbox_permissions` set to `"require_escalated"` and include `justification`. +- If a command may be hanging on sandbox-blocked access, stop after a short timeout and rerun with `require_escalated`. +- Request escalation before potentially destructive actions, such as `rm` or `git reset`, that the user did not explicitly ask for. +- When unsure, prefer requesting escalation unnecessarily over failing to request it when needed. From 29eb434bc5fd81f29540446f6989219736b09a80 Mon Sep 17 00:00:00 2001 From: Eric Ning Date: Thu, 18 Jun 2026 13:29:01 -0700 Subject: [PATCH 046/112] [codex] Remove hardcoded app ID filters (#28947) ## Summary - remove the duplicated originator-specific connector ID denylists - stop filtering connector directory/accessibility results and live/cached Codex Apps MCP tools by hardcoded connector ID - remove the now-unused `codex-login` dependency from `codex-utils-plugins` - update regression coverage so formerly blocked connector IDs are preserved ## Why The client-side policy was duplicated across crates, used opaque IDs without ownership or expiry information, and could drift between app listing and MCP tool behavior. Server-provided visibility, authorization, plugin discoverability, accessibility, enabled-state handling, and consequential-tool approval templates remain unchanged. ## Validation - `just fmt` - `just bazel-lock-update` - `just bazel-lock-check` - `git diff --check` - confirmed the final diff contains no hardcoded denylist symbols A targeted `codex-mcp` test build spent an unusually long time in local compilation/linking. Its first attempt exposed a test-only `PartialEq` assertion issue, which was corrected. A follow-up non-linking `cargo check -p codex-mcp --tests` was still running when this draft was opened; CI should provide the complete Rust validation. --- codex-rs/Cargo.lock | 1 - .../app-server/tests/suite/v2/app_list.rs | 11 +- .../tests/suite/v2/plugin_install.rs | 32 +++++- codex-rs/chatgpt/src/connectors.rs | 45 ++++---- codex-rs/codex-mcp/src/codex_apps.rs | 19 +--- .../codex-mcp/src/connection_manager_tests.rs | 24 +++-- codex-rs/codex-mcp/src/rmcp_client.rs | 4 - codex-rs/connectors/src/filter.rs | 102 +----------------- codex-rs/core/src/connectors.rs | 23 +--- codex-rs/utils/plugins/Cargo.toml | 1 - codex-rs/utils/plugins/src/mcp_connector.rs | 28 ----- 11 files changed, 81 insertions(+), 209 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 1cc32ea681..0fae5a41bf 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4271,7 +4271,6 @@ name = "codex-utils-plugins" version = "0.0.0" dependencies = [ "codex-exec-server", - "codex-login", "codex-utils-absolute-path", "codex-utils-path-uri", "serde", diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index b711867b0a..daface3447 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -365,10 +365,11 @@ connectors = false #[tokio::test] async fn list_apps_keeps_apps_with_app_only_tools_accessible() -> Result<()> { + let connector_id = "connector_2b0a9009c9c64bf9933a3dae3f2b1254"; let connectors = vec![AppInfo { - id: "beta".to_string(), - name: "Beta".to_string(), - description: Some("Beta connector".to_string()), + id: connector_id.to_string(), + name: "Formerly Blocked".to_string(), + description: Some("Formerly blocked connector".to_string()), logo_url: None, logo_url_dark: None, distribution_channel: None, @@ -380,7 +381,7 @@ async fn list_apps_keeps_apps_with_app_only_tools_accessible() -> Result<()> { is_enabled: true, plugin_display_names: Vec::new(), }]; - let mut app_only_tool = connector_tool("beta", "Beta App")?; + let mut app_only_tool = connector_tool(connector_id, "Formerly Blocked")?; app_only_tool .meta .as_mut() @@ -421,7 +422,7 @@ async fn list_apps_keeps_apps_with_app_only_tools_accessible() -> Result<()> { let AppsListResponse { data, next_cursor } = to_response(response)?; assert_eq!(data.len(), 1); - assert_eq!(data[0].id, "beta"); + assert_eq!(data[0].id, connector_id); assert!(data[0].is_accessible); assert!(next_cursor.is_none()); diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index cc008e73a9..4026bef357 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -1245,7 +1245,7 @@ async fn plugin_install_skips_mcp_oauth_for_chatgpt_dual_surface_plugin() -> Res } #[tokio::test] -async fn plugin_install_starts_mcp_oauth_when_only_plugin_apps_are_disallowed() -> Result<()> { +async fn plugin_install_starts_mcp_oauth_with_formerly_disallowed_plugin_app() -> Result<()> { let (apps_server_url, apps_server_handle, _apps_server_control) = start_apps_server(Vec::new(), Vec::new()).await?; let oauth_server = MockServer::start().await; @@ -1296,8 +1296,22 @@ async fn plugin_install_starts_mcp_oauth_when_only_plugin_apps_are_disallowed() .await??; let response: PluginInstallResponse = to_response(response)?; - assert_eq!(response.auth_policy, PluginAuthPolicy::OnInstall); - assert_eq!(response.apps_needing_auth, Vec::::new()); + assert_eq!( + response, + PluginInstallResponse { + auth_policy: PluginAuthPolicy::OnInstall, + apps_needing_auth: vec![AppSummary { + id: "asdk_app_6938a94a61d881918ef32cb999ff937c".to_string(), + name: "asdk_app_6938a94a61d881918ef32cb999ff937c".to_string(), + description: None, + install_url: Some( + "https://chatgpt.com/apps/asdk-app-6938a94a61d881918ef32cb999ff937c/asdk_app_6938a94a61d881918ef32cb999ff937c" + .to_string(), + ), + category: None, + }], + } + ); assert!(oauth_discovery_request_count(&oauth_server).await > 0); apps_server_handle.abort(); @@ -1461,7 +1475,7 @@ async fn plugin_install_skips_remote_mcp_oauth_for_bundled_same_name_app() -> Re } #[tokio::test] -async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> { +async fn plugin_install_includes_formerly_disallowed_apps_needing_auth() -> Result<()> { let connectors = vec![AppInfo { id: "alpha".to_string(), name: "Alpha".to_string(), @@ -1538,6 +1552,16 @@ async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> { description: Some("Alpha connector".to_string()), install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), category: None, + }, + AppSummary { + id: "asdk_app_6938a94a61d881918ef32cb999ff937c".to_string(), + name: "asdk_app_6938a94a61d881918ef32cb999ff937c".to_string(), + description: None, + install_url: Some( + "https://chatgpt.com/apps/asdk-app-6938a94a61d881918ef32cb999ff937c/asdk_app_6938a94a61d881918ef32cb999ff937c" + .to_string(), + ), + category: None, }], } ); diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index 5a024b3ff9..fa1a894cca 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -8,7 +8,6 @@ use codex_app_server_protocol::AppInfo; use codex_connectors::ConnectorDirectoryCacheContext; use codex_connectors::ConnectorDirectoryCacheKey; use codex_connectors::DirectoryListResponse; -use codex_connectors::filter::filter_disallowed_connectors; use codex_connectors::merge::merge_connectors; use codex_connectors::merge::merge_plugin_connectors; use codex_core::config::Config; @@ -21,7 +20,6 @@ pub use codex_core::connectors::list_cached_accessible_connectors_from_mcp_tools pub use codex_core::connectors::with_app_enabled_state; use codex_login::AuthManager; use codex_login::CodexAuth; -use codex_login::default_client::originator; use codex_plugin::AppConnectorId; const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60); @@ -82,7 +80,10 @@ pub async fn list_cached_all_connectors( let auth = connector_auth(config).await.ok()?; let cache_context = connector_directory_cache_context(config, &auth); let connectors = codex_connectors::cached_directory_connectors(&cache_context)?; - Some(merge_and_filter_plugin_connectors(connectors, plugin_apps)) + Some(merge_directory_and_plugin_connectors( + connectors, + plugin_apps, + )) } pub async fn list_all_connectors_with_options( @@ -109,7 +110,10 @@ pub async fn list_all_connectors_with_options( }, ) .await?; - Ok(merge_and_filter_plugin_connectors(connectors, plugin_apps)) + Ok(merge_directory_and_plugin_connectors( + connectors, + plugin_apps, + )) } fn connector_directory_cache_context( @@ -127,17 +131,16 @@ fn connector_directory_cache_context( ) } -fn merge_and_filter_plugin_connectors( +fn merge_directory_and_plugin_connectors( connectors: Vec, plugin_apps: &[AppConnectorId], ) -> Vec { - let connectors = merge_plugin_connectors( + merge_plugin_connectors( connectors, plugin_apps .iter() .map(|connector_id| connector_id.0.clone()), - ); - filter_disallowed_connectors(connectors, originator().value.as_str()) + ) } pub fn connectors_for_plugin_apps( @@ -150,11 +153,10 @@ pub fn connectors_for_plugin_apps( .iter() .map(|connector_id| connector_id.0.clone()), ); - let mut connectors_by_id = - filter_disallowed_connectors(connectors, originator().value.as_str()) - .into_iter() - .map(|connector| (connector.id.clone(), connector)) - .collect::>(); + let mut connectors_by_id = connectors + .into_iter() + .map(|connector| (connector.id.clone(), connector)) + .collect::>(); plugin_apps .iter() @@ -179,8 +181,7 @@ pub fn merge_connectors_with_accessible( } else { accessible_connectors }; - let merged = merge_connectors(connectors, accessible_connectors); - filter_disallowed_connectors(merged, originator().value.as_str()) + merge_connectors(connectors, accessible_connectors) } #[cfg(test)] @@ -269,13 +270,13 @@ mod tests { } #[test] - fn connectors_for_plugin_apps_filters_disallowed_plugin_apps() { - let connectors = connectors_for_plugin_apps( - Vec::new(), - &[AppConnectorId( - "asdk_app_6938a94a61d881918ef32cb999ff937c".to_string(), - )], + fn connectors_for_plugin_apps_preserves_formerly_disallowed_plugin_apps() { + let connector_id = "asdk_app_6938a94a61d881918ef32cb999ff937c"; + let connectors = + connectors_for_plugin_apps(Vec::new(), &[AppConnectorId(connector_id.to_string())]); + assert_eq!( + connectors, + vec![merged_app(connector_id, /*is_accessible*/ false)] ); - assert_eq!(connectors, Vec::::new()); } } diff --git a/codex-rs/codex-mcp/src/codex_apps.rs b/codex-rs/codex-mcp/src/codex_apps.rs index 3196a143e8..b22a45f0d1 100644 --- a/codex-rs/codex-mcp/src/codex_apps.rs +++ b/codex-rs/codex-mcp/src/codex_apps.rs @@ -15,7 +15,6 @@ use crate::tools::ToolInfo; use anyhow::Context; use codex_login::CodexAuth; use codex_protocol::mcp::McpServerInfo; -use codex_utils_plugins::mcp_connector::is_connector_id_allowed; use codex_utils_plugins::mcp_connector::sanitize_name; use serde::Deserialize; use serde::Serialize; @@ -220,7 +219,7 @@ pub(crate) fn load_cached_codex_apps_tools( if cache.schema_version != CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION { return CachedCodexAppsToolsLoad::Invalid; } - CachedCodexAppsToolsLoad::Hit(filter_disallowed_codex_apps_tools(cache.tools)) + CachedCodexAppsToolsLoad::Hit(cache.tools) } pub(crate) fn write_cached_codex_apps_tools( @@ -233,10 +232,9 @@ pub(crate) fn write_cached_codex_apps_tools( { return; } - let tools = filter_disallowed_codex_apps_tools(tools.to_vec()); let Ok(bytes) = serde_json::to_vec_pretty(&CodexAppsToolsDiskCache { schema_version: CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION, - tools, + tools: tools.to_vec(), }) else { return; }; @@ -279,17 +277,6 @@ fn write_cached_codex_apps_server_info( Ok(()) } -pub(crate) fn filter_disallowed_codex_apps_tools(tools: Vec) -> Vec { - tools - .into_iter() - .filter(|tool| { - tool.connector_id - .as_deref() - .is_none_or(is_connector_id_allowed) - }) - .collect() -} - #[derive(Debug, Clone, Serialize, Deserialize)] struct CodexAppsToolsDiskCache { schema_version: u8, @@ -303,7 +290,7 @@ struct CodexAppsServerInfoDiskCache { } const CODEX_APPS_TOOLS_CACHE_DIR: &str = "cache/codex_apps_tools"; -pub(crate) const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 3; +pub(crate) const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 4; const CODEX_APPS_SERVER_INFO_CACHE_DIR: &str = "cache/codex_apps_server_info"; const CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION: u8 = 1; diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index c8d8f79081..52194b07f1 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -612,7 +612,7 @@ fn codex_apps_tools_cache_is_scoped_per_user() { } #[test] -fn codex_apps_tools_cache_filters_disallowed_connectors() { +fn codex_apps_tools_cache_preserves_formerly_disallowed_connectors() { let codex_home = tempdir().expect("tempdir"); let cache_context = create_codex_apps_tools_cache_context( codex_home.path().to_path_buf(), @@ -622,13 +622,13 @@ fn codex_apps_tools_cache_filters_disallowed_connectors() { let tools = vec![ create_test_tool_with_connector( CODEX_APPS_MCP_SERVER_NAME, - "blocked_tool", + "formerly_blocked_tool", "connector_2b0a9009c9c64bf9933a3dae3f2b1254", - Some("Blocked"), + Some("Formerly Blocked"), ), create_test_tool_with_connector( CODEX_APPS_MCP_SERVER_NAME, - "allowed_tool", + "calendar_tool", "calendar", Some("Calendar"), ), @@ -637,9 +637,19 @@ fn codex_apps_tools_cache_filters_disallowed_connectors() { write_cached_codex_apps_tools(&cache_context, &tools); let cached = read_cached_codex_apps_tools(&cache_context).expect("cache entry exists for user"); - assert_eq!(cached.len(), 1); - assert_eq!(cached[0].callable_name, "allowed_tool"); - assert_eq!(cached[0].connector_id.as_deref(), Some("calendar")); + assert_eq!( + cached + .iter() + .map(|tool| (tool.callable_name.as_str(), tool.connector_id.as_deref())) + .collect::>(), + vec![ + ( + "formerly_blocked_tool", + Some("connector_2b0a9009c9c64bf9933a3dae3f2b1254") + ), + ("calendar_tool", Some("calendar")), + ] + ); } #[test] diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index aa56650e8a..d6c2590ba4 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -19,7 +19,6 @@ use std::time::Instant; use crate::codex_apps::CachedCodexAppsToolsLoad; use crate::codex_apps::CodexAppsToolsCacheContext; -use crate::codex_apps::filter_disallowed_codex_apps_tools; use crate::codex_apps::load_cached_codex_apps_tools; use crate::codex_apps::load_startup_cached_codex_apps_server_info; use crate::codex_apps::load_startup_cached_codex_apps_tools_snapshot; @@ -406,9 +405,6 @@ pub(crate) async fn list_tools_for_client_uncached( } }) .collect(); - if server_name == CODEX_APPS_MCP_SERVER_NAME { - return Ok(filter_disallowed_codex_apps_tools(tools)); - } Ok(tools) } diff --git a/codex-rs/connectors/src/filter.rs b/codex-rs/connectors/src/filter.rs index e26291794f..6d60c89ad6 100644 --- a/codex-rs/connectors/src/filter.rs +++ b/codex-rs/connectors/src/filter.rs @@ -6,7 +6,6 @@ pub fn filter_tool_suggest_discoverable_connectors( directory_connectors: Vec, accessible_connectors: &[AppInfo], discoverable_connector_ids: &HashSet, - originator_value: &str, ) -> Vec { let accessible_connector_ids: HashSet<&str> = accessible_connectors .iter() @@ -14,7 +13,7 @@ pub fn filter_tool_suggest_discoverable_connectors( .map(|connector| connector.id.as_str()) .collect(); - let mut connectors = filter_disallowed_connectors(directory_connectors, originator_value) + let mut connectors = directory_connectors .into_iter() .filter(|connector| !accessible_connector_ids.contains(connector.id.as_str())) .filter(|connector| discoverable_connector_ids.contains(connector.id.as_str())) @@ -27,44 +26,6 @@ pub fn filter_tool_suggest_discoverable_connectors( connectors } -const DISALLOWED_CONNECTOR_IDS: &[&str] = &[ - "asdk_app_6938a94a61d881918ef32cb999ff937c", - "connector_2b0a9009c9c64bf9933a3dae3f2b1254", - "connector_3f8d1a79f27c4c7ba1a897ab13bf37dc", - "connector_68de829bf7648191acd70a907364c67c", - "connector_68e004f14af881919eb50893d3d9f523", - "connector_69272cb413a081919685ec3c88d1744e", -]; -const FIRST_PARTY_CHAT_DISALLOWED_CONNECTOR_IDS: &[&str] = - &["connector_0f9c9d4592e54d0a9a12b3f44a1e2010"]; - -pub fn filter_disallowed_connectors( - connectors: Vec, - originator_value: &str, -) -> Vec { - let first_party_chat_originator = is_first_party_chat_originator(originator_value); - connectors - .into_iter() - .filter(|connector| { - is_connector_id_allowed(connector.id.as_str(), first_party_chat_originator) - }) - .collect() -} - -fn is_first_party_chat_originator(originator_value: &str) -> bool { - originator_value == "codex_atlas" || originator_value == "codex_chatgpt_desktop" -} - -fn is_connector_id_allowed(connector_id: &str, first_party_chat_originator: bool) -> bool { - let disallowed_connector_ids = if first_party_chat_originator { - FIRST_PARTY_CHAT_DISALLOWED_CONNECTOR_IDS - } else { - DISALLOWED_CONNECTOR_IDS - }; - - !disallowed_connector_ids.contains(&connector_id) -} - #[cfg(test)] mod tests { use super::*; @@ -98,65 +59,6 @@ mod tests { } } - #[test] - fn filter_disallowed_connectors_allows_non_disallowed_connectors() { - let filtered = - filter_disallowed_connectors(vec![app("asdk_app_hidden"), app("alpha")], "codex_cli"); - assert_eq!(filtered, vec![app("asdk_app_hidden"), app("alpha")]); - } - - #[test] - fn filter_disallowed_connectors_allows_openai_prefix() { - let filtered = filter_disallowed_connectors( - vec![ - app("connector_openai_foo"), - app("connector_openai_bar"), - app("gamma"), - ], - "codex_cli", - ); - assert_eq!( - filtered, - vec![ - app("connector_openai_foo"), - app("connector_openai_bar"), - app("gamma") - ] - ); - } - - #[test] - fn filter_disallowed_connectors_filters_disallowed_connector_ids() { - let filtered = filter_disallowed_connectors( - vec![ - app("asdk_app_6938a94a61d881918ef32cb999ff937c"), - app("connector_3f8d1a79f27c4c7ba1a897ab13bf37dc"), - app("delta"), - ], - "codex_cli", - ); - assert_eq!(filtered, vec![app("delta")]); - } - - #[test] - fn first_party_chat_originator_filters_target_connector_ids() { - let filtered = filter_disallowed_connectors( - vec![ - app("connector_openai_foo"), - app("asdk_app_6938a94a61d881918ef32cb999ff937c"), - app("connector_0f9c9d4592e54d0a9a12b3f44a1e2010"), - ], - "codex_atlas", - ); - assert_eq!( - filtered, - vec![ - app("connector_openai_foo"), - app("asdk_app_6938a94a61d881918ef32cb999ff937c") - ] - ); - } - #[test] fn filter_tool_suggest_discoverable_connectors_keeps_only_plugin_backed_uninstalled_apps() { let filtered = filter_tool_suggest_discoverable_connectors( @@ -179,7 +81,6 @@ mod tests { "connector_2128aebfecb84f64a069897515042a44".to_string(), "connector_68df038e0ba48191908c8434991bbac2".to_string(), ]), - "codex_cli", ); assert_eq!( @@ -219,7 +120,6 @@ mod tests { "connector_2128aebfecb84f64a069897515042a44".to_string(), "connector_68df038e0ba48191908c8434991bbac2".to_string(), ]), - "codex_cli", ); assert_eq!(filtered, Vec::::new()); diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 6d73015595..8cc83be3d2 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -31,7 +31,6 @@ use codex_core_plugins::PluginsManager; use codex_features::Feature; use codex_login::AuthManager; use codex_login::CodexAuth; -use codex_login::default_client::originator; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_mcp::McpConnectionManager; use codex_mcp::McpRuntimeContext; @@ -112,7 +111,6 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( directory_connectors, accessible_connectors, &connector_ids, - originator().value.as_str(), ) .into_iter() .map(DiscoverableTool::from); @@ -143,12 +141,7 @@ pub async fn list_cached_accessible_connectors_from_mcp_tools( return Some(Vec::new()); } let cache_key = accessible_connectors_cache_key(config, auth.as_ref()); - read_cached_accessible_connectors(&cache_key).map(|connectors| { - codex_connectors::filter::filter_disallowed_connectors( - connectors, - originator().value.as_str(), - ) - }) + read_cached_accessible_connectors(&cache_key) } pub(crate) fn refresh_accessible_connectors_cache_from_mcp_tools( @@ -161,10 +154,7 @@ pub(crate) fn refresh_accessible_connectors_cache_from_mcp_tools( } let cache_key = accessible_connectors_cache_key(config, auth); - let accessible_connectors = codex_connectors::filter::filter_disallowed_connectors( - accessible_connectors_from_mcp_tools(mcp_tools), - originator().value.as_str(), - ); + let accessible_connectors = accessible_connectors_from_mcp_tools(mcp_tools); write_cached_accessible_connectors(cache_key, &accessible_connectors); } @@ -240,10 +230,6 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager( let tool_plugin_provenance = tool_plugin_provenance(&mcp_config); if !force_refetch && let Some(cached_connectors) = read_cached_accessible_connectors(&cache_key) { - let cached_connectors = codex_connectors::filter::filter_disallowed_connectors( - cached_connectors, - originator().value.as_str(), - ); let cached_connectors = with_app_plugin_sources(cached_connectors, &tool_plugin_provenance); return Ok(AccessibleConnectorsStatus { connectors: cached_connectors, @@ -353,10 +339,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager( cancel_token.cancel(); } - let accessible_connectors = codex_connectors::filter::filter_disallowed_connectors( - accessible_connectors_from_mcp_tools(&tools), - originator().value.as_str(), - ); + let accessible_connectors = accessible_connectors_from_mcp_tools(&tools); if codex_apps_ready || !accessible_connectors.is_empty() { write_cached_accessible_connectors(cache_key, &accessible_connectors); } diff --git a/codex-rs/utils/plugins/Cargo.toml b/codex-rs/utils/plugins/Cargo.toml index 2c56ab3773..cdfb600c8c 100644 --- a/codex-rs/utils/plugins/Cargo.toml +++ b/codex-rs/utils/plugins/Cargo.toml @@ -15,7 +15,6 @@ workspace = true [dependencies] codex-exec-server = { workspace = true } -codex-login = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-path-uri = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/codex-rs/utils/plugins/src/mcp_connector.rs b/codex-rs/utils/plugins/src/mcp_connector.rs index 0c293936c9..96fd72eb24 100644 --- a/codex-rs/utils/plugins/src/mcp_connector.rs +++ b/codex-rs/utils/plugins/src/mcp_connector.rs @@ -1,31 +1,3 @@ -use codex_login::default_client::is_first_party_chat_originator; -use codex_login::default_client::originator; - -const DISALLOWED_CONNECTOR_IDS: &[&str] = &[ - "asdk_app_6938a94a61d881918ef32cb999ff937c", - "connector_2b0a9009c9c64bf9933a3dae3f2b1254", - "connector_3f8d1a79f27c4c7ba1a897ab13bf37dc", - "connector_68de829bf7648191acd70a907364c67c", - "connector_68e004f14af881919eb50893d3d9f523", - "connector_69272cb413a081919685ec3c88d1744e", -]; -const FIRST_PARTY_CHAT_DISALLOWED_CONNECTOR_IDS: &[&str] = - &["connector_0f9c9d4592e54d0a9a12b3f44a1e2010"]; - -pub fn is_connector_id_allowed(connector_id: &str) -> bool { - is_connector_id_allowed_for_originator(connector_id, originator().value.as_str()) -} - -fn is_connector_id_allowed_for_originator(connector_id: &str, originator_value: &str) -> bool { - let disallowed_connector_ids = if is_first_party_chat_originator(originator_value) { - FIRST_PARTY_CHAT_DISALLOWED_CONNECTOR_IDS - } else { - DISALLOWED_CONNECTOR_IDS - }; - - !disallowed_connector_ids.contains(&connector_id) -} - pub fn sanitize_name(name: &str) -> String { sanitize_slug(name).replace("-", "_") } From 9bcc09f9f7b529cb9547a1cf9f15d6288762e2e1 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Thu, 18 Jun 2026 13:37:04 -0700 Subject: [PATCH 047/112] TUI: improve unified mention selection visibility (#28959) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary [@milanglacier reported in #28653](https://github.com/openai/codex/issues/28653) that the active mention candidate is hard to distinguish. I suspect [@binbjz’s #28500 report](https://github.com/openai/codex/issues/28500) _(where arrow-key navigation appeared not to work)_ may describe the same presentation problem: the selection may have been changing, but the UI was not showing the active row clearly in their terminal. This PR makes two small changes to the selection indication behavior: - Reserve a two-character gutter and mark the active candidate with `> ` for color-agnostic indicator coverage. - Apply the shared theme-aware accent to the entire selected row for extra emphasis. - Update the existing popup snapshot. Reverse-video styling was considered, but avoided it because it is overly dependent on the user’s terminal palette. image ## Testing - `just test -p codex-tui default_unified_mention_popup_snapshot` - `just clippy -p codex-tui` - `just fmt` - Compiled `codex-cli` and tested the unified mentions picker in the terminal. --- .../tui/src/bottom_pane/mentions_v2/render.rs | 44 ++++++++----------- ..._tests__default_unified_mention_popup.snap | 2 +- 2 files changed, 20 insertions(+), 26 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/mentions_v2/render.rs b/codex-rs/tui/src/bottom_pane/mentions_v2/render.rs index c8031a0137..c2fa1f6a14 100644 --- a/codex-rs/tui/src/bottom_pane/mentions_v2/render.rs +++ b/codex-rs/tui/src/bottom_pane/mentions_v2/render.rs @@ -9,8 +9,7 @@ use ratatui::text::Span; use ratatui::widgets::Widget; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; -use crate::render::Insets; -use crate::render::RectExt; +use crate::style::accent_style; use super::candidate::MentionType; use super::candidate::SearchResult; @@ -46,15 +45,7 @@ pub(super) fn render_popup( (area, None) }; - render_rows( - list_area.inset(Insets::tlbr( - /*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0, - )), - buf, - rows, - state, - empty_message, - ); + render_rows(list_area, buf, rows, state, empty_message); if let Some(hint_area) = hint_area { let hint_area = Rect { @@ -78,7 +69,7 @@ fn render_rows( return; } if rows.is_empty() { - Line::from(empty_message.italic()).render(area, buf); + Line::from(vec![" ".into(), empty_message.italic()]).render(area, buf); return; } @@ -131,31 +122,34 @@ fn build_line( width: usize, primary_column_width: usize, ) -> Line<'static> { - let base_style = if selected { - Style::default().bold() - } else { - Style::default() - }; - let dim_style = if selected { - Style::default().bold() - } else { - Style::default().dim() - }; + let base_style = Style::default(); + let dim_style = Style::default().dim(); let tag = row.mention_type.span(base_style); let tag_width = tag.width(); - let content_width = width.saturating_sub(tag_width.saturating_add(2)); + let gutter = if selected { "> " } else { " " }; + let gutter_width = gutter.len(); + let content_width = + width.saturating_sub(gutter_width.saturating_add(tag_width).saturating_add(2)); let content = truncate_line_with_ellipsis_if_overflow( content_line(row, base_style, dim_style, primary_column_width), content_width, ); let rendered_content_width = content.width(); - let mut spans = Vec::new(); + let mut spans = vec![gutter.into()]; spans.extend(content.spans); - let padding = width.saturating_sub(rendered_content_width.saturating_add(tag_width)); + let padding = width.saturating_sub( + gutter_width + .saturating_add(rendered_content_width) + .saturating_add(tag_width), + ); if padding > 0 { spans.push(" ".repeat(padding).set_style(dim_style)); } spans.push(tag); + if selected { + let style = accent_style(); + spans.iter_mut().for_each(|span| span.style = style); + } Line::from(spans) } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__default_unified_mention_popup.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__default_unified_mention_popup.snap index 797e4113f0..bf21e4ea61 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__default_unified_mention_popup.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__default_unified_mention_popup.snap @@ -5,7 +5,7 @@ expression: terminal.backend() " " "› @sa " " " -" Sample Plugin Plugin with skills and an MCP server Plugin" +"> Sample Plugin Plugin with skills and an MCP server Plugin" " " " " " " From 765309d5a611ea02be842ead0ab1a2828196fae9 Mon Sep 17 00:00:00 2001 From: martinauyeung-oai Date: Thu, 18 Jun 2026 14:02:54 -0700 Subject: [PATCH 048/112] Emit Trusted MCP App Identity on Tool-Call Items (#27132) ## Summary - Add optional `appContext` to app-server MCP tool-call items with trusted `connectorId`, `linkId`, and `mcpAppResourceUri` metadata. - Preserve that context across tool-call events, persisted history, reconnects, and thread resume. - Keep the deprecated top-level `mcpAppResourceUri` temporarily for client migration. The consumer contract is `{ appContext: { connectorId, linkId, mcpAppResourceUri }, tool }`. ## Validation - Full GitHub Actions suite passes, including CLA, Bazel tests, clippy, release builds, and argument-comment lint. --------- Co-authored-by: martinauyeung-oai <280153141+martinauyeung-oai@users.noreply.github.com> --- .../analytics/src/analytics_client_tests.rs | 1 + .../schema/json/ServerNotification.json | 34 +++++++++++ .../codex_app_server_protocol.schemas.json | 34 +++++++++++ .../codex_app_server_protocol.v2.schemas.json | 34 +++++++++++ .../json/v2/ItemCompletedNotification.json | 34 +++++++++++ .../json/v2/ItemStartedNotification.json | 34 +++++++++++ .../schema/json/v2/ReviewStartResponse.json | 34 +++++++++++ .../schema/json/v2/ThreadForkResponse.json | 34 +++++++++++ .../schema/json/v2/ThreadListResponse.json | 34 +++++++++++ .../json/v2/ThreadMetadataUpdateResponse.json | 34 +++++++++++ .../schema/json/v2/ThreadReadResponse.json | 34 +++++++++++ .../schema/json/v2/ThreadResumeResponse.json | 34 +++++++++++ .../json/v2/ThreadRollbackResponse.json | 34 +++++++++++ .../schema/json/v2/ThreadStartResponse.json | 34 +++++++++++ .../json/v2/ThreadStartedNotification.json | 34 +++++++++++ .../json/v2/ThreadUnarchiveResponse.json | 34 +++++++++++ .../json/v2/TurnCompletedNotification.json | 34 +++++++++++ .../schema/json/v2/TurnStartResponse.json | 34 +++++++++++ .../json/v2/TurnStartedNotification.json | 34 +++++++++++ .../typescript/v2/McpToolCallAppContext.ts | 5 ++ .../schema/typescript/v2/ThreadItem.ts | 7 ++- .../schema/typescript/v2/index.ts | 1 + .../src/protocol/thread_history.rs | 28 +++++++++- .../src/protocol/v2/item.rs | 16 ++++++ .../src/protocol/v2/tests.rs | 53 ++++++++++++++++++ codex-rs/app-server/README.md | 2 +- .../thread_resume_redaction.rs | 13 +++++ .../app-server/tests/suite/v2/mcp_tool.rs | 1 + .../tests/suite/v2/thread_resume.rs | 25 ++++++++- codex-rs/core/src/mcp_tool_call.rs | 44 ++++++++++++--- codex-rs/core/src/mcp_tool_call_tests.rs | 56 ++++++++++++++++++- .../core/src/tools/handlers/mcp_resource.rs | 4 ++ .../core/tests/common/apps_test_server.rs | 4 ++ codex-rs/core/tests/suite/rmcp_client.rs | 2 + codex-rs/core/tests/suite/search_tool.rs | 3 + ...event_processor_with_jsonl_output_tests.rs | 1 + .../tests/event_processor_with_json_output.rs | 5 ++ codex-rs/protocol/src/items.rs | 10 ++++ codex-rs/protocol/src/protocol.rs | 20 +++++++ .../src/chatwidget/tests/history_replay.rs | 1 + 40 files changed, 899 insertions(+), 15 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/McpToolCallAppContext.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index fcdd97af8e..dac4829ab3 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -4121,6 +4121,7 @@ async fn turn_event_counts_completed_tool_items() { tool: "search".to_string(), status, arguments: json!({}), + app_context: None, mcp_app_resource_uri: None, plugin_id: Some("sample@test".to_string()), result: None, diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index bfafd5483b..8509a1c69d 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2396,6 +2396,29 @@ ], "type": "object" }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -4066,6 +4089,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -4089,6 +4122,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 8ef80b7b54..e9611c67e1 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -12119,6 +12119,29 @@ "title": "McpServerToolCallResponse", "type": "object" }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -17511,6 +17534,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/v2/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -17534,6 +17567,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 00d7a4bbdf..bac9a48e70 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -8543,6 +8543,29 @@ "title": "McpServerToolCallResponse", "type": "object" }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -15290,6 +15313,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -15313,6 +15346,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index 027edd0367..d7bfb3827e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -297,6 +297,29 @@ "LegacyAppPathString": { "type": "string" }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -784,6 +807,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -807,6 +840,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index 64152e9cf4..770fc8bd4c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -297,6 +297,29 @@ "LegacyAppPathString": { "type": "string" }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -784,6 +807,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -807,6 +840,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 61f61e74dc..545ae64d4a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -434,6 +434,29 @@ "LegacyAppPathString": { "type": "string" }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -928,6 +951,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -951,6 +984,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 43a76f145a..9ced571cfa 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -539,6 +539,29 @@ "LegacyAppPathString": { "type": "string" }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -1420,6 +1443,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -1443,6 +1476,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 1e1504b738..9925c5411e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -460,6 +460,29 @@ "LegacyAppPathString": { "type": "string" }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -1235,6 +1258,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -1258,6 +1291,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 6e62eae14b..1e142947ed 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -460,6 +460,29 @@ "LegacyAppPathString": { "type": "string" }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -1235,6 +1258,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -1258,6 +1291,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index ff70a017b4..532a712807 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -460,6 +460,29 @@ "LegacyAppPathString": { "type": "string" }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -1235,6 +1258,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -1258,6 +1291,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index f9a65cdd8b..28a5f5b24a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -539,6 +539,29 @@ "LegacyAppPathString": { "type": "string" }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -1420,6 +1443,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -1443,6 +1476,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index f455f6dfff..9f394b31cf 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -460,6 +460,29 @@ "LegacyAppPathString": { "type": "string" }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -1235,6 +1258,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -1258,6 +1291,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 0c3eac4de5..1baf6e4bdc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -539,6 +539,29 @@ "LegacyAppPathString": { "type": "string" }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -1420,6 +1443,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -1443,6 +1476,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 0e44cfb258..22801809cd 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -460,6 +460,29 @@ "LegacyAppPathString": { "type": "string" }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -1235,6 +1258,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -1258,6 +1291,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index f88a1fae59..3009e86e70 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -460,6 +460,29 @@ "LegacyAppPathString": { "type": "string" }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -1235,6 +1258,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -1258,6 +1291,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 7e6cef2c37..b9a940157c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -434,6 +434,29 @@ "LegacyAppPathString": { "type": "string" }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -928,6 +951,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -951,6 +984,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 083767a6cf..da10713dd1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -434,6 +434,29 @@ "LegacyAppPathString": { "type": "string" }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -928,6 +951,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -951,6 +984,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 3a43d7d3fb..6b93498221 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -434,6 +434,29 @@ "LegacyAppPathString": { "type": "string" }, + "McpToolCallAppContext": { + "properties": { + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "connectorId" + ], + "type": "object" + }, "McpToolCallError": { "properties": { "message": { @@ -928,6 +951,16 @@ }, { "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, "arguments": true, "durationMs": { "description": "The duration of the MCP tool call in milliseconds.", @@ -951,6 +984,7 @@ "type": "string" }, "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpToolCallAppContext.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpToolCallAppContext.ts new file mode 100644 index 0000000000..0a8343428d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpToolCallAppContext.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type McpToolCallAppContext = { connectorId: string, linkId: string | null, resourceUri: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts index 1cdac99c64..eb8c6486b8 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts @@ -16,6 +16,7 @@ import type { DynamicToolCallOutputContentItem } from "./DynamicToolCallOutputCo import type { DynamicToolCallStatus } from "./DynamicToolCallStatus"; import type { FileUpdateChange } from "./FileUpdateChange"; import type { HookPromptFragment } from "./HookPromptFragment"; +import type { McpToolCallAppContext } from "./McpToolCallAppContext"; import type { McpToolCallError } from "./McpToolCallError"; import type { McpToolCallResult } from "./McpToolCallResult"; import type { McpToolCallStatus } from "./McpToolCallStatus"; @@ -55,7 +56,11 @@ exitCode: number | null, /** * The duration of the command execution in milliseconds. */ -durationMs: number | null, } | { "type": "fileChange", id: string, changes: Array, status: PatchApplyStatus, } | { "type": "mcpToolCall", id: string, server: string, tool: string, status: McpToolCallStatus, arguments: JsonValue, mcpAppResourceUri?: string, pluginId: string | null, result: McpToolCallResult | null, error: McpToolCallError | null, +durationMs: number | null, } | { "type": "fileChange", id: string, changes: Array, status: PatchApplyStatus, } | { "type": "mcpToolCall", id: string, server: string, tool: string, status: McpToolCallStatus, arguments: JsonValue, appContext: McpToolCallAppContext | null, +/** + * Deprecated: use `appContext.resourceUri` instead. + */ +mcpAppResourceUri?: string, pluginId: string | null, result: McpToolCallResult | null, error: McpToolCallError | null, /** * The duration of the MCP tool call in milliseconds. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 2e59b0fe19..9b1bd3b6e8 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -243,6 +243,7 @@ export type { McpServerStatusDetail } from "./McpServerStatusDetail"; export type { McpServerStatusUpdatedNotification } from "./McpServerStatusUpdatedNotification"; export type { McpServerToolCallParams } from "./McpServerToolCallParams"; export type { McpServerToolCallResponse } from "./McpServerToolCallResponse"; +export type { McpToolCallAppContext } from "./McpToolCallAppContext"; export type { McpToolCallError } from "./McpToolCallError"; export type { McpToolCallProgressNotification } from "./McpToolCallProgressNotification"; export type { McpToolCallResult } from "./McpToolCallResult"; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 001fa7cf93..6b27e72d46 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -10,6 +10,7 @@ use crate::protocol::v2::CollabAgentToolCallStatus; use crate::protocol::v2::CommandExecutionStatus; use crate::protocol::v2::DynamicToolCallOutputContentItem; use crate::protocol::v2::DynamicToolCallStatus; +use crate::protocol::v2::McpToolCallAppContext; use crate::protocol::v2::McpToolCallError; use crate::protocol::v2::McpToolCallResult; use crate::protocol::v2::McpToolCallStatus; @@ -765,6 +766,14 @@ impl ThreadHistoryBuilder { .arguments .clone() .unwrap_or(serde_json::Value::Null), + app_context: payload + .connector_id + .clone() + .map(|connector_id| McpToolCallAppContext { + connector_id, + link_id: payload.link_id.clone(), + resource_uri: payload.mcp_app_resource_uri.clone(), + }), mcp_app_resource_uri: payload.mcp_app_resource_uri.clone(), plugin_id: payload.plugin_id.clone(), result: None, @@ -807,6 +816,14 @@ impl ThreadHistoryBuilder { .arguments .clone() .unwrap_or(serde_json::Value::Null), + app_context: payload + .connector_id + .clone() + .map(|connector_id| McpToolCallAppContext { + connector_id, + link_id: payload.link_id.clone(), + resource_uri: payload.mcp_app_resource_uri.clone(), + }), mcp_app_resource_uri: payload.mcp_app_resource_uri.clone(), plugin_id: payload.plugin_id.clone(), result, @@ -1567,7 +1584,6 @@ mod tests { use codex_protocol::protocol::DynamicToolCallResponseEvent; use codex_protocol::protocol::ExecCommandEndEvent; use codex_protocol::protocol::ExecCommandSource; - use codex_protocol::protocol::ItemCompletedEvent; use codex_protocol::protocol::ItemStartedEvent; use codex_protocol::protocol::McpInvocation; use codex_protocol::protocol::McpToolCallEndEvent; @@ -2397,7 +2413,9 @@ mod tests { tool: "lookup".into(), arguments: Some(serde_json::json!({"id":"123"})), }, + connector_id: None, mcp_app_resource_uri: None, + link_id: None, plugin_id: None, duration: Duration::from_millis(8), result: Err("boom".into()), @@ -2447,6 +2465,7 @@ mod tests { tool: "lookup".into(), status: McpToolCallStatus::Failed, arguments: serde_json::json!({"id":"123"}), + app_context: None, mcp_app_resource_uri: None, plugin_id: None, result: None, @@ -2475,7 +2494,9 @@ mod tests { tool: "lookup".into(), arguments: Some(serde_json::json!({"id":"123"})), }, + connector_id: Some("calendar".into()), mcp_app_resource_uri: Some("ui://widget/lookup.html".into()), + link_id: Some("link_calendar".into()), plugin_id: Some("sample@test".into()), duration: Duration::from_millis(8), result: Ok(CallToolResult { @@ -2506,6 +2527,11 @@ mod tests { tool: "lookup".into(), status: McpToolCallStatus::Completed, arguments: serde_json::json!({"id":"123"}), + app_context: Some(McpToolCallAppContext { + connector_id: "calendar".into(), + link_id: Some("link_calendar".into()), + resource_uri: Some("ui://widget/lookup.html".into()), + }), mcp_app_resource_uri: Some("ui://widget/lookup.html".into()), plugin_id: Some("sample@test".into()), result: Some(Box::new(McpToolCallResult { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs index 34cfe8e136..e630de634b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -290,8 +290,10 @@ pub enum ThreadItem { tool: String, status: McpToolCallStatus, arguments: JsonValue, + app_context: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] + /// Deprecated: use `appContext.resourceUri` instead. mcp_app_resource_uri: Option, plugin_id: Option, result: Option>, @@ -384,6 +386,15 @@ pub enum ThreadItem { ContextCompaction { id: String }, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub struct McpToolCallAppContext { + pub connector_id: String, + pub link_id: Option, + pub resource_uri: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase", export_to = "v2/")] @@ -877,6 +888,11 @@ impl From for ThreadItem { tool: mcp.tool, status: McpToolCallStatus::from(mcp.status), arguments: mcp.arguments, + app_context: mcp.connector_id.map(|connector_id| McpToolCallAppContext { + connector_id, + link_id: mcp.link_id, + resource_uri: mcp.mcp_app_resource_uri.clone(), + }), mcp_app_resource_uri: mcp.mcp_app_resource_uri, plugin_id: mcp.plugin_id, result: mcp.result.map(McpToolCallResult::from).map(Box::new), diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index bb66aa632e..0368bf45f6 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -2615,7 +2615,9 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { server: "server".to_string(), tool: "tool".to_string(), arguments: json!({"arg": "value"}), + connector_id: Some("calendar".to_string()), mcp_app_resource_uri: Some("app://connector".to_string()), + link_id: Some("link_calendar".to_string()), plugin_id: Some("sample@test".to_string()), status: CoreMcpToolCallStatus::InProgress, result: None, @@ -2631,6 +2633,11 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { tool: "tool".to_string(), status: McpToolCallStatus::InProgress, arguments: json!({"arg": "value"}), + app_context: Some(McpToolCallAppContext { + connector_id: "calendar".to_string(), + link_id: Some("link_calendar".to_string()), + resource_uri: Some("app://connector".to_string()), + }), mcp_app_resource_uri: Some("app://connector".to_string()), plugin_id: Some("sample@test".to_string()), result: None, @@ -2644,7 +2651,9 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { server: "server".to_string(), tool: "tool".to_string(), arguments: JsonValue::Null, + connector_id: None, mcp_app_resource_uri: None, + link_id: None, plugin_id: None, status: CoreMcpToolCallStatus::Completed, result: Some(CallToolResult { @@ -2665,6 +2674,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { tool: "tool".to_string(), status: McpToolCallStatus::Completed, arguments: JsonValue::Null, + app_context: None, mcp_app_resource_uri: None, plugin_id: None, result: Some(Box::new(McpToolCallResult { @@ -2678,6 +2688,49 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { ); } +#[test] +fn mcp_tool_call_app_context_serializes_connector_id() { + let item = ThreadItem::McpToolCall { + id: "mcp-1".to_string(), + server: "codex_apps".to_string(), + tool: "calendar.create_event".to_string(), + status: McpToolCallStatus::InProgress, + arguments: json!({}), + app_context: Some(McpToolCallAppContext { + connector_id: "calendar".to_string(), + link_id: Some("link_calendar".to_string()), + resource_uri: Some("app://connector".to_string()), + }), + mcp_app_resource_uri: Some("app://connector".to_string()), + plugin_id: None, + result: None, + error: None, + duration_ms: None, + }; + + assert_eq!( + serde_json::to_value(item).expect("MCP tool call should serialize"), + json!({ + "type": "mcpToolCall", + "id": "mcp-1", + "server": "codex_apps", + "tool": "calendar.create_event", + "status": "inProgress", + "arguments": {}, + "appContext": { + "connectorId": "calendar", + "linkId": "link_calendar", + "resourceUri": "app://connector", + }, + "mcpAppResourceUri": "app://connector", + "pluginId": null, + "result": null, + "error": null, + "durationMs": null, + }) + ); +} + #[test] fn user_input_into_core_preserves_image_detail() { assert_eq!( diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index b9c7899fc8..deda1c517e 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1365,7 +1365,7 @@ Today both notifications carry an empty `items` array even when item events were - `reasoning` — `{id, summary, content}` where `summary` holds streamed reasoning summaries (applicable for most OpenAI models) and `content` holds raw reasoning blocks (applicable for e.g. open source models). - `commandExecution` — `{id, command, cwd, status, commandActions, aggregatedOutput?, exitCode?, durationMs?}` for sandboxed commands; `status` is `inProgress`, `completed`, `failed`, or `declined`. - `fileChange` — `{id, changes, status}` describing proposed edits; `changes` list `{path, kind, diff}` and `status` is `inProgress`, `completed`, `failed`, or `declined`. -- `mcpToolCall` — `{id, server, tool, status, arguments, mcpAppResourceUri?, pluginId, result?, error?}` describing MCP calls; `status` is `inProgress`, `completed`, or `failed`. +- `mcpToolCall` — `{id, server, tool, status, arguments, appContext, mcpAppResourceUri?, pluginId, result?, error?}` describing MCP calls; `appContext` is `{connectorId, linkId, resourceUri}` for calls through a trusted MCP app, where `connectorId` identifies the connector that owns the tool, `linkId` identifies the app link, and `resourceUri` points to the widget template. The top-level `mcpAppResourceUri` is deprecated and temporarily duplicated for client migration. `tool` identifies the invoked action. `status` is `inProgress`, `completed`, or `failed`. - `collabToolCall` — `{id, tool, status, senderThreadId, receiverThreadId?, newThreadId?, prompt?, agentStatus?}` describing collab tool calls (`spawn_agent`, `send_input`, `resume_agent`, `wait`, `close_agent`); `status` is `inProgress`, `completed`, or `failed`. - `webSearch` — `{id, query, action?}` for a web search request issued by the agent; `action` mirrors the Responses API web_search action payload (`search`, `open_page`, `find_in_page`) and may be omitted until completion. - `imageView` — `{id, path}` emitted when the agent invokes the image viewer tool. diff --git a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs index 110d6d5cbf..b7d8640695 100644 --- a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs +++ b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs @@ -52,6 +52,7 @@ fn redacted_mcp_tool_call_result() -> McpToolCallResult { #[cfg(test)] mod tests { use super::*; + use codex_app_server_protocol::McpToolCallAppContext; use codex_app_server_protocol::McpToolCallError; use codex_app_server_protocol::McpToolCallStatus; use codex_app_server_protocol::SessionSource; @@ -78,6 +79,11 @@ mod tests { tool: "lookup".to_string(), status: McpToolCallStatus::Completed, arguments: serde_json::json!({"secret":"argument"}), + app_context: Some(McpToolCallAppContext { + connector_id: "calendar".to_string(), + link_id: Some("link_calendar".to_string()), + resource_uri: Some("ui://widget/lookup.html".to_string()), + }), mcp_app_resource_uri: Some("ui://widget/lookup.html".to_string()), plugin_id: Some("sample@test".to_string()), result: Some(Box::new(McpToolCallResult { @@ -120,6 +126,11 @@ mod tests { tool: "lookup".to_string(), status: McpToolCallStatus::Completed, arguments: JsonValue::String(REDACTED_PAYLOAD.to_string()), + app_context: Some(McpToolCallAppContext { + connector_id: "calendar".to_string(), + link_id: Some("link_calendar".to_string()), + resource_uri: Some("ui://widget/lookup.html".to_string()), + }), mcp_app_resource_uri: Some("ui://widget/lookup.html".to_string()), plugin_id: Some("sample@test".to_string()), result: Some(Box::new(redacted_mcp_tool_call_result())), @@ -137,6 +148,7 @@ mod tests { tool: "lookup".to_string(), status: McpToolCallStatus::Failed, arguments: serde_json::json!({"secret":"argument"}), + app_context: None, mcp_app_resource_uri: None, plugin_id: None, result: None, @@ -156,6 +168,7 @@ mod tests { tool: "lookup".to_string(), status: McpToolCallStatus::Failed, arguments: JsonValue::String(REDACTED_PAYLOAD.to_string()), + app_context: None, mcp_app_resource_uri: None, plugin_id: None, result: None, diff --git a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs index 10c8a6ef04..07c2e9fed7 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs @@ -513,6 +513,7 @@ url = "{mcp_server_url}/mcp" tool, status, arguments: json!({ "message": LARGE_RESPONSE_MESSAGE }), + app_context: None, mcp_app_resource_uri: None, plugin_id: None, result: Some(result), diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index c191263d1c..cd508fc687 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -23,6 +23,7 @@ use codex_app_server_protocol::FileChangeRequestApprovalResponse; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::McpToolCallAppContext; use codex_app_server_protocol::PatchApplyStatus; use codex_app_server_protocol::PatchChangeKind; use codex_app_server_protocol::RequestId; @@ -729,6 +730,7 @@ async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<( .expect("remote resume should include redacted MCP item"); let ThreadItem::McpToolCall { arguments, + app_context, result, error, .. @@ -737,6 +739,14 @@ async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<( unreachable!("matched MCP item"); }; assert_eq!(arguments, &json!("[redacted]")); + assert_eq!( + app_context, + &Some(McpToolCallAppContext { + connector_id: "calendar".to_string(), + link_id: Some("link_calendar".to_string()), + resource_uri: Some("ui://widget/lookup.html".to_string()), + }) + ); let result = result.as_ref().expect("redacted MCP result"); assert_eq!( result.content, @@ -770,12 +780,23 @@ async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<( .find(|item| matches!(item, ThreadItem::McpToolCall { .. })) .expect("normal resume should include MCP item"); let ThreadItem::McpToolCall { - arguments, result, .. + arguments, + app_context, + result, + .. } = normal_mcp_item else { unreachable!("matched MCP item"); }; assert_eq!(arguments, &json!({"secret":"argument"})); + assert_eq!( + app_context, + &Some(McpToolCallAppContext { + connector_id: "calendar".to_string(), + link_id: Some("link_calendar".to_string()), + resource_uri: Some("ui://widget/lookup.html".to_string()), + }) + ); let result = result.as_ref().expect("normal MCP result"); assert_eq!( result.content, @@ -877,7 +898,9 @@ fn append_resume_redaction_history( tool: "lookup".to_string(), arguments: Some(json!({"secret":"argument"})), }, + connector_id: Some("calendar".to_string()), mcp_app_resource_uri: Some("ui://widget/lookup.html".to_string()), + link_id: Some("link_calendar".to_string()), plugin_id: None, duration: Duration::from_millis(8), result: Ok(CallToolResult { diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index a89951542a..56d6e678a0 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -143,14 +143,7 @@ pub(crate) async fn handle_mcp_tool_call( let metadata = lookup_mcp_tool_metadata(sess.as_ref(), turn_context.as_ref(), &server, &tool_name).await; - let item_metadata = McpToolCallItemMetadata { - mcp_app_resource_uri: metadata - .as_ref() - .and_then(|metadata| metadata.mcp_app_resource_uri.clone()), - plugin_id: metadata - .as_ref() - .and_then(|metadata| metadata.plugin_id.clone()), - }; + let item_metadata = McpToolCallItemMetadata::from_tool_metadata(&server, metadata.as_ref()); let app_tool_policy = if server == CODEX_APPS_MCP_SERVER_NAME { let annotations = metadata .as_ref() @@ -312,12 +305,32 @@ pub(crate) struct HandledMcpToolCall { pub(crate) tool_input: JsonValue, } -#[derive(Clone)] +#[derive(Clone, Debug, PartialEq, Eq)] struct McpToolCallItemMetadata { + connector_id: Option, + link_id: Option, mcp_app_resource_uri: Option, plugin_id: Option, } +impl McpToolCallItemMetadata { + fn from_tool_metadata(server: &str, metadata: Option<&McpToolApprovalMetadata>) -> Self { + let trusted_mcp_app_metadata = if server == CODEX_APPS_MCP_SERVER_NAME { + metadata + } else { + None + }; + Self { + connector_id: trusted_mcp_app_metadata + .and_then(|metadata| metadata.connector_id.clone()), + link_id: trusted_mcp_app_metadata.and_then(|metadata| metadata.link_id.clone()), + mcp_app_resource_uri: metadata + .and_then(|metadata| metadata.mcp_app_resource_uri.clone()), + plugin_id: metadata.and_then(|metadata| metadata.plugin_id.clone()), + } + } +} + async fn handle_approved_mcp_tool_call( sess: &Session, turn_context: &TurnContext, @@ -892,7 +905,9 @@ async fn notify_mcp_tool_call_started( server, tool, arguments: arguments.unwrap_or(JsonValue::Null), + connector_id: item_metadata.connector_id, mcp_app_resource_uri: item_metadata.mcp_app_resource_uri, + link_id: item_metadata.link_id, plugin_id: item_metadata.plugin_id, status: McpToolCallStatus::InProgress, result: None, @@ -932,7 +947,9 @@ async fn notify_mcp_tool_call_completed( server, tool, arguments: arguments.unwrap_or(JsonValue::Null), + connector_id: item_metadata.connector_id, mcp_app_resource_uri: item_metadata.mcp_app_resource_uri, + link_id: item_metadata.link_id, plugin_id: item_metadata.plugin_id, status, result, @@ -998,6 +1015,7 @@ enum McpToolApprovalDecision { pub(crate) struct McpToolApprovalMetadata { annotations: Option, connector_id: Option, + link_id: Option, connector_name: Option, connector_description: Option, plugin_id: Option, @@ -1010,6 +1028,7 @@ pub(crate) struct McpToolApprovalMetadata { const MCP_TOOL_OPENAI_OUTPUT_TEMPLATE_META_KEY: &str = "openai/outputTemplate"; const MCP_TOOL_UI_RESOURCE_URI_META_KEY: &str = "ui/resourceUri"; +const MCP_TOOL_LINK_ID_META_KEY: &str = "link_id"; const MCP_TOOL_PLUGIN_ID_META_KEY: &str = "plugin_id"; const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId"; @@ -1493,6 +1512,13 @@ pub(crate) async fn lookup_mcp_tool_metadata( Some(McpToolApprovalMetadata { annotations: tool_info.tool.annotations, connector_id: tool_info.connector_id, + link_id: tool_info + .tool + .meta + .as_ref() + .and_then(|meta| meta.get(MCP_TOOL_LINK_ID_META_KEY)) + .and_then(serde_json::Value::as_str) + .map(str::to_string), connector_name: tool_info.connector_name, connector_description, plugin_id, diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 24abfd9e09..eb500b0f6c 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -78,6 +78,7 @@ fn approval_metadata( McpToolApprovalMetadata { annotations: None, connector_id: connector_id.map(str::to_string), + link_id: None, connector_name: connector_name.map(str::to_string), connector_description: connector_description.map(str::to_string), plugin_id: None, @@ -1149,8 +1150,39 @@ async fn plugin_mcp_tool_call_request_meta_includes_plugin_id() { ); } +#[test] +fn mcp_tool_call_item_metadata_only_trusts_codex_apps_identity() { + let mut metadata = approval_metadata( + Some("asdk_app_0123456789abcdef0123456789abcdef"), + /*connector_name*/ None, + /*connector_description*/ None, + /*tool_title*/ None, + /*tool_description*/ None, + ); + metadata.link_id = Some("link_fedcba9876543210fedcba9876543210".to_string()); + + assert_eq!( + McpToolCallItemMetadata::from_tool_metadata(CODEX_APPS_MCP_SERVER_NAME, Some(&metadata),), + McpToolCallItemMetadata { + connector_id: Some("asdk_app_0123456789abcdef0123456789abcdef".to_string()), + link_id: Some("link_fedcba9876543210fedcba9876543210".to_string()), + mcp_app_resource_uri: None, + plugin_id: None, + } + ); + assert_eq!( + McpToolCallItemMetadata::from_tool_metadata("custom_server", Some(&metadata)), + McpToolCallItemMetadata { + connector_id: None, + link_id: None, + mcp_app_resource_uri: None, + plugin_id: None, + } + ); +} + #[tokio::test] -async fn mcp_tool_call_item_includes_plugin_id() { +async fn mcp_tool_call_item_includes_app_identity() { let (session, turn_context, rx_event) = make_session_and_context_with_rx().await; notify_mcp_tool_call_started( @@ -1158,11 +1190,13 @@ async fn mcp_tool_call_item_includes_plugin_id() { &turn_context, "call-plugin", McpInvocation { - server: "sample".to_string(), + server: CODEX_APPS_MCP_SERVER_NAME.to_string(), tool: "echo".to_string(), arguments: None, }, McpToolCallItemMetadata { + connector_id: Some("asdk_app_0123456789abcdef0123456789abcdef".to_string()), + link_id: Some("link_fedcba9876543210fedcba9876543210".to_string()), mcp_app_resource_uri: None, plugin_id: Some("sample@test".to_string()), }, @@ -1180,6 +1214,14 @@ async fn mcp_tool_call_item_includes_plugin_id() { panic!("expected MCP tool call item"); }; + assert_eq!( + item.connector_id.as_deref(), + Some("asdk_app_0123456789abcdef0123456789abcdef") + ); + assert_eq!( + item.link_id.as_deref(), + Some("link_fedcba9876543210fedcba9876543210") + ); assert_eq!(item.plugin_id.as_deref(), Some("sample@test")); } @@ -1193,6 +1235,7 @@ async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps let metadata = McpToolApprovalMetadata { annotations: None, connector_id: Some("calendar".to_string()), + link_id: None, connector_name: Some("Calendar".to_string()), connector_description: Some("Manage events".to_string()), plugin_id: None, @@ -1661,6 +1704,7 @@ fn guardian_mcp_review_request_includes_annotations_when_present() { let metadata = McpToolApprovalMetadata { annotations: Some(annotations(Some(false), Some(true), Some(true))), connector_id: None, + link_id: None, connector_name: None, connector_description: None, plugin_id: None, @@ -2326,6 +2370,7 @@ async fn approve_mode_skips_when_annotations_do_not_require_approval() { /*open_world*/ None, )), connector_id: None, + link_id: None, connector_name: None, connector_description: None, plugin_id: None, @@ -2400,6 +2445,7 @@ async fn guardian_mode_skips_auto_when_annotations_do_not_require_approval() { /*open_world*/ None, )), connector_id: None, + link_id: None, connector_name: None, connector_description: None, plugin_id: None, @@ -2457,6 +2503,7 @@ async fn permission_request_hook_allows_mcp_tool_call() { /*open_world*/ None, )), connector_id: None, + link_id: None, connector_name: None, connector_description: None, plugin_id: None, @@ -2593,6 +2640,7 @@ async fn permission_request_hook_runs_after_remembered_mcp_approval() { /*open_world*/ None, )), connector_id: None, + link_id: None, connector_name: None, connector_description: None, plugin_id: None, @@ -2680,6 +2728,7 @@ async fn guardian_mode_mcp_denial_returns_rationale_message() { let metadata = McpToolApprovalMetadata { annotations: Some(annotations(Some(false), Some(true), Some(true))), connector_id: None, + link_id: None, connector_name: None, connector_description: None, plugin_id: None, @@ -2734,6 +2783,7 @@ async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval /*open_world*/ None, )), connector_id: None, + link_id: None, connector_name: None, connector_description: None, plugin_id: None, @@ -2789,6 +2839,7 @@ async fn full_access_mode_skips_mcp_tool_approval_for_all_approval_modes() { let metadata = McpToolApprovalMetadata { annotations: Some(annotations(Some(false), Some(true), Some(true))), connector_id: Some("calendar".to_string()), + link_id: None, connector_name: Some("Calendar".to_string()), connector_description: Some("Manage events".to_string()), plugin_id: None, @@ -2842,6 +2893,7 @@ async fn approve_mode_skips_guardian_in_every_permission_mode() { let metadata = McpToolApprovalMetadata { annotations: Some(annotations(Some(false), Some(true), Some(true))), connector_id: Some("calendar".to_string()), + link_id: None, connector_name: Some("Calendar".to_string()), connector_description: Some("Manage events".to_string()), plugin_id: None, diff --git a/codex-rs/core/src/tools/handlers/mcp_resource.rs b/codex-rs/core/src/tools/handlers/mcp_resource.rs index e2f83859b6..2f97c02a42 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource.rs @@ -203,7 +203,9 @@ async fn emit_tool_call_begin( server, tool, arguments: arguments.unwrap_or(Value::Null), + connector_id: None, mcp_app_resource_uri: None, + link_id: None, plugin_id: None, status: McpToolCallStatus::InProgress, result: None, @@ -242,7 +244,9 @@ async fn emit_tool_call_end( server, tool, arguments: arguments.unwrap_or(Value::Null), + connector_id: None, mcp_app_resource_uri: None, + link_id: None, plugin_id: None, status, result, diff --git a/codex-rs/core/tests/common/apps_test_server.rs b/codex-rs/core/tests/common/apps_test_server.rs index 0e7141281c..8a671ea5bc 100644 --- a/codex-rs/core/tests/common/apps_test_server.rs +++ b/codex-rs/core/tests/common/apps_test_server.rs @@ -17,6 +17,7 @@ use wiremock::matchers::path; use wiremock::matchers::path_regex; const CONNECTOR_ID: &str = "calendar"; +pub const LINK_ID: &str = "link_calendar"; const CONNECTOR_NAME: &str = "Calendar"; const DISCOVERABLE_CALENDAR_ID: &str = "connector_2128aebfecb84f64a069897515042a44"; const DISCOVERABLE_GMAIL_ID: &str = "connector_68df038e0ba48191908c8434991bbac2"; @@ -352,6 +353,7 @@ impl Respond for CodexAppsJsonRpcResponder { }, "_meta": { "connector_id": CONNECTOR_ID, + "link_id": LINK_ID, "connector_name": self.connector_name.clone(), "connector_description": self.connector_description.clone(), "openai/outputTemplate": CALENDAR_CREATE_EVENT_MCP_APP_RESOURCE_URI, @@ -378,6 +380,7 @@ impl Respond for CodexAppsJsonRpcResponder { }, "_meta": { "connector_id": CONNECTOR_ID, + "link_id": LINK_ID, "connector_name": self.connector_name.clone(), "connector_description": self.connector_description.clone(), "_codex_apps": { @@ -410,6 +413,7 @@ impl Respond for CodexAppsJsonRpcResponder { }, "_meta": { "connector_id": CONNECTOR_ID, + "link_id": LINK_ID, "connector_name": self.connector_name.clone(), "connector_description": self.connector_description.clone(), "openai/fileParams": ["file"], diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 2c64b5fba2..dde226a194 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -1337,7 +1337,9 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { tool: "image".to_string(), arguments: Some(json!({})), }, + connector_id: None, mcp_app_resource_uri: None, + link_id: None, plugin_id: None, }, ); diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 284ee49b8f..7228547725 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -25,6 +25,7 @@ use core_test_support::apps_test_server::CALENDAR_CREATE_EVENT_MCP_APP_RESOURCE_ use core_test_support::apps_test_server::CALENDAR_CREATE_EVENT_RESOURCE_URI; use core_test_support::apps_test_server::DIRECT_CALENDAR_CREATE_EVENT_TOOL as CALENDAR_CREATE_TOOL; use core_test_support::apps_test_server::DIRECT_CALENDAR_LIST_EVENTS_TOOL as CALENDAR_LIST_TOOL; +use core_test_support::apps_test_server::LINK_ID; use core_test_support::apps_test_server::SEARCH_CALENDAR_APP_ONLY_TOOL; use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_TOOL; use core_test_support::apps_test_server::SEARCH_CALENDAR_LIST_TOOL; @@ -559,10 +560,12 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - unreachable!("event guard guarantees McpToolCallEnd"); }; assert_eq!(end.call_id, "calendar-call-1"); + assert_eq!(end.connector_id.as_deref(), Some("calendar")); assert_eq!( end.mcp_app_resource_uri.as_deref(), Some(CALENDAR_CREATE_EVENT_MCP_APP_RESOURCE_URI) ); + assert_eq!(end.link_id.as_deref(), Some(LINK_ID)); assert_eq!( end.invocation, McpInvocation { diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index f8521071e3..83cd6d3c6b 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -98,6 +98,7 @@ fn mcp_tool_call_result_preserves_meta_in_jsonl_event() { tool: "web_run".to_string(), status: McpToolCallStatus::Completed, arguments: json!({"search_query": [{"q": "OpenAI Codex CLI documentation"}]}), + app_context: None, mcp_app_resource_uri: None, plugin_id: None, result: Some(Box::new(codex_app_server_protocol::McpToolCallResult { diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 0f88670940..1f44ca31a8 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -478,6 +478,7 @@ fn mcp_tool_call_begin_and_end_emit_item_events() { tool: "tool_x".to_string(), status: ApiMcpToolCallStatus::InProgress, arguments: json!({ "key": "value" }), + app_context: None, mcp_app_resource_uri: None, plugin_id: None, result: None, @@ -496,6 +497,7 @@ fn mcp_tool_call_begin_and_end_emit_item_events() { tool: "tool_x".to_string(), status: ApiMcpToolCallStatus::Completed, arguments: json!({ "key": "value" }), + app_context: None, mcp_app_resource_uri: None, plugin_id: None, result: Some(Box::new(McpToolCallResult { @@ -568,6 +570,7 @@ fn mcp_tool_call_failure_sets_failed_status() { tool: "tool_y".to_string(), status: ApiMcpToolCallStatus::Failed, arguments: json!({ "param": 42 }), + app_context: None, mcp_app_resource_uri: None, plugin_id: None, result: None, @@ -617,6 +620,7 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() { tool: "tool_z".to_string(), status: ApiMcpToolCallStatus::InProgress, arguments: serde_json::Value::Null, + app_context: None, mcp_app_resource_uri: None, plugin_id: None, result: None, @@ -635,6 +639,7 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() { tool: "tool_z".to_string(), status: ApiMcpToolCallStatus::Completed, arguments: serde_json::Value::Null, + app_context: None, mcp_app_resource_uri: None, plugin_id: None, result: Some(Box::new(McpToolCallResult { diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index 01f44576ac..08d257dc3b 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -188,9 +188,15 @@ pub struct McpToolCallItem { pub arguments: serde_json::Value, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] + pub connector_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub mcp_app_resource_uri: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] + pub link_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub plugin_id: Option, pub status: McpToolCallStatus, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -546,7 +552,9 @@ impl McpToolCallItem { tool: self.tool.clone(), arguments: (!self.arguments.is_null()).then(|| self.arguments.clone()), }, + connector_id: self.connector_id.clone(), mcp_app_resource_uri: self.mcp_app_resource_uri.clone(), + link_id: self.link_id.clone(), plugin_id: self.plugin_id.clone(), }) } @@ -566,6 +574,8 @@ impl McpToolCallItem { arguments: (!self.arguments.is_null()).then(|| self.arguments.clone()), }, mcp_app_resource_uri: self.mcp_app_resource_uri.clone(), + connector_id: self.connector_id.clone(), + link_id: self.link_id.clone(), plugin_id: self.plugin_id.clone(), duration: self.duration?, result, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index a71eb64fe4..258150aa84 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2324,9 +2324,15 @@ pub struct McpToolCallBeginEvent { pub invocation: McpInvocation, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] + pub connector_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub mcp_app_resource_uri: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] + pub link_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub plugin_id: Option, } @@ -2337,9 +2343,15 @@ pub struct McpToolCallEndEvent { pub invocation: McpInvocation, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] + pub connector_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub mcp_app_resource_uri: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] + pub link_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub plugin_id: Option, #[ts(type = "string")] pub duration: Duration, @@ -4940,7 +4952,9 @@ mod tests { server: "server".into(), tool: "tool".into(), arguments: json!({"arg": "value"}), + connector_id: Some("connector".into()), mcp_app_resource_uri: Some("app://connector".into()), + link_id: Some("link_123".into()), plugin_id: Some("sample@test".into()), status: McpToolCallStatus::InProgress, result: None, @@ -4956,10 +4970,12 @@ mod tests { assert_eq!(event.call_id, "mcp-1"); assert_eq!(event.invocation.server, "server"); assert_eq!(event.invocation.tool, "tool"); + assert_eq!(event.connector_id.as_deref(), Some("connector")); assert_eq!( event.mcp_app_resource_uri.as_deref(), Some("app://connector") ); + assert_eq!(event.link_id.as_deref(), Some("link_123")); assert_eq!(event.plugin_id.as_deref(), Some("sample@test")); } _ => panic!("expected McpToolCallBegin event"), @@ -5047,7 +5063,9 @@ mod tests { server: "server".into(), tool: "tool".into(), arguments: json!({"arg": "value"}), + connector_id: Some("connector".into()), mcp_app_resource_uri: Some("app://connector".into()), + link_id: Some("link_123".into()), plugin_id: Some("sample@test".into()), status: McpToolCallStatus::Completed, result: Some(CallToolResult { @@ -5068,10 +5086,12 @@ mod tests { assert_eq!(event.call_id, "mcp-1"); assert_eq!(event.invocation.server, "server"); assert_eq!(event.invocation.tool, "tool"); + assert_eq!(event.connector_id.as_deref(), Some("connector")); assert_eq!( event.mcp_app_resource_uri.as_deref(), Some("app://connector") ); + assert_eq!(event.link_id.as_deref(), Some("link_123")); assert_eq!(event.plugin_id.as_deref(), Some("sample@test")); assert_eq!(event.duration, Duration::from_millis(42)); assert!(event.is_success()); diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index e6b9d4ede0..f8626dfcc0 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -973,6 +973,7 @@ async fn replayed_in_progress_mcp_tool_call_stays_active() { tool: "copilot".to_string(), status: codex_app_server_protocol::McpToolCallStatus::InProgress, arguments: json!({"action": "wait"}), + app_context: None, mcp_app_resource_uri: None, plugin_id: None, result: None, From ec848dde0e0b264ee6d703a43baeeae624bba98e Mon Sep 17 00:00:00 2001 From: Adrian <145513011+adrian-openai@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:05:27 -0700 Subject: [PATCH 049/112] feat: opt ChatGPT auth into agent identity (#19049) ## Stack This is PR 2 of the simplified HAI single-run-task stack: - [#19047](https://github.com/openai/codex/pull/19047) Agent Identity assertion and task-registration primitives, including the shared run-task helper used by existing Agent Identity JWT auth. - [#19049](https://github.com/openai/codex/pull/19049) Disabled-by-default ChatGPT auth opt-in that provisions/reuses persisted Agent Identity runtime auth and its single run task. - [#19051](https://github.com/openai/codex/pull/19051) Run-scoped provider auth that uses one backend-owned task id for first-party inference and compaction requests. [#19054](https://github.com/openai/codex/pull/19054) collapsed out of the active stack because the simplified design no longer needs a separate background/control-plane task helper. ## Summary This PR adds the disabled-by-default path for normal ChatGPT-login Codex sessions to obtain Agent Identity runtime auth through the Codex backend. Existing Agent Identity JWT startup mode remains a separate path and does not require the feature flag. What changed: - adds the experimental `use_agent_identity` feature flag and config schema entry - adds an explicit `AgentIdentityAuthPolicy` so call sites choose `JwtOnly` or `ChatGptAuth` instead of passing a bare boolean - stores standalone Agent Identity JWT credentials separately from backend-registered Agent Identity records - persists the registered Agent Identity record, private key, and single run task id in `auth.json` so process restarts reuse the same identity - derives the agent/task registration base URL from ChatGPT/Codex auth config while keeping JWT JWKS lookup separate - provisions and caches ChatGPT-derived Agent Identity runtime auth when `use_agent_identity` is enabled - reuses the shared run-task registration helper from PR1 rather than adding a second task-registration path This PR intentionally does not switch model inference over to `AgentAssertion` auth. The provider-auth integration lands in the next PR. ## Testing - `just test -p codex-login` --- codex-rs/Cargo.lock | 1 + .../src/transport/remote_control/tests.rs | 3 + .../remote_control/tests/clients_tests.rs | 2 + .../remote_control/tests/pairing_tests.rs | 2 + .../src/transport/remote_control/websocket.rs | 3 + codex-rs/cli/src/doctor.rs | 4 +- codex-rs/cli/src/main.rs | 9 +- codex-rs/cloud-config/Cargo.toml | 1 + codex-rs/cloud-config/src/bundle_loader.rs | 1 + codex-rs/cloud-config/src/service_tests.rs | 52 ++ codex-rs/cloud-tasks/src/util.rs | 1 + codex-rs/core/config.schema.json | 6 + codex-rs/exec/src/lib.rs | 1 - codex-rs/features/src/lib.rs | 8 + codex-rs/login/src/auth/agent_identity.rs | 422 ++++++++++++-- codex-rs/login/src/auth/auth_tests.rs | 523 +++++++++++++++++- .../login/src/auth/bedrock_api_key_tests.rs | 3 + codex-rs/login/src/auth/manager.rs | 417 +++++++++++--- codex-rs/login/src/auth/storage.rs | 31 +- codex-rs/login/src/auth/storage_tests.rs | 37 +- codex-rs/login/src/lib.rs | 1 + codex-rs/login/tests/suite/auth_refresh.rs | 1 + codex-rs/login/tests/suite/logout.rs | 1 + codex-rs/tui/src/lib.rs | 1 - 24 files changed, 1359 insertions(+), 172 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 0fae5a41bf..55d02cd8b1 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2418,6 +2418,7 @@ version = "0.0.0" dependencies = [ "base64 0.22.1", "chrono", + "codex-agent-identity", "codex-backend-client", "codex-config", "codex-core", diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs index 8e75c0fc8a..6c453e6f9d 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs @@ -1038,6 +1038,7 @@ async fn remote_control_start_allows_missing_auth_when_enabled() { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), ) @@ -1866,6 +1867,7 @@ async fn remote_control_waits_for_account_id_before_enrolling() { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), ) @@ -1961,6 +1963,7 @@ async fn persisted_enable_does_not_follow_auth_to_an_account_without_a_preferenc codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), ) diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs index 630ce8c6b4..6354e435c9 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs @@ -182,6 +182,7 @@ async fn list_remote_control_clients_recovers_auth_after_unauthorized() { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), ) @@ -266,6 +267,7 @@ async fn list_remote_control_clients_retries_unauthorized_only_once() { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), ) diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs index ff146492fb..190526d720 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs @@ -536,6 +536,7 @@ async fn remote_control_handle_recovers_auth_before_refreshing_pairing() { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), ) @@ -804,6 +805,7 @@ async fn remote_control_handle_discards_pairing_response_after_auth_change() { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), ) diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index 3f594a1b4e..239d5d9ea7 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -2211,6 +2211,7 @@ mod tests { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), ) @@ -2308,6 +2309,7 @@ mod tests { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), ) @@ -2430,6 +2432,7 @@ mod tests { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), ) diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index c0f310e700..5e5a1e94be 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -1399,8 +1399,8 @@ fn stored_auth_issues( codex_app_server_protocol::AuthMode::AgentIdentity => { if auth .agent_identity - .as_deref() - .is_none_or(|token| token.trim().is_empty()) + .as_ref() + .is_none_or(|agent_identity| !agent_identity.has_auth_material()) { issues.push("agent identity auth is missing an agent identity token"); } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 8b633265e5..9d278c2122 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1738,12 +1738,9 @@ async fn load_exec_server_remote_auth_provider( let agent_identity_jwt = read_codex_access_token_from_env().ok_or_else(|| { anyhow::anyhow!("CODEX_ACCESS_TOKEN is required when --use-agent-identity-auth is set") })?; - let auth = CodexAuth::from_agent_identity_jwt( - &agent_identity_jwt, - Some(&config.chatgpt_base_url), - /*agent_identity_authapi_base_url_override*/ None, - ) - .await?; + let auth = + CodexAuth::from_agent_identity_jwt(&agent_identity_jwt, Some(&config.chatgpt_base_url)) + .await?; return Ok(codex_model_provider::auth_provider_from_auth(&auth)); } diff --git a/codex-rs/cloud-config/Cargo.toml b/codex-rs/cloud-config/Cargo.toml index 6bf58c8399..363cb21b6f 100644 --- a/codex-rs/cloud-config/Cargo.toml +++ b/codex-rs/cloud-config/Cargo.toml @@ -25,6 +25,7 @@ tokio = { workspace = true, features = ["fs", "rt", "sync", "time"] } tracing = { workspace = true } [dev-dependencies] +codex-agent-identity = { workspace = true } pretty_assertions = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt", "test-util", "time"] } diff --git a/codex-rs/cloud-config/src/bundle_loader.rs b/codex-rs/cloud-config/src/bundle_loader.rs index 2d76d29a69..5ec1ab260f 100644 --- a/codex-rs/cloud-config/src/bundle_loader.rs +++ b/codex-rs/cloud-config/src/bundle_loader.rs @@ -63,6 +63,7 @@ pub async fn cloud_config_bundle_loader_for_storage( codex_home.clone(), enable_codex_api_key_env, credentials_store_mode, + /*forced_chatgpt_workspace_id*/ None, Some(chatgpt_base_url.clone()), keyring_backend_kind, ) diff --git a/codex-rs/cloud-config/src/service_tests.rs b/codex-rs/cloud-config/src/service_tests.rs index d63fef4c54..96fed2f589 100644 --- a/codex-rs/cloud-config/src/service_tests.rs +++ b/codex-rs/cloud-config/src/service_tests.rs @@ -17,6 +17,8 @@ use codex_config::CloudRequirementsFragment; use codex_config::CloudRequirementsTomlBundle; use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthKeyringBackendKind; +use codex_login::auth::AgentIdentityAuth; +use codex_login::auth::AgentIdentityAuthRecord; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::VecDeque; @@ -48,6 +50,7 @@ async fn auth_manager_with_api_key() -> Arc { tmp.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), ) @@ -77,6 +80,7 @@ async fn auth_manager_with_plan_and_identity( tmp.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), ) @@ -88,6 +92,28 @@ async fn auth_manager_with_plan(plan_type: &str) -> Arc { auth_manager_with_plan_and_identity(plan_type, Some("user-12345"), Some("account-12345")).await } +async fn auth_manager_with_agent_identity_business_plan() -> Arc { + let key_material = + codex_agent_identity::generate_agent_key_material().expect("generate agent key material"); + AuthManager::from_auth_for_testing(CodexAuth::AgentIdentity( + AgentIdentityAuth::from_record( + AgentIdentityAuthRecord { + agent_runtime_id: "agent-runtime-123".to_string(), + agent_private_key: key_material.private_key_pkcs8_base64, + account_id: "account-12345".to_string(), + chatgpt_user_id: "user-12345".to_string(), + email: "user@example.com".to_string(), + plan_type: PlanType::Business, + chatgpt_account_is_fedramp: false, + task_id: Some("task-123".to_string()), + }, + "https://auth.openai.com/api/accounts", + ) + .await + .expect("agent identity record should be complete"), + )) +} + fn chatgpt_auth_json( plan_type: &str, chatgpt_user_id: Option<&str>, @@ -408,6 +434,28 @@ async fn get_bundle_allows_eligible_workspace_plans_and_writes_cache() { } } +#[tokio::test] +async fn get_bundle_allows_agent_identity_business_plan() { + let bundle = test_bundle(); + let fetcher = Arc::new(StaticBundleClient::new(bundle.clone())); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager_with_agent_identity_business_plan().await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(Some(bundle))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + assert!( + codex_home + .path() + .join(CLOUD_CONFIG_BUNDLE_CACHE_FILENAME) + .exists() + ); +} + #[tokio::test] async fn get_bundle_skips_team_like_usage_based_plan() { let fetcher = Arc::new(StaticBundleClient::new(test_bundle())); @@ -635,6 +683,7 @@ async fn get_bundle_recovers_after_unauthorized_reload() { auth_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), ) @@ -690,6 +739,7 @@ async fn get_bundle_recovers_after_unauthorized_reload_updates_cache_identity() auth_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), ) @@ -753,6 +803,7 @@ async fn get_bundle_surfaces_auth_recovery_message() { auth_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), ) @@ -818,6 +869,7 @@ async fn get_bundle_unauthorized_without_recovery_uses_generic_message() { auth_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), ) diff --git a/codex-rs/cloud-tasks/src/util.rs b/codex-rs/cloud-tasks/src/util.rs index f683c58a4b..65db955f15 100644 --- a/codex-rs/cloud-tasks/src/util.rs +++ b/codex-rs/cloud-tasks/src/util.rs @@ -49,6 +49,7 @@ pub async fn load_auth_manager(chatgpt_base_url: Option) -> Option anyhow::Result forced_login_method: config.forced_login_method, forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id.clone(), chatgpt_base_url: Some(config.chatgpt_base_url.clone()), - agent_identity_authapi_base_url: None, }) .await { diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 58f291407a..56acb213e5 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -228,6 +228,8 @@ pub enum Feature { PreventIdleSleep, /// Enable remote compaction v2 over the normal Responses API. RemoteCompactionV2, + /// Use Agent Identity for ChatGPT-authenticated sessions. + UseAgentIdentity, /// Enable workspace dependency support. WorkspaceDependencies, @@ -1314,6 +1316,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::UseAgentIdentity, + key: "use_agent_identity", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::WorkspaceDependencies, key: "workspace_dependencies", diff --git a/codex-rs/login/src/auth/agent_identity.rs b/codex-rs/login/src/auth/agent_identity.rs index d4c4f3e513..370af883c0 100644 --- a/codex-rs/login/src/auth/agent_identity.rs +++ b/codex-rs/login/src/auth/agent_identity.rs @@ -1,83 +1,318 @@ +use std::future::Future; use std::sync::Arc; use codex_agent_identity::AgentIdentityKey; +use codex_agent_identity::ChatGptEnvironment; +use codex_agent_identity::build_abom; +use codex_agent_identity::decode_agent_identity_jwt; +use codex_agent_identity::fetch_agent_identity_jwks; +use codex_agent_identity::generate_agent_key_material; +use codex_agent_identity::is_retryable_registration_error; +use codex_agent_identity::public_key_ssh_from_private_key_pkcs8_base64; +use codex_agent_identity::register_agent_identity; use codex_agent_identity::register_agent_task; use codex_protocol::account::PlanType as AccountPlanType; +use codex_protocol::protocol::SessionSource; +use thiserror::Error; use crate::default_client::build_reqwest_client; use super::storage::AgentIdentityAuthRecord; -#[derive(Clone, Debug)] -pub struct AgentIdentityAuth { - inner: Arc, +pub(super) const MAX_AGENT_IDENTITY_BOOTSTRAP_ATTEMPTS: usize = 3; + +pub(super) fn agent_identity_authapi_base_url( + chatgpt_base_url: Option<&str>, +) -> std::io::Result { + let environment = match chatgpt_base_url { + Some(chatgpt_base_url) => ChatGptEnvironment::from_chatgpt_base_url(chatgpt_base_url) + .map_err(std::io::Error::other)?, + None => ChatGptEnvironment::default(), + }; + Ok(environment.agent_identity_authapi_base_url().to_string()) } -#[derive(Debug)] -struct AgentIdentityAuthInner { - record: AgentIdentityAuthRecord, - run_task_id: String, +pub(super) fn require_agent_identity_authapi_base_url( + agent_identity_authapi_base_url: Option<&str>, +) -> std::io::Result<&str> { + agent_identity_authapi_base_url.ok_or_else(|| { + std::io::Error::other( + "Agent Identity only supports production and staging ChatGPT environments", + ) + }) +} + +#[derive(Debug, Error)] +pub enum AgentIdentityAuthError { + #[error( + "agent identity bootstrap unavailable after {attempts} attempts during {operation}: {message}" + )] + BootstrapUnavailable { + operation: &'static str, + attempts: usize, + message: String, + }, +} + +impl AgentIdentityAuthError { + pub fn is_bootstrap_unavailable(error: &std::io::Error) -> bool { + matches!( + error + .get_ref() + .and_then(|source| source.downcast_ref::()), + Some(Self::BootstrapUnavailable { .. }) + ) + } +} + +#[derive(Debug, Error)] +#[error("retryable agent identity registration failure: {message}")] +pub(super) struct RetryableAgentIdentityRegistrationError { + message: String, +} + +impl RetryableAgentIdentityRegistrationError { + pub(super) fn new(message: String) -> Self { + Self { message } + } +} + +#[derive(Clone, Debug)] +pub struct AgentIdentityAuth { + record: Arc, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ManagedChatGptAgentIdentityBinding { + pub(super) account_id: String, + pub(super) chatgpt_user_id: String, + pub(super) email: String, + pub(super) plan_type: AccountPlanType, + pub(super) chatgpt_account_is_fedramp: bool, + pub(super) access_token: String, } impl AgentIdentityAuth { - pub async fn load( - record: AgentIdentityAuthRecord, + pub async fn from_record( + mut record: AgentIdentityAuthRecord, agent_identity_authapi_base_url: &str, ) -> std::io::Result { - let run_task_id = register_agent_task( - &build_reqwest_client(), - agent_identity_authapi_base_url, - key_for_record(&record), - ) - .await - .map_err(std::io::Error::other)?; + public_key_ssh_from_private_key_pkcs8_base64(&record.agent_private_key) + .map_err(std::io::Error::other)?; + if record_needs_task_registration(&record) { + record.task_id = Some( + register_task_for_record_with_retries(&record, agent_identity_authapi_base_url) + .await?, + ); + } Ok(Self { - inner: Arc::new(AgentIdentityAuthInner { - record, - run_task_id, - }), + record: Arc::new(record), }) } + pub async fn from_jwt( + jwt: &str, + chatgpt_base_url: &str, + agent_identity_authapi_base_url: &str, + ) -> std::io::Result { + let record = verified_record_from_jwt(jwt, chatgpt_base_url).await?; + Self::from_record(record, agent_identity_authapi_base_url).await + } + #[cfg(test)] - fn from_initialized_record(record: AgentIdentityAuthRecord, run_task_id: String) -> Self { + fn from_initialized_record(mut record: AgentIdentityAuthRecord, run_task_id: String) -> Self { + record.task_id = Some(run_task_id); Self { - inner: Arc::new(AgentIdentityAuthInner { - record, - run_task_id, - }), + record: Arc::new(record), } } pub fn record(&self) -> &AgentIdentityAuthRecord { - &self.inner.record + self.record.as_ref() } pub fn run_task_id(&self) -> &str { - &self.inner.run_task_id + match self.record.task_id.as_deref() { + Some(task_id) => task_id, + None => unreachable!("AgentIdentityAuth should only be constructed with a task_id"), + } } pub fn account_id(&self) -> &str { - &self.inner.record.account_id + &self.record.account_id } pub fn chatgpt_user_id(&self) -> &str { - &self.inner.record.chatgpt_user_id + &self.record.chatgpt_user_id } pub fn email(&self) -> &str { - &self.inner.record.email + &self.record.email } pub fn plan_type(&self) -> AccountPlanType { - self.inner.record.plan_type + self.record.plan_type } pub fn is_fedramp_account(&self) -> bool { - self.inner.record.chatgpt_account_is_fedramp + self.record.chatgpt_account_is_fedramp } } +pub(super) async fn register_managed_chatgpt_agent_identity( + binding: ManagedChatGptAgentIdentityBinding, + agent_identity_authapi_base_url: &str, + session_source: SessionSource, +) -> std::io::Result { + let key_material = generate_agent_key_material().map_err(std::io::Error::other)?; + let client = build_reqwest_client(); + let runtime_id = retry_registration(|| async { + register_agent_identity( + &client, + agent_identity_authapi_base_url, + &binding.access_token, + binding.chatgpt_account_is_fedramp, + &key_material, + build_abom(session_source.clone()), + vec!["responsesapi".to_string()], + ) + .await + .map_err(|err| { + if is_retryable_registration_error(&err) { + std::io::Error::other(RetryableAgentIdentityRegistrationError::new( + err.to_string(), + )) + } else { + std::io::Error::other(err) + } + }) + }) + .await + .map_err(|err| classify_bootstrap_error("agent identity registration", err))?; + + let record = AgentIdentityAuthRecord { + agent_runtime_id: runtime_id, + agent_private_key: key_material.private_key_pkcs8_base64, + account_id: binding.account_id, + chatgpt_user_id: binding.chatgpt_user_id, + email: binding.email, + plan_type: binding.plan_type, + chatgpt_account_is_fedramp: binding.chatgpt_account_is_fedramp, + task_id: None, + }; + AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url) + .await + .map_err(|err| classify_bootstrap_error("agent task registration", err)) +} + +pub(super) async fn verified_record_from_jwt( + jwt: &str, + chatgpt_base_url: &str, +) -> std::io::Result { + AgentIdentityAuthRecord::from_agent_identity_jwt(jwt)?; + let jwks = fetch_agent_identity_jwks(&build_reqwest_client(), chatgpt_base_url) + .await + .map_err(std::io::Error::other)?; + let claims = decode_agent_identity_jwt(jwt, Some(&jwks)).map_err(std::io::Error::other)?; + Ok(claims.into()) +} + +pub(super) fn record_needs_task_registration(record: &AgentIdentityAuthRecord) -> bool { + record + .task_id + .as_deref() + .is_none_or(|task_id| task_id.trim().is_empty()) +} + +pub(super) fn record_matches_managed_chatgpt_binding( + record: &AgentIdentityAuthRecord, + binding: &ManagedChatGptAgentIdentityBinding, +) -> bool { + record.account_id == binding.account_id + && record.chatgpt_user_id == binding.chatgpt_user_id + && public_key_ssh_from_private_key_pkcs8_base64(&record.agent_private_key).is_ok() +} + +pub(super) fn classify_bootstrap_error( + operation: &'static str, + err: std::io::Error, +) -> std::io::Error { + if is_retryable_io_registration_error(&err) { + std::io::Error::other(AgentIdentityAuthError::BootstrapUnavailable { + operation, + attempts: MAX_AGENT_IDENTITY_BOOTSTRAP_ATTEMPTS, + message: err.to_string(), + }) + } else { + err + } +} + +pub(super) fn is_retryable_io_registration_error(err: &std::io::Error) -> bool { + err.get_ref().is_some_and( + ::is::< + RetryableAgentIdentityRegistrationError, + >, + ) +} + +pub(super) async fn retry_registration(mut operation: F) -> std::io::Result +where + F: FnMut() -> Fut, + Fut: Future>, +{ + let mut attempt = 1; + loop { + match operation().await { + Ok(value) => return Ok(value), + Err(err) + if attempt < MAX_AGENT_IDENTITY_BOOTSTRAP_ATTEMPTS + && is_retryable_io_registration_error(&err) => + { + tracing::warn!( + attempt, + max_attempts = MAX_AGENT_IDENTITY_BOOTSTRAP_ATTEMPTS, + error = %err, + "agent identity registration attempt failed; retrying" + ); + attempt += 1; + } + Err(err) => return Err(err), + } + } +} + +async fn register_task_for_record_with_retries( + record: &AgentIdentityAuthRecord, + agent_identity_authapi_base_url: &str, +) -> std::io::Result { + retry_registration(|| async { + register_task_for_record(record, agent_identity_authapi_base_url).await + }) + .await +} + +async fn register_task_for_record( + record: &AgentIdentityAuthRecord, + agent_identity_authapi_base_url: &str, +) -> std::io::Result { + register_agent_task( + &build_reqwest_client(), + agent_identity_authapi_base_url, + key_for_record(record), + ) + .await + .map_err(|err| { + if is_retryable_registration_error(&err) { + std::io::Error::other(RetryableAgentIdentityRegistrationError::new( + err.to_string(), + )) + } else { + std::io::Error::other(err) + } + }) +} + fn key_for_record(record: &AgentIdentityAuthRecord) -> AgentIdentityKey<'_> { AgentIdentityKey { agent_runtime_id: &record.agent_runtime_id, @@ -111,6 +346,7 @@ mod tests { email: "agent@example.com".to_string(), plan_type: AccountPlanType::Plus, chatgpt_account_is_fedramp: false, + task_id: None, } } @@ -120,7 +356,7 @@ mod tests { } #[tokio::test] - async fn load_registers_run_task() -> anyhow::Result<()> { + async fn from_record_registers_task() -> anyhow::Result<()> { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/v1/agent/agent-runtime-1/task/register")) @@ -131,9 +367,11 @@ mod tests { .mount(&server) .await; - let auth = - AgentIdentityAuth::load(agent_identity_record_with_generated_key(), &server.uri()) - .await?; + let auth = AgentIdentityAuth::from_record( + agent_identity_record_with_generated_key(), + &server.uri(), + ) + .await?; assert_eq!(auth.run_task_id(), "task-run-1"); let requests = server @@ -152,6 +390,38 @@ mod tests { Ok(()) } + #[tokio::test] + async fn from_jwt_registers_task() -> anyhow::Result<()> { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/agent-identities/jwks")) + .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body())) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/v1/agent/agent-runtime-1/task/register")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "task_id": "task-run-1", + }))) + .expect(1) + .mount(&server) + .await; + + let record = agent_identity_record_with_generated_key(); + let jwt = signed_agent_identity_jwt(&record)?; + let auth = AgentIdentityAuth::from_jwt( + &jwt, + &format!("{}/backend-api", server.uri()), + &server.uri(), + ) + .await?; + + assert_eq!(auth.record().agent_runtime_id, "agent-runtime-1"); + assert_eq!(auth.run_task_id(), "task-run-1"); + Ok(()) + } + #[test] fn run_task_is_shared_across_clones() { let auth = AgentIdentityAuth::from_initialized_record( @@ -160,12 +430,12 @@ mod tests { ); let cloned = auth.clone(); - assert!(Arc::ptr_eq(&auth.inner, &cloned.inner)); + assert!(Arc::ptr_eq(&auth.record, &cloned.record)); assert_eq!(cloned.run_task_id(), "task-run-1"); } #[tokio::test] - async fn failed_run_task_registration_can_be_retried_on_next_call() -> anyhow::Result<()> { + async fn from_record_retries_transient_registration() -> anyhow::Result<()> { let server = MockServer::start().await; let request_count = Arc::new(AtomicUsize::new(0)); let response_count = Arc::clone(&request_count); @@ -183,14 +453,80 @@ mod tests { .expect(2) .mount(&server) .await; - let record = agent_identity_record_with_generated_key(); - AgentIdentityAuth::load(record.clone(), &server.uri()) - .await - .expect_err("first registration should fail"); - let auth = AgentIdentityAuth::load(record, &server.uri()).await?; + let auth = AgentIdentityAuth::from_record( + agent_identity_record_with_generated_key(), + &server.uri(), + ) + .await?; assert_eq!(request_count.load(Ordering::SeqCst), 2); assert_eq!(auth.run_task_id(), "task-run-1"); Ok(()) } + + fn signed_agent_identity_jwt( + record: &AgentIdentityAuthRecord, + ) -> jsonwebtoken::errors::Result { + let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256); + header.kid = Some("test-key".to_string()); + jsonwebtoken::encode( + &header, + &json!({ + "iss": "https://chatgpt.com/codex-backend/agent-identity", + "aud": "codex-app-server", + "iat": 1_700_000_000usize, + "exp": 4_000_000_000usize, + "agent_runtime_id": record.agent_runtime_id, + "agent_private_key": record.agent_private_key, + "account_id": record.account_id, + "chatgpt_user_id": record.chatgpt_user_id, + "email": record.email, + "plan_type": record.plan_type, + "chatgpt_account_is_fedramp": record.chatgpt_account_is_fedramp, + }), + &jsonwebtoken::EncodingKey::from_rsa_pem(TEST_AGENT_IDENTITY_RSA_PRIVATE_KEY_PEM)?, + ) + } + + fn test_jwks_body() -> serde_json::Value { + json!({ + "keys": [{ + "kty": "RSA", + "kid": "test-key", + "use": "sig", + "alg": "RS256", + "n": "1qQF2MqTrGAMDm7wXbjJP5sWqGA83tAGUs2ksy7iJXLJdhCg4AtwGm4SFl4f6kxhCSzlN1QdXuZjvRT2wZZiGUi9xUE28rf4WLrTxSnwqLuTy5knMP08yC0t_0YU_FGPZMcWb14hG05IvZr8UbmRaVagxSR8H4rSIymRoVwwmFSrqz068XrWGSYNIfLEASyo5GdAaqmk1JALINHgYGQJVxMxtwcvDxoVKmC7eltUNymMNBZhsv4E8sx9YNLpBoEibznfEpDU_DGzrM5eZCsQzaqbhBOlGd427ifud_Nnd9cPqzgCUc23-0FXSPfpbgksCXAwAmD0OFjQWrgqVdKL6Q", + "e": "AQAB", + }] + }) + } + + const TEST_AGENT_IDENTITY_RSA_PRIVATE_KEY_PEM: &[u8] = br#"-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDWpAXYypOsYAwO +bvBduMk/mxaoYDze0AZSzaSzLuIlcsl2EKDgC3AabhIWXh/qTGEJLOU3VB1e5mO9 +FPbBlmIZSL3FQTbyt/hYutPFKfCou5PLmScw/TzILS3/RhT8UY9kxxZvXiEbTki9 +mvxRuZFpVqDFJHwfitIjKZGhXDCYVKurPTrxetYZJg0h8sQBLKjkZ0BqqaTUkAsg +0eBgZAlXEzG3By8PGhUqYLt6W1Q3KYw0FmGy/gTyzH1g0ukGgSJvOd8SkNT8MbOs +zl5kKxDNqpuEE6UZ3jbuJ+5382d31w+rOAJRzbf7QVdI9+luCSwJcDACYPQ4WNBa +uCpV0ovpAgMBAAECggEAVu84LwZdqYN9XpswX8VoPYrjMm9IODapWQBRpQFoNyK2 +1ksF3bjEPvA2Azk8U/l7k+vLKw22l6lY3EyRZPcz5GnB8xLm3ogE3mtNOp4yCyVu +RxhQ91aaN7mU17/a4BdorLi2LYVCg3zBmYociD1Q2AluNGsCmwPu+K7tfR2J0Sg8 +NjqiTbDG1XDpR/icwgC9t6vh8lZpCHDhF4tbQfLLVLeA/OdcuzXDyMCXbmdVIdBQ +rm4aIFmr2e1/2ctTbCg85S6AGFTH+pSLjrwTzyvf+F6NW5uNjLQAQLFj+EznBDxj +Xdx90cySrjsKK6PVWQF4RiTvkSW8eWL7R6B2FZbGwQKBgQDuVQRj72hWloR7mbEL +aUEEv3pIXTMXWEsoMBNczos/1L1RnAN1AI44TurznasPZAWvQj+kVbLDR+TAeZrL +iA8HIWswQUI18hFmgKzSkwIXGtubcKVrgsKeS4lMDKCM/Ef6WAYdeq6ronoY5lCN +YrJFmGp81W5zcV7lyiycgbSiGwKBgQDmjWYf6pZjrK7Z+OJ3X1AZfi2vss15SCvL +3fPgzIDbViztpGyQhc3DQZIsBNIu0xZp/veGce9TEeTds2ro9NfdJFeou8+fC7Pq +sOsM3amGFFi+ZW/9BWyjZEM88bgWWAjqLHbpfHDxjAf5CSxddqxgHlbP0Ytyb1Vg +gmPDn9YKSwKBgQDbTi3hC35WFuDHn0/zcSHcDZmnFuOZeqyFyV83yfMGhGrEuqvP +sPgtRikajJ3IZsB4WZyYSidZXEFY/0z6NjOl2xF38MTNQPbT/FmK1q1Yt2UWrlv5 +BvSwlk87RG9D7C0LZo4R+D7cPoDdgqjiwMvMEIkEX5zn641oI1ZTmWKuuwKBgQCD +KF+3unnRvHRAVoFnTZbA2fJdqMeRvogD04GhGlYX8V9f1hFY6nXTJaNlXVzA/J8c +r8ra9kgjJuPfZ+ljG58OFFW2DRohLcQtuHYPfK6rMzoFHqnl9EcIcMp7ijuionR3 +29HOJFgQYgxLFXfit9d6WugiE+BTupiEbckZif13HwKBgE/lAlkVHP6YahOO2Ljc +J1bwkqKZTB5dHolX9A58e/xXnfZ5P8f3Z83+Izap3FwqQulk7b1WO1MQcHuVg2NN +5da0D4h2rYOXnbYIg0BVu4spQbaM6ewsp66b8+MzLOBvj8SzWdt1Oyw0q/MRyQAR +8U4M2TSWCKUY/A6sT4W8+mT9 +-----END PRIVATE KEY-----"#; } diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index aa1dcd9c86..b52f03b706 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -6,6 +6,7 @@ use codex_app_server_protocol::AuthMode; use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::auth::KnownPlan as InternalKnownPlan; use codex_protocol::auth::PlanType as InternalPlanType; +use codex_protocol::protocol::SessionSource; use base64::Engine; use codex_protocol::config_types::ForcedLoginMethod; @@ -14,11 +15,14 @@ use pretty_assertions::assert_eq; use serde::Serialize; use serde_json::json; use std::sync::Arc; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; use tempfile::TempDir; use tempfile::tempdir; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; +use wiremock::matchers::body_partial_json; use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; @@ -95,7 +99,7 @@ fn login_with_api_key_overwrites_existing_auth_json() { } #[tokio::test] -async fn login_with_access_token_writes_only_token() { +async fn login_with_access_token_writes_agent_identity_jwt() { let dir = tempdir().unwrap(); let auth_path = dir.path().join("auth.json"); let record = agent_identity_record(WORKSPACE_ID_ALLOWED); @@ -108,7 +112,8 @@ async fn login_with_access_token_writes_only_token() { .expect(1) .mount(&server) .await; - let chatgpt_base_url = format!("{}/backend-api", server.uri()); + let authapi_base_url = server.uri(); + let chatgpt_base_url = format!("{authapi_base_url}/backend-api"); super::login_with_access_token( dir.path(), @@ -127,14 +132,75 @@ async fn login_with_access_token_writes_only_token() { .expect("auth.json should parse"); assert_eq!(auth.auth_mode, Some(AuthMode::AgentIdentity)); assert_eq!( - auth.agent_identity.as_deref(), - Some(agent_identity.as_str()) + auth.agent_identity, + Some(AgentIdentityStorage::Jwt(agent_identity)) ); assert!(auth.tokens.is_none(), "tokens should be cleared"); assert!(auth.openai_api_key.is_none(), "API key should be cleared"); server.verify().await; } +#[tokio::test] +#[serial(codex_auth_env)] +async fn stored_agent_identity_jwt_keeps_auth_json_unchanged() -> anyhow::Result<()> { + let _access_token_guard = remove_access_token_env_var(); + let codex_home = tempdir()?; + let record = agent_identity_record(WORKSPACE_ID_ALLOWED); + let agent_identity = + signed_agent_identity_jwt(&record, json!(record.plan_type)).expect("signed agent identity"); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/agent-identities/jwks")) + .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body())) + .expect(1) + .mount(&server) + .await; + mock_agent_task_registration(&server, "", &record.agent_runtime_id, "task-id").await; + let authapi_base_url = server.uri(); + let chatgpt_base_url = format!("{authapi_base_url}/backend-api"); + save_auth( + codex_home.path(), + &AuthDotJson { + auth_mode: Some(ApiAuthMode::AgentIdentity), + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: Some(AgentIdentityStorage::Jwt(agent_identity.clone())), + personal_access_token: None, + bedrock_api_key: None, + }, + AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::Direct, + )?; + + let auth = super::load_auth( + codex_home.path(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, + Some(&chatgpt_base_url), + AuthKeyringBackendKind::Direct, + Some(&authapi_base_url), + ) + .await? + .expect("auth should load"); + + let CodexAuth::AgentIdentity(agent_identity_auth) = auth else { + panic!("stored JWT should load as agent identity auth"); + }; + assert_eq!(agent_identity_auth.run_task_id(), "task-id"); + let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); + let auth = storage + .try_read_auth_json(&get_auth_file(codex_home.path())) + .expect("auth.json should parse"); + assert_eq!( + auth.agent_identity, + Some(AgentIdentityStorage::Jwt(agent_identity)) + ); + server.verify().await; + Ok(()) +} + #[tokio::test] #[serial(codex_auth_env)] async fn login_with_access_token_writes_only_personal_access_token() { @@ -278,6 +344,387 @@ async fn login_with_access_token_rejects_invalid_jwt() { ); } +#[tokio::test] +#[serial(codex_auth_env)] +async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result<()> { + let codex_home = tempdir()?; + write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: Some("pro".to_string()), + chatgpt_account_id: Some("account-123".to_string()), + }, + codex_home.path(), + )?; + let auth = super::load_auth( + codex_home.path(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, + /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, + ) + .await? + .expect("auth should load"); + + assert!( + auth.agent_identity_auth( + AgentIdentityAuthPolicy::JwtOnly, + /*agent_identity_authapi_base_url*/ None, + /*forced_chatgpt_workspace_id*/ None, + SessionSource::Cli, + ) + .await? + .is_none() + ); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/agent/register")) + .and(header("authorization", "Bearer test-access-token")) + .and(body_partial_json(json!({ + "abom": { + "agent_harness_id": "codex-cli", + }, + "capabilities": ["responsesapi"], + "ttl": null, + }))) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({ + "agent_runtime_id": "agent-runtime-123", + }))) + .expect(/*r*/ 1) + .mount(&server) + .await; + mock_agent_task_registration(&server, "", "agent-runtime-123", "task-123").await; + + let agent_auth = auth + .agent_identity_auth( + AgentIdentityAuthPolicy::ChatGptAuth, + Some(&server.uri()), + /*forced_chatgpt_workspace_id*/ None, + SessionSource::Cli, + ) + .await? + .expect("agent identity should register"); + let reused = auth + .agent_identity_auth( + AgentIdentityAuthPolicy::ChatGptAuth, + Some(&server.uri()), + /*forced_chatgpt_workspace_id*/ None, + SessionSource::Cli, + ) + .await? + .expect("agent identity should be reused"); + + assert_eq!( + agent_auth.record().agent_runtime_id, + reused.record().agent_runtime_id + ); + assert_eq!(agent_auth.run_task_id(), "task-123"); + assert_eq!(reused.run_task_id(), "task-123"); + assert_eq!(agent_auth.record().agent_runtime_id, "agent-runtime-123"); + assert_eq!(agent_auth.record().account_id, "account-123"); + assert_eq!(agent_auth.record().chatgpt_user_id, "user-12345"); + assert_eq!(agent_auth.record().task_id.as_deref(), Some("task-123")); + assert_eq!(reused.record().task_id.as_deref(), Some("task-123")); + let persisted = auth + .stored_managed_chatgpt_agent_identity_record("account-123") + .expect("identity should persist"); + assert_eq!(persisted.agent_runtime_id, "agent-runtime-123"); + assert_eq!(persisted.task_id.as_deref(), Some("task-123")); + + let reloaded = super::load_auth( + codex_home.path(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, + /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, + ) + .await? + .expect("auth should reload"); + let reloaded_agent_auth = reloaded + .agent_identity_auth( + AgentIdentityAuthPolicy::ChatGptAuth, + Some(&server.uri()), + /*forced_chatgpt_workspace_id*/ None, + SessionSource::Cli, + ) + .await? + .expect("agent identity should reload from storage"); + assert_eq!( + reloaded_agent_auth.record().agent_runtime_id, + "agent-runtime-123" + ); + assert_eq!(reloaded_agent_auth.run_task_id(), "task-123"); + Ok(()) +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn chatgpt_auth_retries_transient_agent_identity_registration() -> anyhow::Result<()> { + let codex_home = tempdir()?; + write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: Some("pro".to_string()), + chatgpt_account_id: Some("account-123".to_string()), + }, + codex_home.path(), + )?; + let auth = super::load_auth( + codex_home.path(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, + /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, + ) + .await? + .expect("auth should load"); + + let server = MockServer::start().await; + let registration_count = Arc::new(AtomicUsize::new(0)); + let response_count = Arc::clone(®istration_count); + Mock::given(method("POST")) + .and(path("/v1/agent/register")) + .respond_with(move |_request: &wiremock::Request| { + if response_count.fetch_add(1, Ordering::SeqCst) < 2 { + ResponseTemplate::new(/*status*/ 503) + } else { + ResponseTemplate::new(/*status*/ 200).set_body_json(json!({ + "agent_runtime_id": "agent-runtime-123", + })) + } + }) + .expect(/*requests*/ 3) + .mount(&server) + .await; + mock_agent_task_registration(&server, "", "agent-runtime-123", "task-123").await; + + let agent_auth = auth + .agent_identity_auth( + AgentIdentityAuthPolicy::ChatGptAuth, + Some(&server.uri()), + /*forced_chatgpt_workspace_id*/ None, + SessionSource::Cli, + ) + .await? + .expect("agent identity should register after retries"); + + assert_eq!(registration_count.load(Ordering::SeqCst), 3); + assert_eq!(agent_auth.record().agent_runtime_id, "agent-runtime-123"); + assert_eq!(agent_auth.record().task_id.as_deref(), Some("task-123")); + assert_eq!( + auth.stored_managed_chatgpt_agent_identity_record("account-123") + .and_then(|record| record.task_id), + Some("task-123".to_string()) + ); + Ok(()) +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn chatgpt_auth_registration_retry_exhaustion_is_fallback_eligible() -> anyhow::Result<()> { + let codex_home = tempdir()?; + write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: Some("pro".to_string()), + chatgpt_account_id: Some("account-123".to_string()), + }, + codex_home.path(), + )?; + let auth = super::load_auth( + codex_home.path(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, + /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, + ) + .await? + .expect("auth should load"); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/agent/register")) + .respond_with(ResponseTemplate::new(/*status*/ 503)) + .expect(/*requests*/ 3) + .mount(&server) + .await; + + let err = auth + .agent_identity_auth( + AgentIdentityAuthPolicy::ChatGptAuth, + Some(&server.uri()), + /*forced_chatgpt_workspace_id*/ None, + SessionSource::Cli, + ) + .await + .expect_err("retry exhaustion should return an error"); + + assert!(AgentIdentityAuthError::is_bootstrap_unavailable(&err)); + assert!( + auth.stored_managed_chatgpt_agent_identity_record("account-123") + .is_none() + ); + Ok(()) +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn chatgpt_auth_task_registration_retry_exhaustion_is_fallback_eligible() -> anyhow::Result<()> +{ + let codex_home = tempdir()?; + write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: Some("pro".to_string()), + chatgpt_account_id: Some("account-123".to_string()), + }, + codex_home.path(), + )?; + let mut record = agent_identity_record("account-123"); + record.chatgpt_user_id = "user-12345".to_string(); + record.email = "user@example.com".to_string(); + let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); + let auth_path = get_auth_file(codex_home.path()); + let mut auth_json = storage.try_read_auth_json(&auth_path)?; + auth_json.agent_identity = Some(AgentIdentityStorage::Record(record.clone())); + storage.save(&auth_json)?; + let auth = super::load_auth( + codex_home.path(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, + /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, + ) + .await? + .expect("auth should load"); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!( + "/v1/agent/{}/task/register", + record.agent_runtime_id + ))) + .respond_with(ResponseTemplate::new(/*status*/ 503)) + .expect(/*requests*/ 3) + .mount(&server) + .await; + + let err = auth + .agent_identity_auth( + AgentIdentityAuthPolicy::ChatGptAuth, + Some(&server.uri()), + /*forced_chatgpt_workspace_id*/ None, + SessionSource::Cli, + ) + .await + .expect_err("task retry exhaustion should return an error"); + + assert!(AgentIdentityAuthError::is_bootstrap_unavailable(&err)); + record.task_id = None; + assert_eq!( + auth.stored_managed_chatgpt_agent_identity_record("account-123"), + Some(record) + ); + Ok(()) +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn chatgpt_auth_non_retryable_registration_error_is_hard_failure() -> anyhow::Result<()> { + let codex_home = tempdir()?; + write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: Some("pro".to_string()), + chatgpt_account_id: Some("account-123".to_string()), + }, + codex_home.path(), + )?; + let auth = super::load_auth( + codex_home.path(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, + /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, + ) + .await? + .expect("auth should load"); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/agent/register")) + .respond_with(ResponseTemplate::new(/*status*/ 403)) + .expect(/*requests*/ 1) + .mount(&server) + .await; + + let err = auth + .agent_identity_auth( + AgentIdentityAuthPolicy::ChatGptAuth, + Some(&server.uri()), + /*forced_chatgpt_workspace_id*/ None, + SessionSource::Cli, + ) + .await + .expect_err("hard registration failure should return an error"); + + assert!(!AgentIdentityAuthError::is_bootstrap_unavailable(&err)); + assert!( + auth.stored_managed_chatgpt_agent_identity_record("account-123") + .is_none() + ); + Ok(()) +} + +#[tokio::test] +async fn agent_identity_jwt_task_registration_retry_exhaustion_is_strict() -> anyhow::Result<()> { + let record = agent_identity_record(WORKSPACE_ID_ALLOWED); + let agent_identity = + signed_agent_identity_jwt(&record, json!(record.plan_type)).expect("signed agent identity"); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/agent-identities/jwks")) + .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body())) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path(format!( + "/v1/agent/{}/task/register", + record.agent_runtime_id + ))) + .respond_with(ResponseTemplate::new(/*status*/ 503)) + .expect(/*requests*/ 3) + .mount(&server) + .await; + let authapi_base_url = server.uri(); + let chatgpt_base_url = format!("{authapi_base_url}/backend-api"); + + let err = CodexAuth::from_agent_identity_jwt_with_authapi_base_url( + &agent_identity, + Some(&chatgpt_base_url), + &authapi_base_url, + ) + .await + .expect_err("agent identity jwt task retry exhaustion should fail"); + + assert!(!AgentIdentityAuthError::is_bootstrap_unavailable(&err)); + Ok(()) +} + #[tokio::test] async fn login_with_access_token_rejects_unsigned_jwt() { let dir = tempdir().unwrap(); @@ -290,7 +737,8 @@ async fn login_with_access_token_rejects_unsigned_jwt() { .expect(1) .mount(&server) .await; - let chatgpt_base_url = format!("{}/backend-api", server.uri()); + let authapi_base_url = server.uri(); + let chatgpt_base_url = format!("{authapi_base_url}/backend-api"); super::login_with_access_token( dir.path(), @@ -457,6 +905,7 @@ async fn unauthorized_recovery_reports_mode_and_step_names() { dir.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), ) @@ -828,7 +1277,6 @@ async fn build_config( forced_login_method, forced_chatgpt_workspace_id, chatgpt_base_url: None, - agent_identity_authapi_base_url: None, } } @@ -879,7 +1327,7 @@ fn remove_access_token_env_var() -> EnvVarGuard { #[serial(codex_auth_env)] async fn load_auth_reads_access_token_from_env() { let codex_home = tempdir().unwrap(); - let expected_record = agent_identity_record(WORKSPACE_ID_ALLOWED); + let mut expected_record = agent_identity_record(WORKSPACE_ID_ALLOWED); let agent_identity = signed_agent_identity_jwt(&expected_record, json!(expected_record.plan_type)) .expect("signed agent identity"); @@ -898,6 +1346,7 @@ async fn load_auth_reads_access_token_from_env() { .expect(1) .mount(&server) .await; + expected_record.task_id = Some("task-123".to_string()); let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, &agent_identity); let authapi_base_url = server.uri(); @@ -1003,11 +1452,10 @@ async fn auth_manager_rejects_env_personal_access_token_workspace_mismatch() { let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, "at-env-workspace-mismatch"); - let manager = AuthManager::new_with_workspace_restriction( + let manager = AuthManager::new( codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, - /*forced_chatgpt_workspace_id*/ Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), @@ -1054,11 +1502,10 @@ async fn auth_manager_rejects_stored_personal_access_token_workspace_mismatch() .await .expect("personal access token login should succeed"); - let manager = AuthManager::new_with_workspace_restriction( + let manager = AuthManager::new( codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, auth_credentials_store_mode, - /*forced_chatgpt_workspace_id*/ Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), @@ -1092,6 +1539,7 @@ async fn personal_access_token_does_not_offer_unauthorized_recovery() { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), ) @@ -1233,7 +1681,6 @@ async fn enforce_login_restrictions_logs_out_for_personal_access_token_workspace forced_login_method: None, forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), chatgpt_base_url: None, - agent_identity_authapi_base_url: None, }; let err = super::enforce_login_restrictions(&config) @@ -1341,7 +1788,7 @@ async fn enforce_login_restrictions_logs_out_for_agent_identity_workspace_mismat openai_api_key: None, tokens: None, last_refresh: None, - agent_identity: Some(agent_identity), + agent_identity: Some(AgentIdentityStorage::Jwt(agent_identity)), personal_access_token: None, bedrock_api_key: None, }, @@ -1357,15 +1804,21 @@ async fn enforce_login_restrictions_logs_out_for_agent_identity_workspace_mismat forced_login_method: None, forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), chatgpt_base_url: Some(chatgpt_base_url), - agent_identity_authapi_base_url: Some(authapi_base_url), }; - let err = super::enforce_login_restrictions(&config) - .await - .expect_err("expected workspace mismatch to error"); - assert!(err.to_string().contains(&format!( - "current credentials belong to {WORKSPACE_ID_DISALLOWED}" - ))); + let err = super::enforce_login_restrictions_with_agent_identity_authapi_base_url( + &config, + Some(&authapi_base_url), + ) + .await + .expect_err("expected workspace mismatch to error"); + let message = err.to_string(); + assert!( + message.contains(&format!( + "current credentials belong to {WORKSPACE_ID_DISALLOWED}" + )), + "{message}" + ); assert!( !codex_home.path().join("auth.json").exists(), "auth.json should be removed on mismatch" @@ -1437,9 +1890,28 @@ fn agent_identity_record(account_id: &str) -> AgentIdentityAuthRecord { email: "user@example.com".to_string(), plan_type: AccountPlanType::Pro, chatgpt_account_is_fedramp: false, + task_id: None, } } +async fn mock_agent_task_registration( + server: &MockServer, + path_prefix: &str, + agent_runtime_id: &str, + task_id: &str, +) { + Mock::given(method("POST")) + .and(path(format!( + "{path_prefix}/v1/agent/{agent_runtime_id}/task/register" + ))) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({ + "task_id": task_id, + }))) + .expect(/*r*/ 1) + .mount(server) + .await; +} + fn fake_agent_identity_jwt(record: &AgentIdentityAuthRecord) -> std::io::Result { fake_agent_identity_jwt_with_plan_type(record, serde_json::to_value(record.plan_type)?) } @@ -1580,10 +2052,13 @@ async fn assert_agent_identity_plan_alias( .await; let authapi_base_url = server.uri(); let chatgpt_base_url = format!("{authapi_base_url}/backend-api"); - let auth = - CodexAuth::from_agent_identity_jwt(&jwt, Some(&chatgpt_base_url), Some(&authapi_base_url)) - .await - .expect("agent identity auth"); + let auth = CodexAuth::from_agent_identity_jwt_with_authapi_base_url( + &jwt, + Some(&chatgpt_base_url), + &authapi_base_url, + ) + .await + .expect("agent identity auth"); pretty_assertions::assert_eq!(auth.account_plan_type(), Some(expected_plan_type)); server.verify().await; diff --git a/codex-rs/login/src/auth/bedrock_api_key_tests.rs b/codex-rs/login/src/auth/bedrock_api_key_tests.rs index 35ace9fea8..691d8bbc5d 100644 --- a/codex-rs/login/src/auth/bedrock_api_key_tests.rs +++ b/codex-rs/login/src/auth/bedrock_api_key_tests.rs @@ -60,6 +60,7 @@ async fn login_with_bedrock_api_key_replaces_openai_auth() -> anyhow::Result<()> codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), ) @@ -107,6 +108,7 @@ async fn logout_removes_bedrock_auth() -> anyhow::Result<()> { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), ) @@ -130,6 +132,7 @@ async fn bedrock_only_auth_storage_creates_primary_auth() -> anyhow::Result<()> codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), ) diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index bb548ffe78..138bdad5b9 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -20,8 +20,6 @@ use tokio::sync::watch; use tracing::instrument; use codex_agent_identity::ChatGptEnvironment; -use codex_agent_identity::decode_agent_identity_jwt; -use codex_agent_identity::fetch_agent_identity_jwks; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::AuthMode as ApiAuthMode; use codex_protocol::config_types::ForcedLoginMethod; @@ -29,18 +27,27 @@ use codex_protocol::config_types::ModelProviderAuthInfo; use super::access_token::CodexAccessToken; use super::access_token::classify_codex_access_token; +use super::agent_identity::ManagedChatGptAgentIdentityBinding; +use super::agent_identity::agent_identity_authapi_base_url; +use super::agent_identity::classify_bootstrap_error; +use super::agent_identity::record_matches_managed_chatgpt_binding; +use super::agent_identity::record_needs_task_registration; +use super::agent_identity::register_managed_chatgpt_agent_identity; +use super::agent_identity::require_agent_identity_authapi_base_url; +use super::agent_identity::verified_record_from_jwt; use super::external_bearer::BearerTokenRefresher; use super::revoke::revoke_auth_tokens; pub use crate::auth::agent_identity::AgentIdentityAuth; +pub use crate::auth::agent_identity::AgentIdentityAuthError; pub use crate::auth::bedrock_api_key::BedrockApiKeyAuth; pub use crate::auth::personal_access_token::PersonalAccessTokenAuth; pub use crate::auth::storage::AgentIdentityAuthRecord; +pub use crate::auth::storage::AgentIdentityStorage; pub use crate::auth::storage::AuthDotJson; pub use crate::auth::storage::AuthKeyringBackendKind; use crate::auth::storage::AuthStorageBackend; use crate::auth::storage::create_auth_storage; use crate::auth::util::try_parse_error_message; -use crate::default_client::build_reqwest_client; use crate::default_client::create_client; use crate::token_data::TokenData; use crate::token_data::parse_chatgpt_jwt_claims; @@ -51,6 +58,7 @@ use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::auth::PlanType as InternalPlanType; use codex_protocol::auth::RefreshTokenFailedError; use codex_protocol::auth::RefreshTokenFailedReason; +use codex_protocol::protocol::SessionSource; use serde_json::Value; use thiserror::Error; @@ -65,6 +73,15 @@ pub enum CodexAuth { BedrockApiKey(BedrockApiKeyAuth), } +/// Policy for resolving Agent Identity auth from a broader Codex auth snapshot. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentIdentityAuthPolicy { + /// Use Agent Identity auth only when the current auth is already Agent Identity. + JwtOnly, + /// Allow managed ChatGPT auth to register or reuse Agent Identity auth. + ChatGptAuth, +} + impl PartialEq for CodexAuth { fn eq(&self, other: &Self) -> bool { match (self, other) { @@ -222,7 +239,6 @@ impl CodexAuth { agent_identity_authapi_base_url: Option<&str>, ) -> std::io::Result { let auth_mode = auth_dot_json.resolved_mode(); - let client = create_client(); if auth_mode == ApiAuthMode::ApiKey { let Some(api_key) = auth_dot_json.openai_api_key.as_deref() else { return Err(std::io::Error::other("API key auth is missing a key.")); @@ -230,17 +246,34 @@ impl CodexAuth { return Ok(Self::from_api_key(api_key)); } if auth_mode == ApiAuthMode::AgentIdentity { - let Some(agent_identity) = auth_dot_json.agent_identity else { + let Some(agent_identity) = auth_dot_json.agent_identity.clone() else { return Err(std::io::Error::other( - "agent identity auth is missing an agent identity token.", + "agent identity auth is missing agent identity auth material.", )); }; - return Self::from_agent_identity_jwt( - &agent_identity, - chatgpt_base_url, - agent_identity_authapi_base_url, - ) - .await; + let base_url = chatgpt_base_url + .unwrap_or(ChatGptEnvironment::default().chatgpt_base_url()) + .trim_end_matches('/') + .to_string(); + let agent_identity_authapi_base_url = + require_agent_identity_authapi_base_url(agent_identity_authapi_base_url)?; + match agent_identity { + AgentIdentityStorage::Jwt(jwt) => { + let auth = AgentIdentityAuth::from_jwt( + &jwt, + &base_url, + agent_identity_authapi_base_url, + ) + .await?; + return Ok(Self::AgentIdentity(auth)); + } + AgentIdentityStorage::Record(record) => { + let auth = + AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url) + .await?; + return Ok(Self::AgentIdentity(auth)); + } + } } if auth_mode == ApiAuthMode::PersonalAccessToken { let Some(personal_access_token) = auth_dot_json.personal_access_token.as_deref() else { @@ -262,7 +295,7 @@ impl CodexAuth { let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode); let state = ChatgptAuthState { auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))), - client, + client: create_client(), }; match auth_mode { @@ -292,6 +325,8 @@ impl CodexAuth { chatgpt_base_url: Option<&str>, keyring_backend_kind: AuthKeyringBackendKind, ) -> std::io::Result> { + let agent_identity_authapi_base_url = + agent_identity_authapi_base_url(chatgpt_base_url).ok(); load_auth( codex_home, /*enable_codex_api_key_env*/ false, @@ -299,7 +334,7 @@ impl CodexAuth { /*forced_chatgpt_workspace_id*/ None, chatgpt_base_url, keyring_backend_kind, - /*agent_identity_authapi_base_url*/ None, + agent_identity_authapi_base_url.as_deref(), ) .await } @@ -307,19 +342,28 @@ impl CodexAuth { pub async fn from_agent_identity_jwt( jwt: &str, chatgpt_base_url: Option<&str>, - agent_identity_authapi_base_url_override: Option<&str>, + ) -> std::io::Result { + let agent_identity_authapi_base_url = agent_identity_authapi_base_url(chatgpt_base_url)?; + Self::from_agent_identity_jwt_with_authapi_base_url( + jwt, + chatgpt_base_url, + &agent_identity_authapi_base_url, + ) + .await + } + + async fn from_agent_identity_jwt_with_authapi_base_url( + jwt: &str, + chatgpt_base_url: Option<&str>, + agent_identity_authapi_base_url: &str, ) -> std::io::Result { let base_url = chatgpt_base_url .unwrap_or(ChatGptEnvironment::default().chatgpt_base_url()) .trim_end_matches('/') .to_string(); - let resolved_authapi_base_url = resolve_agent_identity_authapi_base_url( - Some(&base_url), - agent_identity_authapi_base_url_override, - )?; - let record = verified_agent_identity_record(jwt, &base_url).await?; - let auth = AgentIdentityAuth::load(record, &resolved_authapi_base_url).await?; - Ok(Self::AgentIdentity(auth)) + Ok(Self::AgentIdentity( + AgentIdentityAuth::from_jwt(jwt, &base_url, agent_identity_authapi_base_url).await?, + )) } pub async fn from_personal_access_token(access_token: &str) -> std::io::Result { @@ -499,6 +543,89 @@ impl CodexAuth { self.get_current_auth_json().and_then(|t| t.tokens) } + fn stored_managed_chatgpt_agent_identity_record( + &self, + account_id: &str, + ) -> Option { + self.get_current_auth_json() + .and_then(|auth| auth.agent_identity) + .and_then(|identity| identity.as_record().cloned()) + .filter(|identity| identity.account_id == account_id) + } + + fn persist_managed_chatgpt_agent_identity_record( + &self, + record: AgentIdentityAuthRecord, + ) -> std::io::Result<()> { + if let Self::Chatgpt(chatgpt_auth) = self { + chatgpt_auth.persist_agent_identity_record(record)?; + } + Ok(()) + } + + async fn agent_identity_auth( + &self, + policy: AgentIdentityAuthPolicy, + agent_identity_authapi_base_url: Option<&str>, + forced_chatgpt_workspace_id: Option>, + session_source: SessionSource, + ) -> std::io::Result> { + match self { + Self::AgentIdentity(auth) => Ok(Some(auth.clone())), + Self::ApiKey(_) + | Self::ChatgptAuthTokens(_) + | Self::PersonalAccessToken(_) + | Self::BedrockApiKey(_) => Ok(None), + Self::Chatgpt(_) => { + if policy == AgentIdentityAuthPolicy::JwtOnly { + return Ok(None); + } + self.ensure_managed_chatgpt_agent_identity( + require_agent_identity_authapi_base_url(agent_identity_authapi_base_url)?, + forced_chatgpt_workspace_id, + session_source, + ) + .await + .map(Some) + } + } + } + + async fn ensure_managed_chatgpt_agent_identity( + &self, + agent_identity_authapi_base_url: &str, + forced_chatgpt_workspace_id: Option>, + session_source: SessionSource, + ) -> std::io::Result { + let binding = + ManagedChatGptAgentIdentityBinding::from_auth(self, forced_chatgpt_workspace_id) + .ok_or_else(|| std::io::Error::other("ChatGPT auth is unavailable"))?; + + // JWT auth is loaded as CodexAuth::AgentIdentity; this path only reuses + // records created by the managed ChatGPT Agent Identity bootstrap. + if let Some(record) = self.stored_managed_chatgpt_agent_identity_record(&binding.account_id) + && record_matches_managed_chatgpt_binding(&record, &binding) + { + let should_persist = record_needs_task_registration(&record); + let auth = AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url) + .await + .map_err(|err| classify_bootstrap_error("agent task registration", err))?; + if should_persist { + self.persist_managed_chatgpt_agent_identity_record(auth.record().clone())?; + } + return Ok(auth); + } + + let auth = register_managed_chatgpt_agent_identity( + binding, + agent_identity_authapi_base_url, + session_source, + ) + .await?; + self.persist_managed_chatgpt_agent_identity_record(auth.record().clone())?; + Ok(auth) + } + /// Consider this private to integration tests. pub fn create_dummy_chatgpt_auth_for_testing() -> Self { let auth_dot_json = AuthDotJson { @@ -516,10 +643,9 @@ impl CodexAuth { bedrock_api_key: None, }; - let client = create_client(); let state = ChatgptAuthState { auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))), - client, + client: create_client(), }; let dummy_auth_id = NEXT_DUMMY_AUTH_ID.fetch_add(1, Ordering::Relaxed); let storage = create_auth_storage( @@ -537,6 +663,43 @@ impl CodexAuth { } } +impl ManagedChatGptAgentIdentityBinding { + fn from_auth(auth: &CodexAuth, forced_workspace_id: Option>) -> Option { + if !auth.is_chatgpt_auth() { + return None; + } + + let token_data = auth.get_token_data().ok()?; + let forced_workspace_id = + forced_workspace_id + .as_deref() + .and_then(|workspace_ids| match workspace_ids { + [workspace_id] if !workspace_id.is_empty() => Some(workspace_id.clone()), + _ => None, + }); + let account_id = forced_workspace_id + .or(token_data + .account_id + .clone() + .filter(|value| !value.is_empty())) + .or(token_data.id_token.chatgpt_account_id.clone())?; + let chatgpt_user_id = token_data + .id_token + .chatgpt_user_id + .clone() + .filter(|value| !value.is_empty())?; + + Some(Self { + account_id, + chatgpt_user_id, + email: token_data.id_token.email.clone().unwrap_or_default(), + plan_type: auth.account_plan_type().unwrap_or(AccountPlanType::Unknown), + chatgpt_account_is_fedramp: auth.is_fedramp_account(), + access_token: token_data.access_token, + }) + } +} + impl ChatgptAuth { fn current_auth_json(&self) -> Option { #[expect(clippy::unwrap_used)] @@ -554,6 +717,31 @@ impl ChatgptAuth { fn client(&self) -> &CodexHttpClient { &self.state.client } + + fn persist_agent_identity_record( + &self, + record: AgentIdentityAuthRecord, + ) -> std::io::Result<()> { + persist_agent_identity_record(&self.state.auth_dot_json, &self.storage, record) + } +} + +fn persist_agent_identity_record( + auth_dot_json: &Arc>>, + storage: &Arc, + record: AgentIdentityAuthRecord, +) -> std::io::Result<()> { + let mut guard = auth_dot_json + .lock() + .map_err(|_| std::io::Error::other("failed to lock auth state"))?; + let mut auth = storage + .load()? + .or_else(|| guard.clone()) + .ok_or_else(|| std::io::Error::other("auth data is not available"))?; + auth.agent_identity = Some(AgentIdentityStorage::Record(record)); + storage.save(&auth)?; + *guard = Some(auth); + Ok(()) } pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY"; @@ -582,34 +770,6 @@ fn read_non_empty_env_var(key: &str) -> Option { .filter(|value| !value.is_empty()) } -async fn verified_agent_identity_record( - jwt: &str, - chatgpt_base_url: &str, -) -> std::io::Result { - AgentIdentityAuthRecord::from_agent_identity_jwt(jwt)?; - let jwks = fetch_agent_identity_jwks(&build_reqwest_client(), chatgpt_base_url) - .await - .map_err(std::io::Error::other)?; - let claims = decode_agent_identity_jwt(jwt, Some(&jwks)).map_err(std::io::Error::other)?; - Ok(claims.into()) -} - -fn resolve_agent_identity_authapi_base_url( - chatgpt_base_url: Option<&str>, - agent_identity_authapi_base_url_override: Option<&str>, -) -> std::io::Result { - if let Some(base_url) = agent_identity_authapi_base_url_override { - return Ok(base_url.trim_end_matches('/').to_string()); - } - - let environment = match chatgpt_base_url { - Some(chatgpt_base_url) => ChatGptEnvironment::from_chatgpt_base_url(chatgpt_base_url) - .map_err(std::io::Error::other)?, - None => ChatGptEnvironment::default(), - }; - Ok(environment.agent_identity_authapi_base_url().to_string()) -} - /// Delete the auth.json file inside `codex_home` if it exists. Returns `Ok(true)` /// if a file was removed, `Ok(false)` if no auth file was present. pub fn logout( @@ -705,13 +865,13 @@ pub async fn login_with_access_token( .unwrap_or(ChatGptEnvironment::default().chatgpt_base_url()) .trim_end_matches('/') .to_string(); - verified_agent_identity_record(jwt, &base_url).await?; + verified_record_from_jwt(jwt, &base_url).await?; AuthDotJson { auth_mode: Some(ApiAuthMode::AgentIdentity), openai_api_key: None, tokens: None, last_refresh: None, - agent_identity: Some(jwt.to_string()), + agent_identity: Some(AgentIdentityStorage::Jwt(jwt.to_string())), personal_access_token: None, bedrock_api_key: None, } @@ -793,11 +953,23 @@ pub struct AuthConfig { pub keyring_backend_kind: AuthKeyringBackendKind, pub forced_login_method: Option, pub chatgpt_base_url: Option, - pub agent_identity_authapi_base_url: Option, pub forced_chatgpt_workspace_id: Option>, } pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<()> { + let agent_identity_authapi_base_url = + agent_identity_authapi_base_url(config.chatgpt_base_url.as_deref()).ok(); + enforce_login_restrictions_with_agent_identity_authapi_base_url( + config, + agent_identity_authapi_base_url.as_deref(), + ) + .await +} + +async fn enforce_login_restrictions_with_agent_identity_authapi_base_url( + config: &AuthConfig, + agent_identity_authapi_base_url: Option<&str>, +) -> std::io::Result<()> { let Some(auth) = load_auth( &config.codex_home, /*enable_codex_api_key_env*/ true, @@ -805,7 +977,7 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result< /*forced_chatgpt_workspace_id*/ None, config.chatgpt_base_url.as_deref(), config.keyring_backend_kind, - config.agent_identity_authapi_base_url.as_deref(), + agent_identity_authapi_base_url, ) .await? else { @@ -985,11 +1157,13 @@ async fn load_auth( ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, &auth)?; Ok(Some(CodexAuth::PersonalAccessToken(auth))) } - CodexAccessToken::AgentIdentityJwt(jwt) => CodexAuth::from_agent_identity_jwt( - jwt, - chatgpt_base_url, - agent_identity_authapi_base_url, - ) + CodexAccessToken::AgentIdentityJwt(jwt) => { + CodexAuth::from_agent_identity_jwt_with_authapi_base_url( + jwt, + chatgpt_base_url, + require_agent_identity_authapi_base_url(agent_identity_authapi_base_url)?, + ) + } .await .map(Some), }; @@ -1524,7 +1698,9 @@ pub struct AuthManager { keyring_backend_kind: AuthKeyringBackendKind, forced_chatgpt_workspace_id: RwLock>>, chatgpt_base_url: Option, + agent_identity_authapi_base_url: Option, refresh_lock: Semaphore, + agent_identity_lock: Semaphore, external_auth: RwLock>>, } @@ -1572,30 +1748,16 @@ impl Debug for AuthManager { } } +fn default_agent_identity_authapi_base_url() -> Option { + agent_identity_authapi_base_url(/*chatgpt_base_url*/ None).ok() +} + impl AuthManager { /// Create a new manager loading the initial auth using the provided /// preferred auth method. Errors loading auth are swallowed; `auth()` will /// simply return `None` in that case so callers can treat it as an /// unauthenticated state. pub async fn new( - codex_home: PathBuf, - enable_codex_api_key_env: bool, - auth_credentials_store_mode: AuthCredentialsStoreMode, - chatgpt_base_url: Option, - keyring_backend_kind: AuthKeyringBackendKind, - ) -> Self { - Self::new_with_workspace_restriction( - codex_home, - enable_codex_api_key_env, - auth_credentials_store_mode, - /*forced_chatgpt_workspace_id*/ None, - chatgpt_base_url, - keyring_backend_kind, - ) - .await - } - - async fn new_with_workspace_restriction( codex_home: PathBuf, enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, @@ -1603,6 +1765,8 @@ impl AuthManager { chatgpt_base_url: Option, keyring_backend_kind: AuthKeyringBackendKind, ) -> Self { + let agent_identity_authapi_base_url = + agent_identity_authapi_base_url(chatgpt_base_url.as_deref()).ok(); let managed_auth = load_auth( &codex_home, enable_codex_api_key_env, @@ -1610,7 +1774,7 @@ impl AuthManager { forced_chatgpt_workspace_id.as_deref(), chatgpt_base_url.as_deref(), keyring_backend_kind, - /*agent_identity_authapi_base_url*/ None, + agent_identity_authapi_base_url.as_deref(), ) .await .ok() @@ -1628,7 +1792,9 @@ impl AuthManager { keyring_backend_kind, forced_chatgpt_workspace_id: RwLock::new(forced_chatgpt_workspace_id), chatgpt_base_url, + agent_identity_authapi_base_url, refresh_lock: Semaphore::new(/*permits*/ 1), + agent_identity_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(None), } } @@ -1650,7 +1816,9 @@ impl AuthManager { keyring_backend_kind: AuthKeyringBackendKind::default(), forced_chatgpt_workspace_id: RwLock::new(None), chatgpt_base_url: None, + agent_identity_authapi_base_url: default_agent_identity_authapi_base_url(), refresh_lock: Semaphore::new(/*permits*/ 1), + agent_identity_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(None), }) } @@ -1671,7 +1839,40 @@ impl AuthManager { keyring_backend_kind: AuthKeyringBackendKind::default(), forced_chatgpt_workspace_id: RwLock::new(None), chatgpt_base_url: None, + agent_identity_authapi_base_url: default_agent_identity_authapi_base_url(), refresh_lock: Semaphore::new(/*permits*/ 1), + agent_identity_lock: Semaphore::new(/*permits*/ 1), + external_auth: RwLock::new(None), + }) + } + + /// Create an AuthManager with a specific CodexAuth and Agent Identity AuthAPI base URL, for testing only. + #[doc(hidden)] + pub fn from_auth_for_testing_with_agent_identity_authapi_base_url( + auth: CodexAuth, + agent_identity_authapi_base_url: String, + ) -> Arc { + let cached = CachedAuth { + auth: Some(auth), + permanent_refresh_failure: None, + }; + let (auth_change_tx, _auth_change_rx) = watch::channel(0); + Arc::new(Self { + codex_home: PathBuf::from("non-existent"), + inner: RwLock::new(cached), + auth_change_tx, + enable_codex_api_key_env: false, + auth_credentials_store_mode: AuthCredentialsStoreMode::File, + keyring_backend_kind: AuthKeyringBackendKind::default(), + forced_chatgpt_workspace_id: RwLock::new(None), + chatgpt_base_url: None, + agent_identity_authapi_base_url: Some( + agent_identity_authapi_base_url + .trim_end_matches('/') + .to_string(), + ), + refresh_lock: Semaphore::new(/*permits*/ 1), + agent_identity_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(None), }) } @@ -1690,7 +1891,9 @@ impl AuthManager { keyring_backend_kind: AuthKeyringBackendKind::default(), forced_chatgpt_workspace_id: RwLock::new(None), chatgpt_base_url: None, + agent_identity_authapi_base_url: default_agent_identity_authapi_base_url(), refresh_lock: Semaphore::new(/*permits*/ 1), + agent_identity_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(Some( Arc::new(BearerTokenRefresher::new(config)) as Arc )), @@ -1736,6 +1939,38 @@ impl AuthManager { self.auth_cached() } + pub async fn agent_identity_auth( + &self, + policy: AgentIdentityAuthPolicy, + session_source: SessionSource, + ) -> std::io::Result> { + let Some(auth) = self.auth().await else { + return Ok(None); + }; + if policy == AgentIdentityAuthPolicy::ChatGptAuth && matches!(auth, CodexAuth::Chatgpt(_)) { + let _bootstrap_permit = self + .agent_identity_lock + .acquire() + .await + .map_err(std::io::Error::other)?; + return auth + .agent_identity_auth( + policy, + self.agent_identity_authapi_base_url.as_deref(), + self.forced_chatgpt_workspace_id(), + session_source, + ) + .await; + } + auth.agent_identity_auth( + policy, + self.agent_identity_authapi_base_url.as_deref(), + self.forced_chatgpt_workspace_id(), + session_source, + ) + .await + } + /// Force a reload of the auth information from auth.json. Returns /// whether the auth value changed. pub async fn reload(&self) -> bool { @@ -1838,7 +2073,7 @@ impl AuthManager { forced_chatgpt_workspace_id.as_deref(), self.chatgpt_base_url.as_deref(), self.keyring_backend_kind, - /*agent_identity_authapi_base_url*/ None, + self.agent_identity_authapi_base_url.as_deref(), ) .await .ok() @@ -1911,6 +2146,7 @@ impl AuthManager { codex_home: PathBuf, enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, + forced_chatgpt_workspace_id: Option>, chatgpt_base_url: Option, keyring_backend_kind: AuthKeyringBackendKind, ) -> Arc { @@ -1919,6 +2155,7 @@ impl AuthManager { codex_home, enable_codex_api_key_env, auth_credentials_store_mode, + forced_chatgpt_workspace_id, chatgpt_base_url, keyring_backend_kind, ) @@ -1931,17 +2168,15 @@ impl AuthManager { config: &impl AuthManagerConfig, enable_codex_api_key_env: bool, ) -> Arc { - Arc::new( - Self::new_with_workspace_restriction( - config.codex_home(), - enable_codex_api_key_env, - config.cli_auth_credentials_store_mode(), - config.forced_chatgpt_workspace_id(), - Some(config.chatgpt_base_url()), - config.auth_keyring_backend_kind(), - ) - .await, + Self::shared( + config.codex_home(), + enable_codex_api_key_env, + config.cli_auth_credentials_store_mode(), + config.forced_chatgpt_workspace_id(), + Some(config.chatgpt_base_url()), + config.auth_keyring_backend_kind(), ) + .await } pub fn unauthorized_recovery(self: &Arc) -> UnauthorizedRecovery { diff --git a/codex-rs/login/src/auth/storage.rs b/codex-rs/login/src/auth/storage.rs index ebec064170..19ba018f30 100644 --- a/codex-rs/login/src/auth/storage.rs +++ b/codex-rs/login/src/auth/storage.rs @@ -51,7 +51,7 @@ pub struct AuthDotJson { pub last_refresh: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_identity: Option, + pub agent_identity: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub personal_access_token: Option, @@ -60,6 +60,32 @@ pub struct AuthDotJson { pub bedrock_api_key: Option, } +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)] +#[serde(untagged)] +pub enum AgentIdentityStorage { + Jwt(String), + Record(AgentIdentityAuthRecord), +} + +impl AgentIdentityStorage { + pub fn has_auth_material(&self) -> bool { + match self { + Self::Jwt(jwt) => !jwt.trim().is_empty(), + Self::Record(record) => { + !record.agent_runtime_id.trim().is_empty() + && !record.agent_private_key.trim().is_empty() + } + } + } + + pub(crate) fn as_record(&self) -> Option<&AgentIdentityAuthRecord> { + match self { + Self::Jwt(_) => None, + Self::Record(record) => Some(record), + } + } +} + #[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)] pub struct AgentIdentityAuthRecord { pub agent_runtime_id: String, @@ -69,6 +95,8 @@ pub struct AgentIdentityAuthRecord { pub email: String, pub plan_type: AccountPlanType, pub chatgpt_account_is_fedramp: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub task_id: Option, } impl AgentIdentityAuthRecord { @@ -90,6 +118,7 @@ impl From for AgentIdentityAuthRecord { email: claims.email, plan_type: claims.plan_type.into(), chatgpt_account_is_fedramp: claims.chatgpt_account_is_fedramp, + task_id: None, } } } diff --git a/codex-rs/login/src/auth/storage_tests.rs b/codex-rs/login/src/auth/storage_tests.rs index 72d59b6600..db51a7746e 100644 --- a/codex-rs/login/src/auth/storage_tests.rs +++ b/codex-rs/login/src/auth/storage_tests.rs @@ -81,7 +81,38 @@ async fn file_storage_round_trips_agent_identity_auth() -> anyhow::Result<()> { openai_api_key: None, tokens: None, last_refresh: None, - agent_identity: Some(agent_identity), + agent_identity: Some(AgentIdentityStorage::Jwt(agent_identity)), + personal_access_token: None, + bedrock_api_key: None, + }; + + storage.save(&auth_dot_json)?; + + let loaded = storage.load()?; + assert_eq!(Some(auth_dot_json), loaded); + Ok(()) +} + +#[tokio::test] +async fn file_storage_round_trips_registered_agent_identity_auth() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); + let record = AgentIdentityAuthRecord { + agent_runtime_id: "agent-runtime-id".to_string(), + agent_private_key: "private-key".to_string(), + account_id: "account-id".to_string(), + chatgpt_user_id: "user-id".to_string(), + email: "user@example.com".to_string(), + plan_type: AccountPlanType::Pro, + chatgpt_account_is_fedramp: false, + task_id: Some("task-id".to_string()), + }; + let auth_dot_json = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: Some(AgentIdentityStorage::Record(record)), personal_access_token: None, bedrock_api_key: None, }; @@ -139,8 +170,8 @@ async fn file_storage_loads_agent_identity_as_jwt() -> anyhow::Result<()> { let loaded = storage.load()?; assert_eq!( - loaded.expect("auth should load").agent_identity.as_deref(), - Some(agent_identity_jwt.as_str()) + loaded.expect("auth should load").agent_identity, + Some(AgentIdentityStorage::Jwt(agent_identity_jwt)) ); Ok(()) } diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 367f276308..2b8e48b4a5 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -17,6 +17,7 @@ pub use server::ServerOptions; pub use server::ShutdownHandle; pub use server::run_login_server; +pub use auth::AgentIdentityAuthPolicy; pub use auth::AuthConfig; pub use auth::AuthDotJson; pub use auth::AuthKeyringBackendKind; diff --git a/codex-rs/login/tests/suite/auth_refresh.rs b/codex-rs/login/tests/suite/auth_refresh.rs index 4065722298..af892d76a0 100644 --- a/codex-rs/login/tests/suite/auth_refresh.rs +++ b/codex-rs/login/tests/suite/auth_refresh.rs @@ -1217,6 +1217,7 @@ impl RefreshTokenTestContext { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), ) diff --git a/codex-rs/login/tests/suite/logout.rs b/codex-rs/login/tests/suite/logout.rs index 5df73e932f..3c47bc88b8 100644 --- a/codex-rs/login/tests/suite/logout.rs +++ b/codex-rs/login/tests/suite/logout.rs @@ -201,6 +201,7 @@ async fn auth_manager_logout_with_revoke_uses_cached_auth() -> Result<()> { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), ) diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 55cf893076..f189b23149 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1163,7 +1163,6 @@ pub async fn run_main( forced_login_method: config.forced_login_method, forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id.clone(), chatgpt_base_url: Some(config.chatgpt_base_url.clone()), - agent_identity_authapi_base_url: None, }) .await { From 4af7762f0129e903461a351cc1e6daa79cb0cea0 Mon Sep 17 00:00:00 2001 From: Alex Daley Date: Thu, 18 Jun 2026 17:19:24 -0400 Subject: [PATCH 050/112] [connectors] Ignore synthetic links for app accessibility (#28770) Summary - Stop treating Codex Apps MCP tools with `_meta._codex_apps.synthetic_link: true` as evidence that a connector is accessible in `app/list`. - Preserve synthetic tools in the agent-facing MCP connector set so they remain available for install/auth flows. - Keep the app-list accessibility cache limited to connectors backed by at least one non-synthetic tool. - Add focused regression coverage for both sides of the boundary. Validation - `just fmt` - `just test -p codex-core synthetic_links_are_exposed_to_the_agent_but_not_accessible_in_app_list` - `git diff --check` - A crate-wide `just test -p codex-core` run completed with 2,699 passing and 51 unrelated local sandbox/state failures, primarily state DB migration races (`UNIQUE constraint failed: _sqlx_migrations.version`). --- codex-rs/core/src/connectors.rs | 27 +++++++++-- codex-rs/core/src/connectors_tests.rs | 64 +++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 8cc83be3d2..86e5f07f9a 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -32,6 +32,7 @@ use codex_features::Feature; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; +use codex_mcp::MCP_TOOL_CODEX_APPS_META_KEY; use codex_mcp::McpConnectionManager; use codex_mcp::McpRuntimeContext; use codex_mcp::ToolInfo; @@ -154,7 +155,7 @@ pub(crate) fn refresh_accessible_connectors_cache_from_mcp_tools( } let cache_key = accessible_connectors_cache_key(config, auth); - let accessible_connectors = accessible_connectors_from_mcp_tools(mcp_tools); + let accessible_connectors = accessible_connectors_for_app_list_from_mcp_tools(mcp_tools); write_cached_accessible_connectors(cache_key, &accessible_connectors); } @@ -339,7 +340,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager( cancel_token.cancel(); } - let accessible_connectors = accessible_connectors_from_mcp_tools(&tools); + let accessible_connectors = accessible_connectors_for_app_list_from_mcp_tools(&tools); if codex_apps_ready || !accessible_connectors.is_empty() { write_cached_accessible_connectors(cache_key, &accessible_connectors); } @@ -469,9 +470,15 @@ async fn cached_directory_connectors_for_tool_suggest_with_auth( } pub(crate) fn accessible_connectors_from_mcp_tools(mcp_tools: &[ToolInfo]) -> Vec { + collect_accessible_connectors_from_mcp_tools(mcp_tools.iter()) +} + +fn collect_accessible_connectors_from_mcp_tools<'a>( + mcp_tools: impl Iterator, +) -> Vec { // ToolInfo already carries plugin provenance, so app-level plugin sources // can be derived here instead of requiring a separate enrichment pass. - let tools = mcp_tools.iter().filter_map(|tool| { + let tools = mcp_tools.filter_map(|tool| { if tool.server_name != CODEX_APPS_MCP_SERVER_NAME { return None; } @@ -486,6 +493,20 @@ pub(crate) fn accessible_connectors_from_mcp_tools(mcp_tools: &[ToolInfo]) -> Ve codex_connectors::accessible::collect_accessible_connectors(tools) } +fn accessible_connectors_for_app_list_from_mcp_tools(mcp_tools: &[ToolInfo]) -> Vec { + let non_synthetic_tools = mcp_tools.iter().filter(|tool| { + tool.tool + .meta + .as_deref() + .and_then(|meta| meta.get(MCP_TOOL_CODEX_APPS_META_KEY)) + .and_then(serde_json::Value::as_object) + .and_then(|meta| meta.get("synthetic_link")) + .and_then(serde_json::Value::as_bool) + != Some(true) + }); + collect_accessible_connectors_from_mcp_tools(non_synthetic_tools) +} + pub fn with_app_enabled_state(mut connectors: Vec, config: &Config) -> Vec { let user_apps_config = apps_config_from_layer_stack(&config.config_layer_stack); let requirements_apps_config = config.config_layer_stack.requirements_toml().apps.as_ref(); diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index 4e6cb55aaa..5bbd6a1f88 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -17,6 +17,7 @@ use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_mcp::ToolInfo; use pretty_assertions::assert_eq; use rmcp::model::JsonObject; +use rmcp::model::Meta; use rmcp::model::Tool; use std::collections::BTreeMap; use std::collections::HashSet; @@ -140,6 +141,69 @@ fn accessible_connectors_from_mcp_tools_carries_plugin_display_names() { ); } +#[test] +fn synthetic_links_are_exposed_to_the_agent_but_not_accessible_in_app_list() { + let mut synthetic_tool = codex_app_tool("gmail_batch_read_email", "gmail", Some("Gmail"), &[]); + synthetic_tool.tool.meta = Some(Meta( + serde_json::json!({ + "resource_name": "gmail.batch_read_email", + "_codex_apps": { + "resource_uri": "/connector/gmail/batch_read_email", + "contains_mcp_source": false, + "synthetic_link": true + } + }) + .as_object() + .expect("meta should be an object") + .clone(), + )); + let tools = vec![ + synthetic_tool, + codex_app_tool("calendar_list_events", "calendar", Some("Calendar"), &[]), + ]; + + let calendar = AppInfo { + id: "calendar".to_string(), + name: "Calendar".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: Some(connector_install_url("Calendar", "calendar")), + branding: None, + app_metadata: None, + labels: None, + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + }; + assert_eq!( + accessible_connectors_for_app_list_from_mcp_tools(&tools), + vec![calendar.clone()] + ); + assert_eq!( + accessible_connectors_from_mcp_tools(&tools), + vec![ + calendar, + AppInfo { + id: "gmail".to_string(), + name: "Gmail".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: Some(connector_install_url("Gmail", "gmail")), + branding: None, + app_metadata: None, + labels: None, + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + } + ] + ); +} + #[tokio::test] async fn refresh_accessible_connectors_cache_from_mcp_tools_writes_latest_installed_apps() { let codex_home = tempdir().expect("tempdir should succeed"); From 406062c3af8b27c8e1b4b83c485ebe1ae0df874c Mon Sep 17 00:00:00 2001 From: xl-openai Date: Thu, 18 Jun 2026 14:29:01 -0700 Subject: [PATCH 051/112] [codex] Preserve remote plugin download status errors (#28863) ## Summary - preserve the original HTTP status when a remote plugin bundle download returns a non-success response - retain at most 8 KiB of the error response body and annotate truncation or body-read failures - add regression coverage for an oversized error response ## Root cause The non-success response path reused the normal size-limited body reader. When an error response exceeded 8 KiB, that reader returned `DownloadTooLarge` before the code constructed `DownloadStatus`, masking the upstream HTTP status and response context. ## Impact Remote plugin installation failures now retain the actionable upstream HTTP status without allowing unbounded error bodies into logs. ## Validation - `just test -p codex-app-server plugin_install_preserves_status_when_remote_bundle_error_body_is_too_large` - `just fmt` - `git diff --check` --- .../tests/suite/v2/plugin_install.rs | 25 ++++++++---- codex-rs/core-plugins/src/remote_bundle.rs | 38 +++++++++++++++---- 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 4026bef357..3fc40d2dce 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -1010,15 +1010,12 @@ async fn plugin_install_tracks_remote_plugin_analytics_event() -> Result<()> { } #[tokio::test] -async fn plugin_install_errors_when_remote_bundle_download_fails() -> Result<()> { +async fn plugin_install_preserves_status_when_remote_bundle_error_body_is_too_large() -> Result<()> +{ let codex_home = TempDir::new()?; let server = MockServer::start().await; - let bundle_url = mount_remote_plugin_bundle( - &server, - /*status_code*/ 503, - b"bundle temporarily unavailable".to_vec(), - ) - .await; + let bundle_url = + mount_remote_plugin_bundle(&server, /*status_code*/ 503, vec![b'x'; 8 * 1024 + 1]).await; configure_remote_plugin_test(codex_home.path(), &server)?; mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await; mount_empty_remote_installed_plugins(&server).await; @@ -1041,6 +1038,20 @@ async fn plugin_install_errors_when_remote_bundle_download_fails() -> Result<()> assert_eq!(err.error.code, -32603); assert!(err.error.message.contains("failed with status 503")); + assert!( + err.error + .message + .contains("[response body truncated after 8192 bytes]") + ); + assert_eq!( + err.error + .message + .bytes() + .filter(|byte| *byte == b'x') + .count(), + 8192 + ); + assert!(!err.error.message.contains("exceeded maximum size")); wait_for_remote_plugin_request_count( &server, "GET", diff --git a/codex-rs/core-plugins/src/remote_bundle.rs b/codex-rs/core-plugins/src/remote_bundle.rs index cb60d60b77..49e6bd4138 100644 --- a/codex-rs/core-plugins/src/remote_bundle.rs +++ b/codex-rs/core-plugins/src/remote_bundle.rs @@ -293,13 +293,37 @@ async fn download_remote_plugin_bundle_with_limit( let url = final_url.to_string(); let status = response.status(); if !status.is_success() { - let body = read_response_body_with_limit( - response, - &url, - /*max_bytes*/ REMOTE_PLUGIN_BUNDLE_ERROR_BODY_MAX_BYTES, - ) - .await?; - let body = String::from_utf8_lossy(&body).to_string(); + let mut response = response; + let mut body = Vec::new(); + let mut body_truncated = false; + let mut body_read_error = None; + loop { + let chunk = match response.chunk().await { + Ok(Some(chunk)) => chunk, + Ok(None) => break, + Err(source) => { + body_read_error = Some(source); + break; + } + }; + let remaining = REMOTE_PLUGIN_BUNDLE_ERROR_BODY_MAX_BYTES as usize - body.len(); + if chunk.len() > remaining { + body.extend_from_slice(&chunk[..remaining]); + body_truncated = true; + break; + } + body.extend_from_slice(&chunk); + } + + let mut body = String::from_utf8_lossy(&body).into_owned(); + if body_truncated { + body.push_str(&format!( + "\n[response body truncated after {REMOTE_PLUGIN_BUNDLE_ERROR_BODY_MAX_BYTES} bytes]" + )); + } + if let Some(source) = body_read_error { + body.push_str(&format!("\n[failed to read response body: {source}]")); + } return Err(RemotePluginBundleInstallError::DownloadStatus { url, status, body }); } From dce673905a191744138dbba19a7d3f8d85b10d6c Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Thu, 18 Jun 2026 15:06:23 -0700 Subject: [PATCH 052/112] core: load AGENTS.md from foreign environments (#28958) ## Why Make it possible to load AGENTS.md from remote exec-servers whose OS is different than app-server. ## What - keep `AGENTS.md` discovery and provenance as `PathUri`, with root-aware parent and ancestor traversal - expose lifecycle instruction sources as legacy app-server path strings in events while retaining `PathUri` internally - preserve and test mixed POSIX and Windows paths in model context and TUI status output - cover remote Windows loading end to end by seeding the Wine prefix through host filesystem APIs - fix bug in `PathUri`'s parent() implementation that would erase Windows drive letters --- bazel/rules/testing/wine/src/lib.rs | 8 + .../codex_app_server_protocol.schemas.json | 12 +- .../codex_app_server_protocol.v2.schemas.json | 12 +- .../schema/json/v2/ThreadForkResponse.json | 4 +- .../schema/json/v2/ThreadResumeResponse.json | 4 +- .../schema/json/v2/ThreadStartResponse.json | 4 +- .../typescript/v2/ThreadForkResponse.ts | 5 +- .../typescript/v2/ThreadResumeResponse.ts | 5 +- .../typescript/v2/ThreadStartResponse.ts | 5 +- .../src/protocol/common.rs | 6 +- .../src/protocol/v2/tests.rs | 41 +++- .../src/protocol/v2/thread.rs | 52 ++++- codex-rs/app-server/README.md | 2 +- .../request_processors/thread_processor.rs | 8 +- codex-rs/app-server/src/thread_state.rs | 5 +- .../tests/suite/v2/thread_resume.rs | 6 +- .../app-server/tests/suite/v2/thread_start.rs | 6 +- codex-rs/core/src/agents_md.rs | 63 +++--- codex-rs/core/src/agents_md_tests.rs | 181 ++++++++++++++---- codex-rs/core/src/codex_thread.rs | 12 +- codex-rs/core/src/session/mod.rs | 6 +- codex-rs/core/tests/common/test_codex.rs | 16 +- .../core/tests/remote_env_windows/BUILD.bazel | 1 - .../remote_env_windows_test.rs | 37 +++- codex-rs/core/tests/suite/agents_md.rs | 62 +++--- codex-rs/core/tests/suite/compact.rs | 13 +- .../core/tests/suite/hierarchical_agents.rs | 3 - .../exec-server/testing/wine_exec_server.rs | 9 +- .../testing/wine_remote_test_runner.rs | 2 +- codex-rs/tui/Cargo.toml | 2 +- codex-rs/tui/src/app_server_session.rs | 16 +- codex-rs/tui/src/chatwidget.rs | 3 +- .../src/chatwidget/tests/slash_commands.rs | 4 +- .../chatwidget/tests/status_command_tests.rs | 23 ++- codex-rs/tui/src/session_state.rs | 3 +- codex-rs/tui/src/status/helpers.rs | 50 ++++- codex-rs/utils/path-uri/src/lib.rs | 26 ++- codex-rs/utils/path-uri/src/tests.rs | 36 +++- 38 files changed, 550 insertions(+), 203 deletions(-) diff --git a/bazel/rules/testing/wine/src/lib.rs b/bazel/rules/testing/wine/src/lib.rs index 3b99f5870b..76cb029e17 100644 --- a/bazel/rules/testing/wine/src/lib.rs +++ b/bazel/rules/testing/wine/src/lib.rs @@ -105,6 +105,14 @@ impl WineTestCommand { } impl WineTestProcess { + /// Returns the host path to this process's isolated Wine prefix. + pub fn prefix_path(&self) -> &Path { + let Some(processes) = self.processes.as_ref() else { + panic!("Wine process guard is missing"); + }; + processes.prefix.path() + } + /// Takes the piped standard output of the Wine process. /// /// This may only be called once for a process created by diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index e9611c67e1..0a18c588f3 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -16973,9 +16973,9 @@ }, "instructionSources": { "default": [], - "description": "Instruction source files currently loaded for this thread.", + "description": "Environment-native paths to instruction source files currently loaded for this thread.", "items": { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "$ref": "#/definitions/v2/LegacyAppPathString" }, "type": "array" }, @@ -18688,9 +18688,9 @@ }, "instructionSources": { "default": [], - "description": "Instruction source files currently loaded for this thread.", + "description": "Environment-native paths to instruction source files currently loaded for this thread.", "items": { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "$ref": "#/definitions/v2/LegacyAppPathString" }, "type": "array" }, @@ -19105,9 +19105,9 @@ }, "instructionSources": { "default": [], - "description": "Instruction source files currently loaded for this thread.", + "description": "Environment-native paths to instruction source files currently loaded for this thread.", "items": { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "$ref": "#/definitions/v2/LegacyAppPathString" }, "type": "array" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index bac9a48e70..0ddfdb62bd 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -14752,9 +14752,9 @@ }, "instructionSources": { "default": [], - "description": "Instruction source files currently loaded for this thread.", + "description": "Environment-native paths to instruction source files currently loaded for this thread.", "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": "array" }, @@ -16467,9 +16467,9 @@ }, "instructionSources": { "default": [], - "description": "Instruction source files currently loaded for this thread.", + "description": "Environment-native paths to instruction source files currently loaded for this thread.", "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": "array" }, @@ -16884,9 +16884,9 @@ }, "instructionSources": { "default": [], - "description": "Instruction source files currently loaded for this thread.", + "description": "Environment-native paths to instruction source files currently loaded for this thread.", "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": "array" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 9ced571cfa..bebd194497 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -2368,9 +2368,9 @@ }, "instructionSources": { "default": [], - "description": "Instruction source files currently loaded for this thread.", + "description": "Environment-native paths to instruction source files currently loaded for this thread.", "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": "array" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 28a5f5b24a..73e228845d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -2394,9 +2394,9 @@ }, "instructionSources": { "default": [], - "description": "Instruction source files currently loaded for this thread.", + "description": "Environment-native paths to instruction source files currently loaded for this thread.", "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": "array" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 1baf6e4bdc..726088c0d9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -2368,9 +2368,9 @@ }, "instructionSources": { "default": [], - "description": "Instruction source files currently loaded for this thread.", + "description": "Environment-native paths to instruction source files currently loaded for this thread.", "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "type": "array" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts index c5b1201c26..9577562476 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts @@ -2,6 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { LegacyAppPathString } from "../LegacyAppPathString"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -9,9 +10,9 @@ import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; export type ThreadForkResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /** - * Instruction source files currently loaded for this thread. + * Environment-native paths to instruction source files currently loaded for this thread. */ -instructionSources: Array, approvalPolicy: AskForApproval, /** +instructionSources: Array, approvalPolicy: AskForApproval, /** * Reviewer currently used for approval requests on this thread. */ approvalsReviewer: ApprovalsReviewer, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts index 7a4f90377c..e1f7d642be 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts @@ -2,6 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { LegacyAppPathString } from "../LegacyAppPathString"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -9,9 +10,9 @@ import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; export type ThreadResumeResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /** - * Instruction source files currently loaded for this thread. + * Environment-native paths to instruction source files currently loaded for this thread. */ -instructionSources: Array, approvalPolicy: AskForApproval, /** +instructionSources: Array, approvalPolicy: AskForApproval, /** * Reviewer currently used for approval requests on this thread. */ approvalsReviewer: ApprovalsReviewer, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts index 38859a3805..992ab5dba7 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts @@ -2,6 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { LegacyAppPathString } from "../LegacyAppPathString"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -9,9 +10,9 @@ import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; export type ThreadStartResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /** - * Instruction source files currently loaded for this thread. + * Environment-native paths to instruction source files currently loaded for this thread. */ -instructionSources: Array, approvalPolicy: AskForApproval, /** +instructionSources: Array, approvalPolicy: AskForApproval, /** * Reviewer currently used for approval requests on this thread. */ approvalsReviewer: ApprovalsReviewer, /** diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index e5a882b3b5..3336f74a63 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2573,7 +2573,11 @@ mod tests { service_tier: None, cwd, runtime_workspace_roots: Vec::new(), - instruction_sources: vec![absolute_path("/tmp/AGENTS.md")], + instruction_sources: vec![ + codex_utils_path_uri::LegacyAppPathString::from_abs_path(&absolute_path( + "/tmp/AGENTS.md", + )), + ], approval_policy: v2::AskForApproval::OnFailure, approvals_reviewer: v2::ApprovalsReviewer::User, sandbox: v2::SandboxPolicy::DangerFullAccess, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 0368bf45f6..459b11f7f2 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -36,6 +36,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; use codex_utils_path_uri::LegacyAppPathString; +use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; use serde_json::Value as JsonValue; use serde_json::json; @@ -3685,17 +3686,49 @@ fn thread_lifecycle_responses_default_missing_optional_fields() { serde_json::from_value(response.clone()).expect("thread/start response"); let resume: ThreadResumeResponse = serde_json::from_value(response.clone()).expect("thread/resume response"); - let fork: ThreadForkResponse = serde_json::from_value(response).expect("thread/fork response"); + let fork: ThreadForkResponse = + serde_json::from_value(response.clone()).expect("thread/fork response"); - assert_eq!(start.instruction_sources, Vec::::new()); + assert_eq!(start.instruction_sources, Vec::::new()); assert_eq!(start.thread.parent_thread_id, None); assert_eq!(start.thread.recency_at, None); - assert_eq!(resume.instruction_sources, Vec::::new()); - assert_eq!(fork.instruction_sources, Vec::::new()); + assert_eq!( + resume.instruction_sources, + Vec::::new() + ); + assert_eq!(fork.instruction_sources, Vec::::new()); assert_eq!(start.active_permission_profile, None); assert_eq!(resume.active_permission_profile, None); assert_eq!(resume.initial_turns_page, None); assert_eq!(fork.active_permission_profile, None); + + let foreign_source: LegacyAppPathString = + serde_json::from_value(json!(r"C:\workspace\AGENTS.md")).expect("foreign source"); + let mut response_with_foreign_source = response; + response_with_foreign_source["instructionSources"] = json!([foreign_source.as_str()]); + let start: ThreadStartResponse = serde_json::from_value(response_with_foreign_source.clone()) + .expect("thread/start response with foreign source"); + let resume: ThreadResumeResponse = serde_json::from_value(response_with_foreign_source.clone()) + .expect("thread/resume response with foreign source"); + let fork: ThreadForkResponse = serde_json::from_value(response_with_foreign_source) + .expect("thread/fork response with foreign source"); + assert_eq!(start.instruction_sources, vec![foreign_source.clone()]); + assert_eq!(resume.instruction_sources, vec![foreign_source.clone()]); + assert_eq!(fork.instruction_sources, vec![foreign_source]); + let foreign_source_uri = + PathUri::parse("file:///C:/workspace/AGENTS.md").expect("foreign source URI"); + assert_eq!( + start.instruction_source_path_uris(), + vec![foreign_source_uri.clone()] + ); + assert_eq!( + resume.instruction_source_path_uris(), + vec![foreign_source_uri.clone()] + ); + assert_eq!( + fork.instruction_source_path_uris(), + vec![foreign_source_uri] + ); } #[test] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index b5809f09b8..59adcbaa85 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -26,6 +26,8 @@ use codex_protocol::protocol::ThreadGoalStatus as CoreThreadGoalStatus; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::LegacyAppPathString; +use codex_utils_path_uri::PathUri; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; @@ -161,9 +163,9 @@ pub struct ThreadStartResponse { #[experimental("thread/start.runtimeWorkspaceRoots")] #[serde(default)] pub runtime_workspace_roots: Vec, - /// Instruction source files currently loaded for this thread. + /// Environment-native paths to instruction source files currently loaded for this thread. #[serde(default)] - pub instruction_sources: Vec, + pub instruction_sources: Vec, #[experimental(nested)] pub approval_policy: AskForApproval, /// Reviewer currently used for approval requests on this thread. @@ -179,6 +181,13 @@ pub struct ThreadStartResponse { pub reasoning_effort: Option, } +impl ThreadStartResponse { + /// Parses valid absolute instruction source paths and omits malformed legacy values. + pub fn instruction_source_path_uris(&self) -> Vec { + instruction_source_path_uris(&self.instruction_sources) + } +} + #[derive( Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, )] @@ -375,9 +384,9 @@ pub struct ThreadResumeResponse { #[experimental("thread/resume.runtimeWorkspaceRoots")] #[serde(default)] pub runtime_workspace_roots: Vec, - /// Instruction source files currently loaded for this thread. + /// Environment-native paths to instruction source files currently loaded for this thread. #[serde(default)] - pub instruction_sources: Vec, + pub instruction_sources: Vec, #[experimental(nested)] pub approval_policy: AskForApproval, /// Reviewer currently used for approval requests on this thread. @@ -397,6 +406,13 @@ pub struct ThreadResumeResponse { pub initial_turns_page: Option, } +impl ThreadResumeResponse { + /// Parses valid absolute instruction source paths and omits malformed legacy values. + pub fn instruction_source_path_uris(&self) -> Vec { + instruction_source_path_uris(&self.instruction_sources) + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -523,9 +539,9 @@ pub struct ThreadForkResponse { #[experimental("thread/fork.runtimeWorkspaceRoots")] #[serde(default)] pub runtime_workspace_roots: Vec, - /// Instruction source files currently loaded for this thread. + /// Environment-native paths to instruction source files currently loaded for this thread. #[serde(default)] - pub instruction_sources: Vec, + pub instruction_sources: Vec, #[experimental(nested)] pub approval_policy: AskForApproval, /// Reviewer currently used for approval requests on this thread. @@ -541,6 +557,30 @@ pub struct ThreadForkResponse { pub reasoning_effort: Option, } +impl ThreadForkResponse { + /// Parses valid absolute instruction source paths and omits malformed legacy values. + pub fn instruction_source_path_uris(&self) -> Vec { + instruction_source_path_uris(&self.instruction_sources) + } +} + +fn instruction_source_path_uris(sources: &[LegacyAppPathString]) -> Vec { + // Instruction sources are advisory diagnostics. Warn and fail open so a malformed legacy + // path cannot fail thread start, resume, or fork. + sources + .iter() + .filter_map(|source| { + source.to_inferred_path_uri().or_else(|| { + tracing::warn!( + path = source.as_str(), + "ignoring invalid instruction source path from app-server" + ); + None + }) + }) + .collect() +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index deda1c517e..871f9cee79 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -140,7 +140,7 @@ Example with notification opt-out: - `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are also started in that environment; HTTP MCP declarations remain inactive. - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. - `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`. -- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. +- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. `instructionSources` lists loaded instruction files using each source environment's native absolute path syntax, including files loaded from remote environments. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. - `thread/list` — page through stored threads; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Experimental clients can use `parentThreadId` to filter direct spawned children represented by persisted spawn-edge state. Review and Guardian threads are not included because they do not participate in that spawn-edge lifecycle. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. Subagent threads also include `parentThreadId` when the immediate parent is known. - `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 63883cbdfe..1aa6e4eeac 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1181,7 +1181,7 @@ impl ThreadRequestProcessor { ) .await?; - let instruction_sources = thread.instruction_sources().await; + let instruction_sources = thread.legacy_instruction_sources().await; let config_snapshot = thread .config_snapshot() .instrument(tracing::info_span!( @@ -2681,7 +2681,7 @@ impl ThreadRequestProcessor { self.outgoing.send_error(request_id, err).await; return Ok(()); } - let instruction_sources = codex_thread.instruction_sources().await; + let instruction_sources = codex_thread.legacy_instruction_sources().await; let SessionConfiguredEvent { rollout_path, .. } = session_configured; let Some(rollout_path) = rollout_path else { let error = @@ -2989,7 +2989,7 @@ impl ThreadRequestProcessor { /*include_turns*/ false, ); thread_summary.session_id = existing_thread.session_configured().session_id.to_string(); - let instruction_sources = existing_thread.instruction_sources().await; + let instruction_sources = existing_thread.legacy_instruction_sources().await; let listener_command_tx = { let thread_state = thread_state.lock().await; @@ -3422,7 +3422,7 @@ impl ThreadRequestProcessor { .map_err(|err| core_thread_write_error("inherit source thread name", err))?; } - let instruction_sources = forked_thread.instruction_sources().await; + let instruction_sources = forked_thread.legacy_instruction_sources().await; // Auto-attach a conversation listener when forking a thread. log_listener_attach_result( diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 890d4da5eb..91de76b99c 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -13,7 +13,7 @@ use codex_protocol::ThreadId; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::RolloutItem; use codex_rollout::state_db::StateDbHandle; -use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::LegacyAppPathString; use std::collections::HashMap; use std::collections::HashSet; use std::sync::Arc; @@ -31,7 +31,7 @@ pub(crate) struct PendingThreadResumeRequest { pub(crate) request_id: ConnectionRequestId, pub(crate) history_items: Vec, pub(crate) config_snapshot: ThreadConfigSnapshot, - pub(crate) instruction_sources: Vec, + pub(crate) instruction_sources: Vec, pub(crate) thread_summary: codex_app_server_protocol::Thread, pub(crate) emit_thread_goal_update: bool, pub(crate) thread_goal_state_db: Option, @@ -200,6 +200,7 @@ mod tests { use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; + use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; #[test] diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index cd508fc687..07ee66caf2 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -82,6 +82,7 @@ use codex_rollout::append_rollout_item_to_path; use codex_rollout::read_session_meta_line; use codex_state::StateRuntime; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::LegacyAppPathString; use core_test_support::responses; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; @@ -288,7 +289,8 @@ async fn thread_resume_running_thread_uses_cached_instruction_sources() -> Resul .. } = to_response::(start_resp)?; let project_agents = AbsolutePathBuf::try_from(project_agents)?; - assert_eq!(instruction_sources, vec![project_agents.clone()]); + let project_agents_source = LegacyAppPathString::from_abs_path(&project_agents); + assert_eq!(instruction_sources, vec![project_agents_source.clone()]); let turn_id = mcp .send_turn_start_request(TurnStartParams { @@ -330,7 +332,7 @@ async fn thread_resume_running_thread_uses_cached_instruction_sources() -> Resul .. } = to_response::(resume_resp)?; - assert_eq!(instruction_sources, vec![project_agents]); + assert_eq!(instruction_sources, vec![project_agents_source]); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index c0946ba679..dcfeb4fa94 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -381,7 +381,7 @@ async fn thread_start_response_includes_loaded_instruction_sources() -> Result<( let instruction_sources = instruction_sources .into_iter() - .map(normalize_path_for_comparison) + .map(|path| normalize_path_for_comparison(path.as_str())) .collect::>(); let expected_instruction_sources = vec![ std::fs::canonicalize(global_agents_path)?, @@ -428,7 +428,7 @@ async fn thread_start_response_excludes_empty_project_instruction_source() -> Re let instruction_sources = instruction_sources .into_iter() - .map(normalize_path_for_comparison) + .map(|path| normalize_path_for_comparison(path.as_str())) .collect::>(); let expected_instruction_sources = vec![normalize_path_for_comparison(std::fs::canonicalize( global_agents_path, @@ -474,7 +474,7 @@ async fn thread_start_without_selected_environment_includes_only_global_instruct assert_eq!( instruction_sources .into_iter() - .map(normalize_path_for_comparison) + .map(|path| normalize_path_for_comparison(path.as_str())) .collect::>(), vec![normalize_path_for_comparison(std::fs::canonicalize( global_agents_path, diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index 861e7b4ac7..2465cb6d55 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -53,16 +53,11 @@ pub(crate) async fn load_project_instructions( let mut loaded = LoadedAgentsMd::from_user_instructions(user_instructions); for turn_environment in &environments.turn_environments { let filesystem = turn_environment.environment.get_filesystem(); - // TODO(anp): Migrate AGENTS.md discovery to PathUri so instructions can be loaded from - // environment-native foreign working directories. - let Ok(cwd) = turn_environment.cwd().to_abs_path() else { - continue; - }; match read_agents_md( config, filesystem.as_ref(), &turn_environment.environment_id, - &cwd, + turn_environment.cwd(), ) .await { @@ -97,7 +92,7 @@ async fn read_agents_md( config: &Config, fs: &dyn ExecutorFileSystem, environment_id: &str, - cwd: &AbsolutePathBuf, + cwd: &PathUri, ) -> io::Result> { let max_total = config.project_doc_max_bytes; @@ -118,15 +113,14 @@ async fn read_agents_md( break; } - let path_uri = PathUri::from_abs_path(&p); - match fs.get_metadata(&path_uri, /*sandbox*/ None).await { + match fs.get_metadata(&p, /*sandbox*/ None).await { Ok(metadata) if !metadata.is_file => continue, Ok(_) => {} Err(err) if err.kind() == io::ErrorKind::NotFound => continue, Err(err) => return Err(err), } - let mut data = match fs.read_file(&path_uri, /*sandbox*/ None).await { + let mut data = match fs.read_file(&p, /*sandbox*/ None).await { Ok(data) => data, Err(err) if err.kind() == io::ErrorKind::NotFound => continue, Err(err) => return Err(err), @@ -139,7 +133,7 @@ async fn read_agents_md( if size > remaining { tracing::warn!( "Project doc `{}` exceeds remaining budget ({} bytes) - truncating.", - p.display(), + p.inferred_native_path_string(), remaining, ); } @@ -169,9 +163,9 @@ async fn read_agents_md( /// directory, inclusive. Symlinks are allowed. async fn agents_md_paths( config: &Config, - cwd: &AbsolutePathBuf, + cwd: &PathUri, fs: &dyn ExecutorFileSystem, -) -> io::Result> { +) -> io::Result> { let dir = cwd.clone(); let mut merged = TomlValue::Table(toml::map::Map::new()); @@ -194,18 +188,18 @@ async fn agents_md_paths( }; let mut project_root = None; if !project_root_markers.is_empty() { - for ancestor in dir.ancestors() { + for current in dir.ancestors() { for marker in &project_root_markers { - let marker_path = ancestor.join(marker); - let marker_path_uri = PathUri::from_abs_path(&marker_path); - let marker_exists = match fs.get_metadata(&marker_path_uri, /*sandbox*/ None).await - { + let marker_path = current + .join(marker) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; + let marker_exists = match fs.get_metadata(&marker_path, /*sandbox*/ None).await { Ok(_) => true, Err(err) if err.kind() == io::ErrorKind::NotFound => false, Err(err) => return Err(err), }; if marker_exists { - project_root = Some(ancestor.clone()); + project_root = Some(current.clone()); break; } } @@ -215,7 +209,7 @@ async fn agents_md_paths( } } - let search_dirs: Vec = if let Some(root) = project_root { + let search_dirs: Vec = if let Some(root) = project_root { let mut dirs = Vec::new(); let mut cursor = dir.clone(); loop { @@ -234,13 +228,14 @@ async fn agents_md_paths( vec![dir] }; - let mut found: Vec = Vec::new(); + let mut found: Vec = Vec::new(); let candidate_filenames = candidate_filenames(config); for d in search_dirs { for name in &candidate_filenames { - let candidate = d.join(name); - let candidate_uri = PathUri::from_abs_path(&candidate); - match fs.get_metadata(&candidate_uri, /*sandbox*/ None).await { + let candidate = d + .join(name) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; + match fs.get_metadata(&candidate, /*sandbox*/ None).await { Ok(md) if md.is_file => { found.push(candidate); break; @@ -371,7 +366,7 @@ impl LoadedAgentsMd { fn environment_labeled_text(&self) -> String { let mut output = String::new(); let mut has_previous = false; - let mut previous_environment: Option<(&str, &AbsolutePathBuf)> = None; + let mut previous_environment: Option<(&str, &PathUri)> = None; if let Some(instructions) = &self.user_instructions { output.push_str(&instructions.text); has_previous = true; @@ -394,7 +389,7 @@ impl LoadedAgentsMd { output.push_str(&format!( "for `{}` with root {}\n\n", environment_id, - cwd.display() + cwd.inferred_native_path_string() )); } output.push_str(&entry.contents); @@ -421,7 +416,7 @@ impl LoadedAgentsMd { None } else { self.single_project_cwd() - .map(|cwd| cwd.to_string_lossy().into_owned()) + .map(PathUri::inferred_native_path_string) }; ContextUserInstructions { directory, @@ -436,10 +431,10 @@ impl LoadedAgentsMd { } /// Returns the AGENTS.md files that supplied instruction entries. - pub fn sources(&self) -> impl Iterator { + pub fn sources(&self) -> impl Iterator + '_ { self.user_instructions .iter() - .map(|instructions| &instructions.source) + .map(|instructions| PathUri::from_abs_path(&instructions.source)) .chain( self.entries .iter() @@ -463,7 +458,7 @@ impl LoadedAgentsMd { }) } - fn single_project_cwd(&self) -> Option<&AbsolutePathBuf> { + fn single_project_cwd(&self) -> Option<&PathUri> { self.entries .iter() .find_map(|entry| match &entry.provenance { @@ -488,9 +483,9 @@ enum InstructionProvenance { /// Workspace instructions discovered from project AGENTS.md files. Project { /// Exact AGENTS.md file, distinct from the environment's selected cwd. - source_path: AbsolutePathBuf, + source_path: PathUri, environment_id: String, - cwd: AbsolutePathBuf, + cwd: PathUri, }, /// Instructions without a file source, including internally defined guidance. @@ -498,9 +493,9 @@ enum InstructionProvenance { } impl InstructionProvenance { - fn path(&self) -> Option<&AbsolutePathBuf> { + fn path(&self) -> Option { match self { - Self::Project { source_path, .. } => Some(source_path), + Self::Project { source_path, .. } => Some(source_path.clone()), Self::Internal => None, } } diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index 42604629a0..ab027112a9 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -250,8 +250,13 @@ async fn load_agents_md(config: &TestConfig) -> Option { .await } -async fn agents_md_paths(config: &TestConfig) -> std::io::Result> { - super::agents_md_paths(&config.config, &config.cwd, LOCAL_FS.as_ref()).await +async fn agents_md_paths(config: &TestConfig) -> std::io::Result> { + super::agents_md_paths( + &config.config, + &PathUri::from_abs_path(&config.cwd), + LOCAL_FS.as_ref(), + ) + .await } fn resolved_local_environments( @@ -277,12 +282,101 @@ fn resolved_local_environments( fn project_provenance(path: AbsolutePathBuf, cwd: AbsolutePathBuf) -> InstructionProvenance { InstructionProvenance::Project { - source_path: path, + source_path: PathUri::from_abs_path(&path), environment_id: "local".to_string(), - cwd, + cwd: PathUri::from_abs_path(&cwd), } } +#[test] +fn foreign_agents_md_uses_environment_native_paths() { + let (cwd, rendered_cwd) = if cfg!(windows) { + ( + PathUri::parse("file:///codex%20runtime").expect("POSIX cwd URI"), + "/codex runtime", + ) + } else { + ( + PathUri::parse("file:///C:/codex%20runtime").expect("Windows cwd URI"), + r"C:\codex runtime", + ) + }; + let source_path = cwd.join("AGENTS.md").expect("AGENTS.md URI"); + let loaded = LoadedAgentsMd { + user_instructions: None, + entries: vec![InstructionEntry { + contents: "remote instructions".to_string(), + provenance: InstructionProvenance::Project { + source_path: source_path.clone(), + environment_id: "remote".to_string(), + cwd, + }, + }], + }; + + assert_eq!( + loaded.render(), + format!( + "# AGENTS.md instructions for {rendered_cwd} + + +remote instructions +" + ) + ); + assert_eq!(loaded.sources().collect::>(), vec![source_path]); +} + +#[test] +fn multi_environment_agents_md_renders_mixed_path_conventions() { + let posix_cwd = PathUri::parse("file:///srv/project").expect("POSIX cwd URI"); + let windows_cwd = PathUri::parse("file:///C:/workspace").expect("Windows cwd URI"); + let posix_source = posix_cwd.join("AGENTS.md").expect("POSIX AGENTS.md URI"); + let windows_source = windows_cwd + .join("AGENTS.md") + .expect("Windows AGENTS.md URI"); + let loaded = LoadedAgentsMd { + user_instructions: None, + entries: vec![ + InstructionEntry { + contents: "POSIX instructions".to_string(), + provenance: InstructionProvenance::Project { + source_path: posix_source.clone(), + environment_id: "posix".to_string(), + cwd: posix_cwd, + }, + }, + InstructionEntry { + contents: "Windows instructions".to_string(), + provenance: InstructionProvenance::Project { + source_path: windows_source.clone(), + environment_id: "windows".to_string(), + cwd: windows_cwd, + }, + }, + ], + }; + + assert_eq!( + loaded.render(), + r#"# AGENTS.md instructions + + +for `posix` with root /srv/project + +POSIX instructions + +for `windows` with root C:\workspace + +Windows instructions +"# + ); + assert_eq!( + loaded.sources().collect::>(), + vec![posix_source, windows_source] + ); +} + /// Helper that returns a `Config` pointing at `root` and using `limit` as /// the maximum number of bytes to embed from AGENTS.md. The caller can /// optionally specify a custom `instructions` string – when `None` the @@ -508,7 +602,7 @@ async fn read_agents_md_propagates_metadata_errors() { }; let cwd = config.cwd.clone(); - let err = read_agents_md(&config.config, &fs, "local", &cwd) + let err = read_agents_md(&config.config, &fs, "local", &PathUri::from_abs_path(&cwd)) .await .expect_err("metadata error"); @@ -526,7 +620,7 @@ async fn read_agents_md_propagates_read_errors() { }; let cwd = config.cwd.clone(); - let err = read_agents_md(&config.config, &fs, "local", &cwd) + let err = read_agents_md(&config.config, &fs, "local", &PathUri::from_abs_path(&cwd)) .await .expect_err("read error"); @@ -544,7 +638,7 @@ async fn read_agents_md_ignores_files_removed_after_discovery() { }; let cwd = config.cwd.clone(); - let loaded = read_agents_md(&config.config, &fs, "local", &cwd) + let loaded = read_agents_md(&config.config, &fs, "local", &PathUri::from_abs_path(&cwd)) .await .expect("removed file is recoverable"); @@ -659,17 +753,18 @@ secondary doc"#, ); assert_eq!(loaded.render(), expected_fragment); assert_eq!( - loaded.sources().cloned().collect::>(), + loaded.sources().collect::>(), vec![ - config - .user_instructions - .as_ref() - .expect("global instructions") - .source - .clone(), - primary.path().join("AGENTS.md").abs(), - primary_nested.join("AGENTS.md").abs(), - secondary.path().join("AGENTS.md").abs(), + PathUri::from_abs_path( + &config + .user_instructions + .as_ref() + .expect("global instructions") + .source, + ), + PathUri::from_abs_path(&primary.path().join("AGENTS.md").abs()), + PathUri::from_abs_path(&primary_nested.join("AGENTS.md").abs()), + PathUri::from_abs_path(&secondary.path().join("AGENTS.md").abs()), ] ); } @@ -898,7 +993,10 @@ async fn concatenates_root_and_cwd_docs() { assert_eq!(loaded.text(), "root doc\n\ncrate doc"); assert_eq!( loaded.sources().collect::>(), - vec![&root_agents, &crate_agents] + vec![ + PathUri::from_abs_path(&root_agents), + PathUri::from_abs_path(&crate_agents), + ] ); } @@ -925,8 +1023,8 @@ async fn project_root_markers_are_honored_for_agents_discovery() { let expected_parent = root.path().join("AGENTS.md").abs(); let expected_child = cfg.cwd.join("AGENTS.md"); assert_eq!(discovery.len(), 2); - assert_eq!(discovery[0], expected_parent); - assert_eq!(discovery[1], expected_child); + assert_eq!(discovery[0], PathUri::from_abs_path(&expected_parent)); + assert_eq!(discovery[1], PathUri::from_abs_path(&expected_child)); let res = get_user_instructions(&cfg).await.expect("doc expected"); assert_eq!(res, "parent doc\n\nchild doc"); @@ -971,8 +1069,8 @@ async fn project_layers_do_not_override_project_root_markers() { assert_eq!( discovery, vec![ - root.path().join("AGENTS.md").abs(), - config.cwd.join("AGENTS.md"), + PathUri::from_abs_path(&root.path().join("AGENTS.md").abs()), + PathUri::from_abs_path(&config.cwd.join("AGENTS.md")), ] ); } @@ -991,7 +1089,10 @@ async fn agents_md_paths_preserve_symlinked_cwd() { cfg.cwd = linked_cwd.abs(); let discovery = agents_md_paths(&cfg).await.expect("discover paths"); - assert_eq!(discovery, vec![cfg.cwd.join("AGENTS.md")]); + assert_eq!( + discovery, + vec![PathUri::from_abs_path(&cfg.cwd.join("AGENTS.md"))] + ); let res = get_user_instructions(&cfg).await.expect("doc expected"); assert_eq!(res, "project doc"); @@ -1050,7 +1151,10 @@ async fn instruction_sources_include_global_before_agents_md_docs() { assert_eq!(loaded.user_instructions(), cfg.user_instructions.as_ref()); assert_eq!( loaded.sources().collect::>(), - vec![&global_agents, &project_agents] + vec![ + PathUri::from_abs_path(&global_agents), + PathUri::from_abs_path(&project_agents), + ] ); assert_eq!( loaded.text(), @@ -1091,7 +1195,10 @@ async fn child_agents_message_after_project_docs_is_not_an_instruction_source() assert_eq!(loaded, expected); assert_eq!( loaded.sources().collect::>(), - vec![&global_agents, &project_agents] + vec![ + PathUri::from_abs_path(&global_agents), + PathUri::from_abs_path(&project_agents), + ] ); assert_eq!( loaded.text(), @@ -1117,8 +1224,8 @@ async fn agents_local_md_preferred() { let discovery = agents_md_paths(&cfg).await.expect("discover paths"); assert_eq!(discovery.len(), 1); assert_eq!( - discovery[0].file_name().unwrap().to_string_lossy(), - LOCAL_AGENTS_MD_FILENAME + discovery[0].basename().as_deref(), + Some(LOCAL_AGENTS_MD_FILENAME) ); } @@ -1166,12 +1273,9 @@ async fn agents_md_preferred_over_fallbacks() { let discovery = agents_md_paths(&cfg).await.expect("discover paths"); assert_eq!(discovery.len(), 1); - assert!( - discovery[0] - .file_name() - .unwrap() - .to_string_lossy() - .eq(DEFAULT_AGENTS_MD_FILENAME) + assert_eq!( + discovery[0].basename().as_deref(), + Some(DEFAULT_AGENTS_MD_FILENAME) ); } @@ -1186,7 +1290,7 @@ async fn agents_md_directory_is_ignored() { assert_eq!(res, None); let discovery = agents_md_paths(&cfg).await.expect("discover paths"); - assert_eq!(discovery, Vec::::new()); + assert_eq!(discovery, Vec::::new()); } #[cfg(unix)] @@ -1209,7 +1313,7 @@ async fn agents_md_special_file_is_ignored() { assert_eq!(res, None); let discovery = agents_md_paths(&cfg).await.expect("discover paths"); - assert_eq!(discovery, Vec::::new()); + assert_eq!(discovery, Vec::::new()); } #[tokio::test] @@ -1228,11 +1332,8 @@ async fn override_directory_falls_back_to_agents_md_file() { let discovery = agents_md_paths(&cfg).await.expect("discover paths"); assert_eq!(discovery.len(), 1); assert_eq!( - discovery[0] - .file_name() - .expect("file name") - .to_string_lossy(), - DEFAULT_AGENTS_MD_FILENAME + discovery[0].basename().as_deref(), + Some(DEFAULT_AGENTS_MD_FILENAME) ); } diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 8c44857297..ab46d8b3ab 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -42,6 +42,7 @@ use codex_thread_store::ThreadMetadataPatch; use codex_thread_store::ThreadStoreError; use codex_thread_store::ThreadStoreResult; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::LegacyAppPathString; use codex_utils_path_uri::PathUri; use rmcp::model::ReadResourceRequestParams; use std::collections::BTreeMap; @@ -564,10 +565,19 @@ impl CodexThread { } /// Returns the files that supplied the thread's loaded model instructions. - pub async fn instruction_sources(&self) -> Vec { + pub async fn instruction_sources(&self) -> Vec { self.codex.instruction_sources().await } + /// Returns loaded instruction sources rendered as legacy app-server path strings. + pub async fn legacy_instruction_sources(&self) -> Vec { + self.instruction_sources() + .await + .into_iter() + .map(Into::into) + .collect() + } + pub async fn config(&self) -> Arc { self.codex.session.get_config().await } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index b55c4b11d7..5d579f14a8 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -830,15 +830,13 @@ impl Codex { state.session_configuration.thread_config_snapshot() } - pub(crate) async fn instruction_sources(&self) -> Vec { + pub(crate) async fn instruction_sources(&self) -> Vec { let state = self.session.state.lock().await; state .session_configuration .loaded_agents_md .as_ref() - .map_or_else(Vec::new, |instructions| { - instructions.sources().cloned().collect() - }) + .map_or_else(Vec::new, |instructions| instructions.sources().collect()) } pub(crate) async fn thread_environment_selections(&self) -> Vec { diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 42b9bf87ca..e0e7b20287 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -58,6 +58,7 @@ use tempfile::TempDir; use wiremock::MockServer; use crate::TempDirExt; +use crate::TestEnvironment; use crate::get_remote_test_env; use crate::load_default_config_for_test; use crate::load_default_config_for_test_with_cloud_config_bundle; @@ -179,7 +180,20 @@ pub async fn test_env() -> Result { /*sandbox*/ None, ) .await?; - let cwd = cwd_uri.to_abs_path()?; + let cwd = if remote_env == TestEnvironment::WineExec { + // TODO(anp): Convert `Config::cwd` to `LegacyAppPathString` and remove this + // compatibility projection. + // `Config::cwd` still requires `AbsolutePathBuf`. Preserve the test harness's + // Linux-absolute `/C:/...` compatibility spelling so converting it back to a + // `PathUri` recovers the remote Windows convention. Production conversions stay + // strict: `PathUri::to_abs_path` intentionally rejects foreign paths. + let path = cwd_uri.to_url().to_file_path().map_err(|()| { + anyhow!("remote test cwd URI cannot be projected onto the host: {cwd_uri}") + })?; + AbsolutePathBuf::try_from(path)? + } else { + cwd_uri.to_abs_path()? + }; Ok(TestEnv { environment, exec_server_url: Some(websocket_url), diff --git a/codex-rs/core/tests/remote_env_windows/BUILD.bazel b/codex-rs/core/tests/remote_env_windows/BUILD.bazel index 5e213ac14b..9b5ce52bd9 100644 --- a/codex-rs/core/tests/remote_env_windows/BUILD.bazel +++ b/codex-rs/core/tests/remote_env_windows/BUILD.bazel @@ -22,7 +22,6 @@ wine_rust_test( "//codex-rs/protocol", "//codex-rs/utils/path-uri", "@crates//:anyhow", - "@crates//:base64", "@crates//:pretty_assertions", "@crates//:serde_json", "@crates//:tempfile", diff --git a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs index cff8ded739..04f8914982 100644 --- a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs +++ b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs @@ -36,11 +36,13 @@ use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; use codex_utils_path_uri::LegacyAppPathString; +use codex_utils_path_uri::PathConvention; use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; use std::collections::BTreeMap; +use std::fs; use tempfile::TempDir; use tokio::time::timeout; use wine_exec_server_test_support::WineExecServer; @@ -57,7 +59,7 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { const VERIFY_COMMAND: &str = r#"$path = Join-Path (Get-Location) 'codex-apply-patch-smoke.txt'; if (-not (Test-Path $path)) { exit 1 }; if ([IO.File]::ReadAllText($path) -ne "patched through unified exec`n") { exit 2 }; Remove-Item $path; Write-Output 'PATCH_VERIFIED'"#; WineExecServer - .scope(|exec_server_url| async move { + .scope(|exec_server_url, _wine_prefix| async move { let server = start_mock_server().await; let arguments = serde_json::to_string(&json!({ "cmd": COMMAND, @@ -245,8 +247,17 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn app_server_starts_thread_with_windows_environment_native_cwd() -> Result<()> { + const AGENTS_INSTRUCTIONS: &str = "remote Windows workspace instructions"; + const NATIVE_CWD: &str = r"C:\windows"; + WineExecServer - .scope(|exec_server_url| async move { + .scope(|exec_server_url, wine_prefix| async move { + let agents_path = PathUri::parse("file:///C:/windows/AGENTS.md")?; + fs::write( + wine_prefix.join("drive_c").join("windows").join("AGENTS.md"), + AGENTS_INSTRUCTIONS, + )?; + let codex_home = TempDir::new()?; let server = create_mock_responses_server_repeating_assistant("done").await; write_mock_responses_config_toml( @@ -272,7 +283,7 @@ async fn app_server_starts_thread_with_windows_environment_native_cwd() -> Resul .send_thread_start_request(ThreadStartParams { environments: Some(vec![TurnEnvironmentParams { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), - cwd: serde_json::from_value::(json!(r"C:\windows"))?, + cwd: serde_json::from_value::(json!(NATIVE_CWD))?, }]), ..Default::default() }) @@ -289,8 +300,13 @@ async fn app_server_starts_thread_with_windows_environment_native_cwd() -> Resul assert_eq!(response.cwd, host_cwd); // TODO(anp): Derive runtime workspace roots from the selected remote environment. assert_eq!(response.runtime_workspace_roots, vec![host_cwd]); - // TODO(anp): Discover and report instruction sources from the remote filesystem. - assert_eq!(response.instruction_sources, Vec::new()); + assert_eq!( + response.instruction_sources, + vec![LegacyAppPathString::from_path_uri( + &agents_path, + PathConvention::Windows, + )?] + ); // TODO(anp): Report the implicit built-in permission profile instead of None. assert_eq!(response.active_permission_profile, None); @@ -326,6 +342,17 @@ async fn app_server_starts_thread_with_windows_environment_native_cwd() -> Resul .find(|request| request.url.path().ends_with("/responses")) .context("turn should send a Responses request")?; let body = first_request.body_json::()?; + let remote_instructions = body["input"] + .as_array() + .into_iter() + .flatten() + .filter(|item| item.get("role").and_then(Value::as_str) == Some("user")) + .filter_map(|item| item.get("content").and_then(Value::as_array)) + .flatten() + .filter_map(|content| content.get("text").and_then(Value::as_str)) + .find(|text| text.contains(AGENTS_INSTRUCTIONS)) + .context("remote workspace instructions should be model visible")?; + assert!(remote_instructions.contains(r"# AGENTS.md instructions for C:\windows")); let environment_context = body["input"] .as_array() .into_iter() diff --git a/codex-rs/core/tests/suite/agents_md.rs b/codex-rs/core/tests/suite/agents_md.rs index 5b79e9f1e6..e3a08447c5 100644 --- a/codex-rs/core/tests/suite/agents_md.rs +++ b/codex-rs/core/tests/suite/agents_md.rs @@ -25,7 +25,6 @@ use core_test_support::responses::mount_sse_once; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; -use core_test_support::skip_if_wine_exec; use core_test_support::test_codex::RecordingUserInstructionsProvider; use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::test_codex; @@ -88,7 +87,7 @@ fn instruction_fragments(request: &responses::ResponsesRequest) -> Vec { } fn expected_instruction_fragment(cwd: &AbsolutePathBuf, contents: &str) -> String { - let cwd = cwd.as_path().display(); + let cwd = PathUri::from_abs_path(cwd).inferred_native_path_string(); format!("# AGENTS.md instructions for {cwd}\n\n\n{contents}\n") } @@ -138,8 +137,6 @@ fn request_body_contains(request: &wiremock::Request, text: &str) -> bool { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn agents_override_is_preferred_over_agents_md() -> Result<()> { - // TODO(anp): Remove after instruction-source helpers use target-native paths. - skip_if_wine_exec!(Ok(()), "requires native cross-OS instruction-source paths"); let instructions = agents_instructions(test_codex().with_workspace_setup(|cwd, fs| async move { let agents_md = cwd.join("AGENTS.md"); @@ -172,8 +169,6 @@ async fn agents_override_is_preferred_over_agents_md() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn configured_fallback_is_used_when_agents_candidate_is_directory() -> Result<()> { - // TODO(anp): Remove after instruction-source helpers use target-native paths. - skip_if_wine_exec!(Ok(()), "requires native cross-OS instruction-source paths"); let instructions = agents_instructions( test_codex() .with_config(|config| { @@ -334,8 +329,8 @@ async fn symlinked_cwd_uses_logical_parent_for_agents_discovery() -> Result<()> assert_eq!( test.codex.instruction_sources().await, vec![ - logical_root.join("AGENTS.md"), - test.config.cwd.join("AGENTS.md") + PathUri::from_abs_path(&logical_root.join("AGENTS.md")), + PathUri::from_abs_path(&test.config.cwd.join("AGENTS.md")) ] ); @@ -355,8 +350,6 @@ async fn symlinked_cwd_uses_logical_parent_for_agents_discovery() -> Result<()> #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn selected_environment_sources_match_model_visible_instructions() -> Result<()> { - // TODO(anp): Remove after instruction-source helpers use target-native paths. - skip_if_wine_exec!(Ok(()), "requires native cross-OS instruction-source paths"); let server = start_mock_server().await; let resp_mock = mount_sse_once( &server, @@ -385,7 +378,10 @@ async fn selected_environment_sources_match_model_visible_instructions() -> Resu assert_eq!( test.codex.instruction_sources().await, - vec![global_agents, project_agents] + vec![ + PathUri::from_abs_path(&global_agents), + PathUri::from_abs_path(&project_agents), + ] ); test.submit_turn("hello").await?; @@ -454,7 +450,7 @@ async fn loads_user_instructions_without_a_primary_environment() -> Result<()> { assert_eq!(provider.load_count(), 2); assert_eq!( no_environment_thread.thread.instruction_sources().await, - vec![global_source] + vec![PathUri::from_abs_path(&global_source)] ); no_environment_thread @@ -485,8 +481,6 @@ async fn loads_user_instructions_without_a_primary_environment() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Result<()> { - // TODO(anp): Remove after instruction-source helpers use target-native paths. - skip_if_wine_exec!(Ok(()), "requires native cross-OS instruction-source paths"); // Set up one global source, one project source, and two ordinary model turns. let server = responses::start_mock_server().await; let response_mock = responses::mount_sse_sequence( @@ -521,7 +515,10 @@ async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Re }); let test = builder.build_with_remote_env(&server).await?; let project_source = test.config.cwd.join(GLOBAL_AGENTS_FILENAME); - let creation_sources = vec![global_source.clone(), project_source.clone()]; + let creation_sources = vec![ + PathUri::from_abs_path(&global_source), + PathUri::from_abs_path(&project_source), + ]; // Confirm the thread records both creation-time sources in composition order. assert_eq!(test.codex.instruction_sources().await, creation_sources); @@ -597,8 +594,6 @@ async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Re #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapshot() -> Result<()> { - // TODO(anp): Remove after instruction-source helpers use target-native paths. - skip_if_wine_exec!(Ok(()), "requires native cross-OS instruction-source paths"); skip_if_no_network!(Ok(())); let Some(_remote_env) = get_remote_test_env() else { return Ok(()); @@ -672,9 +667,9 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho assert_eq!( thread.thread.instruction_sources().await, vec![ - global_source.clone(), - remote_source.clone(), - local_source.clone().try_into()?, + PathUri::from_abs_path(&global_source), + PathUri::from_abs_path(&remote_source), + PathUri::from_path(&local_source)?, ] ); @@ -700,7 +695,7 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho let contents = format!( "{GLOBAL_INSTRUCTIONS}\n\nfor `{REMOTE_ENVIRONMENT_ID}` with root {}\n\nremote project instructions\n\nfor `{LOCAL_ENVIRONMENT_ID}` with root {}\n\nlocal project instructions", - test.config.cwd.display(), + PathUri::from_abs_path(&test.config.cwd).inferred_native_path_string(), local_root.path().display(), ); let expected = @@ -712,7 +707,11 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho assert_eq!(provider.load_count(), 2); assert_eq!( thread.thread.instruction_sources().await, - vec![global_source, remote_source, local_source.try_into()?] + vec![ + PathUri::from_abs_path(&global_source), + PathUri::from_abs_path(&remote_source), + PathUri::from_path(&local_source)?, + ] ); Ok(()) @@ -741,7 +740,10 @@ async fn invalid_utf8_global_instructions_are_lossy() -> Result<()> { test.submit_turn("inspect lossy global instructions") .await?; - assert_eq!(test.codex.instruction_sources().await, vec![source.clone()]); + assert_eq!( + test.codex.instruction_sources().await, + vec![PathUri::from_abs_path(&source)] + ); let expected_fragment = expected_provider_only_instruction_fragment("global\u{FFFD}instructions"); assert_single_instruction_fragment(&response_mock.single_request(), &expected_fragment); @@ -784,7 +786,7 @@ async fn cold_resume_replays_rendered_instructions_but_reports_current_config_so // Assert the pre-resume thread reports the source used to create its snapshot. assert_eq!( initial.codex.instruction_sources().await, - vec![old_source.clone()], + vec![PathUri::from_abs_path(&old_source)], "initial thread reports the creation-time global source" ); initial.submit_turn("persist instructions").await?; @@ -814,7 +816,7 @@ async fn cold_resume_replays_rendered_instructions_but_reports_current_config_so // Assert the API reports the new source while model history replays the old structured prefix. assert_eq!( resumed.codex.instruction_sources().await, - vec![new_source], + vec![PathUri::from_abs_path(&new_source)], "resume reports sources from the newly loaded config" ); @@ -868,7 +870,7 @@ async fn fork_replays_rendered_instructions_from_shared_history() -> Result<()> // Assert the parent reports the source used to create its snapshot. assert_eq!( parent.codex.instruction_sources().await, - vec![source.clone()], + vec![PathUri::from_abs_path(&source)], "parent reports the creation-time global source" ); parent.submit_turn("persist instructions").await?; @@ -903,7 +905,7 @@ async fn fork_replays_rendered_instructions_from_shared_history() -> Result<()> // Assert the fork reports the new source before issuing its first turn. assert_eq!( forked.thread.instruction_sources().await, - vec![new_source], + vec![PathUri::from_abs_path(&new_source)], "fork config should reflect the newly loaded global source" ); @@ -1033,7 +1035,7 @@ async fn run_subagent_global_instruction_case(fork_context: bool) -> Result<()> // Assert the parent reports the creation-time source before spawning. assert_eq!( test.codex.instruction_sources().await, - vec![source.clone()], + vec![PathUri::from_abs_path(&source)], "parent reports the creation-time global source before spawning" ); test.submit_turn(SPAWN_SEED_PROMPT).await?; @@ -1076,12 +1078,12 @@ async fn run_subagent_global_instruction_case(fork_context: bool) -> Result<()> assert_single_instruction_fragment(&child_request, &expected_fragment); assert_eq!( test.codex.instruction_sources().await, - vec![source.clone()], + vec![PathUri::from_abs_path(&source)], "running parent retains the creation-time global source after spawning" ); assert_eq!( child_thread.instruction_sources().await, - vec![source], + vec![PathUri::from_abs_path(&source)], "subagent reports the parent's creation-time source" ); if fork_context { diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 3b709d54a2..92ec66e161 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -25,6 +25,7 @@ use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::WarningEvent; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use core_test_support::PathBufExt; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; @@ -4593,7 +4594,7 @@ async fn manual_compaction_keeps_the_creation_time_global_instructions() -> Resu // Assert the pre-compaction source list points at the creation-time file. assert_eq!( test.codex.instruction_sources().await, - vec![source.clone()], + vec![PathUri::from_abs_path(&source)], "thread reports the creation-time global source before compaction" ); @@ -4623,7 +4624,7 @@ async fn manual_compaction_keeps_the_creation_time_global_instructions() -> Resu assert_single_instruction_fragment(&requests[2], &expected_fragment); assert_eq!( test.codex.instruction_sources().await, - vec![source], + vec![PathUri::from_abs_path(&source)], "thread retains the creation-time global source after compaction" ); @@ -4673,7 +4674,7 @@ async fn mid_turn_compaction_keeps_the_creation_time_global_instructions() -> Re // Assert the pre-compaction source list points at the creation-time file. assert_eq!( test.codex.instruction_sources().await, - vec![source.clone()], + vec![PathUri::from_abs_path(&source)], "thread reports the creation-time global source before mid-turn compaction" ); @@ -4695,7 +4696,7 @@ async fn mid_turn_compaction_keeps_the_creation_time_global_instructions() -> Re assert_single_instruction_fragment(&requests[2], &expected_fragment); assert_eq!( test.codex.instruction_sources().await, - vec![source], + vec![PathUri::from_abs_path(&source)], "thread retains the creation-time global source after mid-turn compaction" ); @@ -4780,7 +4781,7 @@ async fn remote_v2_compaction_keeps_creation_time_instructions_after_same_path_m ); assert_eq!( test.codex.instruction_sources().await, - vec![source.clone()], + vec![PathUri::from_abs_path(&source)], "running thread retains the selected same-path source" ); assert_eq!( @@ -4829,7 +4830,7 @@ async fn remote_v2_compaction_keeps_creation_time_instructions_after_same_path_m ); assert_eq!( resumed.codex.instruction_sources().await, - vec![source], + vec![PathUri::from_abs_path(&source)], "cold-resumed thread reports the same rewritten source path" ); diff --git a/codex-rs/core/tests/suite/hierarchical_agents.rs b/codex-rs/core/tests/suite/hierarchical_agents.rs index f017d7d42b..c8c5da94b7 100644 --- a/codex-rs/core/tests/suite/hierarchical_agents.rs +++ b/codex-rs/core/tests/suite/hierarchical_agents.rs @@ -5,7 +5,6 @@ use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; -use core_test_support::skip_if_wine_exec; use core_test_support::test_codex::test_codex; const HIERARCHICAL_AGENTS_SNIPPET: &str = @@ -13,8 +12,6 @@ const HIERARCHICAL_AGENTS_SNIPPET: &str = #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn hierarchical_agents_appends_to_project_doc_in_user_instructions() { - // TODO(anp): Remove after instruction-source helpers use target-native paths. - skip_if_wine_exec!("requires native cross-OS instruction-source paths"); let server = start_mock_server().await; let resp_mock = mount_sse_once( &server, diff --git a/codex-rs/exec-server/testing/wine_exec_server.rs b/codex-rs/exec-server/testing/wine_exec_server.rs index 88857e785a..a3643f8668 100644 --- a/codex-rs/exec-server/testing/wine_exec_server.rs +++ b/codex-rs/exec-server/testing/wine_exec_server.rs @@ -1,6 +1,7 @@ //! Test support for running the Windows exec-server under Wine. use std::future::Future; +use std::path::PathBuf; use anyhow::Context; use anyhow::Result; @@ -12,16 +13,18 @@ use wine_test_support::WineTestCommand; pub struct WineExecServer; impl WineExecServer { - /// Starts the server, passes its WebSocket URL to `operation`, and tears it down afterward. + /// Starts the server, passes its WebSocket URL and Wine prefix to `operation`, and tears it + /// down afterward. pub async fn scope(self, operation: F) -> Result where - F: FnOnce(String) -> Fut, + F: FnOnce(String, PathBuf) -> Fut, Fut: Future>, { let executable = codex_utils_cargo_bin::cargo_bin("wine-windows-exec-server")?; let mut exec_server = WineTestCommand::new(executable) .env("CODEX_HOME", r"C:\codex-home") .spawn()?; + let wine_prefix = exec_server.prefix_path().to_path_buf(); let stdout = exec_server.take_stdout(); exec_server @@ -36,7 +39,7 @@ impl WineExecServer { break line; } }; - operation(exec_server_url).await + operation(exec_server_url, wine_prefix).await }) .await } diff --git a/codex-rs/exec-server/testing/wine_remote_test_runner.rs b/codex-rs/exec-server/testing/wine_remote_test_runner.rs index 2889415675..6e73e68e9b 100644 --- a/codex-rs/exec-server/testing/wine_remote_test_runner.rs +++ b/codex-rs/exec-server/testing/wine_remote_test_runner.rs @@ -33,7 +33,7 @@ async fn main() -> Result<()> { } WineExecServer - .scope(|exec_server_url| async move { + .scope(|exec_server_url, _wine_prefix| async move { let mut command = Command::new(test_binary); command .env(TEST_ENVIRONMENT_ENV_VAR, "wine-exec") diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index b000cba124..2a10f00b42 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -62,6 +62,7 @@ codex-utils-fuzzy-match = { workspace = true } codex-utils-home-dir = { workspace = true } codex-utils-oss = { workspace = true } codex-utils-path = { workspace = true } +codex-utils-path-uri = { workspace = true } codex-utils-plugins = { workspace = true } codex-utils-sandbox-summary = { workspace = true } codex-utils-sleep-inhibitor = { workspace = true } @@ -151,7 +152,6 @@ codex-cli = { workspace = true } codex-mcp = { workspace = true } core_test_support = { workspace = true } codex-utils-cargo-bin = { workspace = true } -codex-utils-path-uri = { workspace = true } assert_matches = { workspace = true } chrono = { workspace = true, features = ["serde"] } insta = { workspace = true } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index e57b26840d..a3734ac0e2 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -120,6 +120,7 @@ use codex_protocol::openai_models::ModelServiceTier; use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -1561,7 +1562,7 @@ async fn thread_session_state_from_thread_start_response( response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.runtime_workspace_roots.clone(), - response.instruction_sources.clone(), + response.instruction_source_path_uris(), response.reasoning_effort.clone(), config, ) @@ -1602,7 +1603,7 @@ async fn thread_session_state_from_thread_resume_response( response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.runtime_workspace_roots.clone(), - response.instruction_sources.clone(), + response.instruction_source_path_uris(), response.reasoning_effort.clone(), config, ) @@ -1634,7 +1635,7 @@ async fn thread_session_state_from_thread_fork_response( response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.runtime_workspace_roots.clone(), - response.instruction_sources.clone(), + response.instruction_source_path_uris(), response.reasoning_effort.clone(), config, ) @@ -1673,7 +1674,7 @@ async fn thread_session_state_from_thread_response( active_permission_profile: Option, cwd: AbsolutePathBuf, runtime_workspace_roots: Vec, - instruction_source_paths: Vec, + instruction_source_paths: Vec, reasoning_effort: Option, config: &Config, ) -> Result { @@ -1759,6 +1760,7 @@ mod tests { use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; + use codex_utils_path_uri::LegacyAppPathString; use pretty_assertions::assert_eq; use tempfile::TempDir; @@ -2367,7 +2369,9 @@ mod tests { test_path_buf("/tmp/project").abs(), test_path_buf("/tmp/project/extra").abs(), ], - instruction_sources: vec![test_path_buf("/tmp/project/AGENTS.md").abs()], + instruction_sources: vec![LegacyAppPathString::from_abs_path( + &test_path_buf("/tmp/project/AGENTS.md").abs(), + )], approval_policy: codex_app_server_protocol::AskForApproval::Never, approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::User, sandbox: read_only_profile @@ -2393,7 +2397,7 @@ mod tests { ); assert_eq!( started.session.instruction_source_paths, - response.instruction_sources + response.instruction_source_path_uris() ); assert_eq!(started.session.permission_profile, read_only_profile); assert_eq!(started.turns.len(), 1); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 94cdd9e03d..fd3c140e3f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -163,6 +163,7 @@ use codex_terminal_detection::TerminalName; use codex_terminal_detection::terminal_info; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::resume_hint; +use codex_utils_path_uri::PathUri; use codex_utils_plugins::mention_syntax::PLUGIN_TEXT_MENTION_SIGIL; use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL; use crossterm::event::KeyCode; @@ -687,7 +688,7 @@ pub(crate) struct ChatWidget { // App-server-backed command runner for status-line workspace metadata lookups. workspace_command_runner: Option, // Instruction source files loaded for the current session, supplied by app-server. - instruction_source_paths: Vec, + instruction_source_paths: Vec, // Runtime network proxy bind addresses from SessionConfigured. session_network_proxy: Option, // Shared latch so we only warn once about invalid status-line item IDs. diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 6fe4500feb..73d033de03 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -589,7 +589,9 @@ async fn ctrl_d_with_modal_open_does_not_quit() { #[tokio::test] async fn slash_init_does_not_depend_on_loaded_instruction_sources() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.instruction_source_paths = vec![chat.config.cwd.join("project-instructions.md")]; + chat.instruction_source_paths = vec![codex_utils_path_uri::PathUri::from_abs_path( + &chat.config.cwd.join("project-instructions.md"), + )]; submit_composer_text(&mut chat, "/init"); diff --git a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs index c206313549..953de6b89c 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs @@ -1,5 +1,6 @@ use super::*; use assert_matches::assert_matches; +use codex_utils_path_uri::PathUri; #[tokio::test] async fn status_command_renders_immediately_and_refreshes_rate_limits_for_chatgpt_auth() { @@ -96,9 +97,23 @@ async fn status_command_uses_catalog_default_reasoning_when_config_empty() { } #[tokio::test] -async fn status_command_renders_instruction_sources_from_thread_session() { +async fn status_command_renders_native_and_foreign_instruction_sources() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.instruction_source_paths = vec![chat.config.cwd.join("AGENTS.md")]; + let (foreign_source, foreign_display) = if cfg!(windows) { + ( + PathUri::parse("file:///remote/AGENTS.md").expect("POSIX instruction source"), + "/remote/AGENTS.md", + ) + } else { + ( + PathUri::parse("file:///C:/remote/AGENTS.md").expect("Windows instruction source"), + r"C:\remote\AGENTS.md", + ) + }; + chat.instruction_source_paths = vec![ + PathUri::from_abs_path(&chat.config.cwd.join("AGENTS.md")), + foreign_source, + ]; chat.dispatch_command(SlashCommand::Status); @@ -109,8 +124,8 @@ async fn status_command_renders_instruction_sources_from_thread_session() { other => panic!("expected status output, got {other:?}"), }; assert!( - rendered.contains("Agents.md"), - "expected /status to render app-server instruction sources, got: {rendered}" + rendered.contains(&format!("AGENTS.md, {foreign_display}")), + "expected /status to show native-relative and environment-native foreign paths, got: {rendered}" ); assert!( !rendered.contains("Agents.md "), diff --git a/codex-rs/tui/src/session_state.rs b/codex-rs/tui/src/session_state.rs index 7988ba2264..059e148a79 100644 --- a/codex-rs/tui/src/session_state.rs +++ b/codex-rs/tui/src/session_state.rs @@ -12,6 +12,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; #[derive(Debug, Clone, PartialEq)] pub(crate) struct SessionNetworkProxyRuntime { @@ -47,7 +48,7 @@ pub(crate) struct ThreadSessionState { pub(crate) active_permission_profile: Option, pub(crate) cwd: AbsolutePathBuf, pub(crate) runtime_workspace_roots: Vec, - pub(crate) instruction_source_paths: Vec, + pub(crate) instruction_source_paths: Vec, pub(crate) reasoning_effort: Option, pub(crate) collaboration_mode: Option>, pub(crate) personality: Option, diff --git a/codex-rs/tui/src/status/helpers.rs b/codex-rs/tui/src/status/helpers.rs index f3ceeb435f..f03336a4b0 100644 --- a/codex-rs/tui/src/status/helpers.rs +++ b/codex-rs/tui/src/status/helpers.rs @@ -5,7 +5,8 @@ use crate::text_formatting; use chrono::DateTime; use chrono::Local; use codex_protocol::account::PlanType; -use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathConvention; +use codex_utils_path_uri::PathUri; use std::path::Path; use unicode_width::UnicodeWidthStr; @@ -33,10 +34,20 @@ pub(crate) fn compose_model_display( (model_name.to_string(), details) } -pub(crate) fn compose_agents_summary(config: &Config, paths: &[AbsolutePathBuf]) -> String { +pub(crate) fn compose_agents_summary(config: &Config, paths: &[PathUri]) -> String { let mut rels: Vec = Vec::new(); - for p in paths { + for path in paths { + // TODO(anp): Rationalize instruction-source summaries with the TUI's broader foreign-path + // display strategy once other status surfaces can retain environment-native paths. + if path.infer_path_convention() != Some(PathConvention::native()) { + rels.push(path.inferred_native_path_string()); + continue; + } + let Ok(p) = path.to_abs_path() else { + rels.push(path.inferred_native_path_string()); + continue; + }; let p = p.as_path(); let file_name = p .file_name() @@ -229,7 +240,10 @@ mod tests { let config = test_config(&codex_home, &cwd).await; assert_eq!( - compose_agents_summary(&config, &[global_agents_path.abs()]), + compose_agents_summary( + &config, + &[PathUri::from_abs_path(&global_agents_path.abs())] + ), format_directory_display(&global_agents_path, /*max_width*/ None) ); } @@ -242,11 +256,33 @@ mod tests { let config = test_config(&codex_home, &cwd).await; assert_eq!( - compose_agents_summary(&config, &[override_path.abs()]), + compose_agents_summary(&config, &[PathUri::from_abs_path(&override_path.abs())]), format_directory_display(&override_path, /*max_width*/ None) ); } + #[tokio::test] + async fn compose_agents_summary_shows_relative_native_and_full_foreign_paths() { + let codex_home = TempDir::new().expect("temp codex home"); + let cwd = TempDir::new().expect("temp cwd"); + let config = test_config(&codex_home, &cwd).await; + let native_source = PathUri::from_abs_path(&config.cwd.join("AGENTS.md")); + let foreign_source = if cfg!(windows) { + PathUri::parse("file:///remote%20workspace/AGENTS.md") + .expect("POSIX instruction source") + } else { + PathUri::parse("file:///C:/remote%20workspace/AGENTS.md") + .expect("Windows instruction source") + }; + + let summary = compose_agents_summary(&config, &[native_source, foreign_source]); + if cfg!(windows) { + insta::assert_snapshot!(summary, @r"AGENTS.md, /remote workspace/AGENTS.md"); + } else { + insta::assert_snapshot!(summary, @r"AGENTS.md, C:\remote workspace\AGENTS.md"); + } + } + #[tokio::test] async fn compose_agents_summary_orders_global_before_project_agents() { let codex_home = TempDir::new().expect("temp codex home"); @@ -258,8 +294,8 @@ mod tests { let summary = compose_agents_summary( &config, &[ - global_agents_path.clone().abs(), - project_agents_path.clone().abs(), + PathUri::from_abs_path(&global_agents_path.clone().abs()), + PathUri::from_abs_path(&project_agents_path.clone().abs()), ], ); let mut paths = summary.split(", "); diff --git a/codex-rs/utils/path-uri/src/lib.rs b/codex-rs/utils/path-uri/src/lib.rs index 6bc80b5561..5ef1df2fa2 100644 --- a/codex-rs/utils/path-uri/src/lib.rs +++ b/codex-rs/utils/path-uri/src/lib.rs @@ -209,13 +209,28 @@ impl PathUri { PathBuf::from(self.inferred_native_path_string()) } - /// Returns the parent URI, or `None` for the URI root or an opaque fallback - /// URI created by [`Self::from_abs_path`]. + /// Returns the lexical parent without crossing the inferred native path root. + /// + /// POSIX `/`, Windows drive roots, Windows UNC share roots, and opaque fallback + /// URIs created by [`Self::from_abs_path`] have no parent. pub fn parent(&self) -> Option { - if self.encoded_path() == "/" || decode_bad_path_uri(&self.0).is_some() { + if decode_bad_path_uri(&self.0).is_some() { return None; } + let convention = self.infer_path_convention()?; + // In URI form, both a Windows drive root (`file:///C:`) and a UNC share root + // (`file://server/share`) retain one non-empty path segment. Keep that segment as the + // anchor so parent traversal cannot produce a URI that is not an absolute Windows path. + let anchor_depth = usize::from(convention == PathConvention::Windows); + let depth = self + .0 + .path_segments()? + .filter(|segment| !segment.is_empty()) + .count(); + if depth <= anchor_depth { + return None; + } let mut url = self.0.clone(); { let mut segments = match url.path_segments_mut() { @@ -227,6 +242,11 @@ impl PathUri { Some(Self(url)) } + /// Returns this URI and each lexical parent up to its inferred native path root. + pub fn ancestors(&self) -> impl Iterator { + std::iter::successors(Some(self.clone()), Self::parent) + } + /// Lexically resolves native absolute or relative path text against this URI. /// /// Path text is interpreted using the POSIX or Windows convention inferred diff --git a/codex-rs/utils/path-uri/src/tests.rs b/codex-rs/utils/path-uri/src/tests.rs index 328142dc6d..075d343d01 100644 --- a/codex-rs/utils/path-uri/src/tests.rs +++ b/codex-rs/utils/path-uri/src/tests.rs @@ -522,7 +522,7 @@ fn path_buf_uses_the_inferred_native_spelling() { } #[test] -fn parent_uses_uri_hierarchy_and_preserves_authority() { +fn parent_stops_at_posix_drive_and_unc_roots() { for (input, expected) in [ ( "file:///workspace/src/lib.rs", @@ -531,12 +531,13 @@ fn parent_uses_uri_hierarchy_and_preserves_authority() { ("file:///workspace", Some("file:///")), ("file:///", None), ("file:///C:/Users", Some("file:///C:")), - ("file:///C:/", Some("file:///")), + ("file:///C:/", None), + ("file:///C:", None), ( "file://server/share/src/main.rs", Some("file://server/share/src"), ), - ("file://server/share", Some("file://server/")), + ("file://server/share", None), ] { let uri = PathUri::parse(input).expect("valid file URI"); let expected = expected.map(|value| PathUri::parse(value).expect("valid expected URI")); @@ -544,6 +545,35 @@ fn parent_uses_uri_hierarchy_and_preserves_authority() { } } +#[test] +fn ancestors_include_self_and_stop_at_native_path_roots() { + for (input, expected) in [ + ( + "file:///workspace/src", + vec!["file:///workspace/src", "file:///workspace", "file:///"], + ), + ( + "file:///C:/workspace/src", + vec![ + "file:///C:/workspace/src", + "file:///C:/workspace", + "file:///C:", + ], + ), + ( + "file://server/share/project", + vec!["file://server/share/project", "file://server/share"], + ), + ] { + let uri = PathUri::parse(input).expect("valid file URI"); + let ancestors = uri + .ancestors() + .map(|path| path.to_string()) + .collect::>(); + assert_eq!(ancestors, expected, "ancestors for {input}"); + } +} + #[test] fn join_normalizes_relative_uri_segments() { for (base, relative, expected) in [ From 772c5c51952a8bd279dbeeedfea69a6feb837a1d Mon Sep 17 00:00:00 2001 From: charlesgong-openai Date: Thu, 18 Jun 2026 15:49:27 -0700 Subject: [PATCH 053/112] [codex] Support marketplace plugin manifest fallback (#28789) ## Summary Support marketplace plugins whose source directory does not include a discoverable plugin manifest. Metadata-rich `marketplace.json` entries now act as fallback plugin manifests for listing, local detail reads, install, and non-curated cache refresh. The fallback preserves marketplace-entry plugin fields wholesale, then adds the small Codex-facing compatibility bridge for presentation metadata. A real source `plugin.json` always wins when present. ## Details - Capture flattened marketplace-entry fields into `MarketplacePluginManifestFallback`, preserving fields such as `version`, `description`, `skills`, `mcpServers`, `apps`, `hooks`, `agents`, `commands`, `strict`, `author`, and future manifest fields without a per-field translation list. - Bridge Claude-style top-level `displayName`, `author.name`, `homepage`, and marketplace `category` into Codex's nested `interface` fields only when the nested values are absent. - Treat fallback metadata as installable only when the marketplace entry contributes metadata beyond bare `name` and `source`; existing missing-manifest behavior remains for metadata-free entries. - Read local plugin details from the already parsed fallback manifest, including fallback-declared app and MCP paths, instead of rereading only an on-disk manifest. - Pass fallback contents into `PluginStore`, which validates them and injects `.codex-plugin/plugin.json` into Store's existing atomic copy. Local marketplace source directories are never mutated, and the fallback path no longer needs an additional staging directory. - Keep Git source materialization unchanged; Git clones still use the existing marketplace source staging area before Store installation. --- codex-rs/core-plugins/src/loader.rs | 67 ++++- codex-rs/core-plugins/src/manager.rs | 63 ++++- codex-rs/core-plugins/src/manager_tests.rs | 242 ++++++++++++++++- codex-rs/core-plugins/src/marketplace.rs | 158 ++++++++++- .../core-plugins/src/marketplace_tests.rs | 254 +++++++++++++++++- codex-rs/core-plugins/src/store.rs | 155 ++++++++++- codex-rs/core-plugins/src/store_tests.rs | 5 +- 7 files changed, 897 insertions(+), 47 deletions(-) diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index 43b561b3be..279578d75e 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -7,12 +7,14 @@ use crate::manifest::PluginManifestMcpServers; use crate::manifest::PluginManifestPaths; use crate::manifest::load_plugin_manifest; use crate::marketplace::MarketplacePluginSource; +use crate::marketplace::find_marketplace_plugin; use crate::marketplace::list_marketplaces; use crate::marketplace::load_marketplace; use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; use crate::remote::RemoteInstalledPlugin; use crate::store::PluginStore; use crate::store::plugin_version_for_source; +use crate::store::plugin_version_for_source_with_fallback_manifest; use codex_app_server_protocol::AuthMode; use codex_config::ConfigLayerStack; use codex_config::HooksFile; @@ -460,7 +462,7 @@ fn refresh_non_curated_plugin_cache_with_mode( let store = PluginStore::try_new(codex_home.to_path_buf()).map_err(|err| err.to_string())?; let marketplace_outcome = list_marketplaces(additional_roots) .map_err(|err| format!("failed to discover marketplaces for cache refresh: {err}"))?; - let mut plugin_sources = HashMap::::new(); + let mut plugin_sources = HashMap::)>::new(); for marketplace in marketplace_outcome.marketplaces { if is_openai_curated_marketplace_name(&marketplace.name) { @@ -489,14 +491,31 @@ fn refresh_non_curated_plugin_cache_with_mode( continue; } - plugin_sources.insert(plugin_key, plugin.source); + let manifest_fallback = find_marketplace_plugin(&marketplace.path, &plugin.name) + .map(|resolved| { + resolved + .manifest_fallback + .contents_if_has_metadata() + .map(str::to_string) + }) + .unwrap_or_else(|err| { + warn!( + plugin = plugin.name, + marketplace = marketplace.name, + error = %err, + "failed to resolve marketplace plugin manifest fallback during cache refresh" + ); + None + }); + plugin_sources.insert(plugin_key, (plugin.source, manifest_fallback)); } } let mut cache_refreshed = false; for plugin_id in configured_non_curated_plugin_ids { let plugin_key = plugin_id.as_key(); - let Some(source) = plugin_sources.get(&plugin_key).cloned() else { + let Some((source, manifest_fallback_contents)) = plugin_sources.get(&plugin_key).cloned() + else { warn!( plugin = plugin_id.plugin_name, marketplace = plugin_id.marketplace_name, @@ -509,8 +528,14 @@ fn refresh_non_curated_plugin_cache_with_mode( format!("failed to materialize plugin source for {plugin_key}: {err}") })?; let source_path = materialized.path.clone(); - let plugin_version = plugin_version_for_source(source_path.as_path()) - .map_err(|err| format!("failed to read plugin version for {plugin_key}: {err}"))?; + let plugin_version = match manifest_fallback_contents.as_deref() { + Some(manifest_contents) => plugin_version_for_source_with_fallback_manifest( + source_path.as_path(), + manifest_contents, + ), + None => plugin_version_for_source(source_path.as_path()), + } + .map_err(|err| format!("failed to read plugin version for {plugin_key}: {err}"))?; if mode == NonCuratedCacheRefreshMode::IfVersionChanged && store.active_plugin_version(&plugin_id).as_deref() == Some(plugin_version.as_str()) @@ -518,9 +543,16 @@ fn refresh_non_curated_plugin_cache_with_mode( continue; } - store - .install_with_version(source_path, plugin_id.clone(), plugin_version) - .map_err(|err| format!("failed to refresh plugin cache for {plugin_key}: {err}"))?; + match manifest_fallback_contents.as_deref() { + Some(manifest_contents) => store.install_with_version_and_fallback_manifest( + source_path, + plugin_id.clone(), + plugin_version, + manifest_contents, + ), + None => store.install_with_version(source_path, plugin_id.clone(), plugin_version), + } + .map_err(|err| format!("failed to refresh plugin cache for {plugin_key}: {err}"))?; cache_refreshed = true; } @@ -901,15 +933,22 @@ fn default_mcp_config_paths(plugin_root: &Path) -> Vec { pub async fn load_plugin_apps(plugin_root: &Path) -> Vec { if let Some(manifest) = load_plugin_manifest(plugin_root) { - return load_apps_from_paths( - plugin_root, - plugin_app_config_paths(plugin_root, &manifest.paths), - ) - .await; + return load_plugin_apps_from_manifest(plugin_root, &manifest.paths).await; } load_apps_from_paths(plugin_root, default_app_config_paths(plugin_root)).await } +pub(crate) async fn load_plugin_apps_from_manifest( + plugin_root: &Path, + manifest_paths: &PluginManifestPaths, +) -> Vec { + load_apps_from_paths( + plugin_root, + plugin_app_config_paths(plugin_root, manifest_paths), + ) + .await +} + pub fn plugin_app_declarations_from_value(value: &JsonValue) -> Vec { let Ok(parsed) = serde_json::from_value::(value.clone()) else { return Vec::new(); @@ -1181,7 +1220,7 @@ async fn load_declared_plugin_mcp_servers(plugin_root: &Path) -> HashMap>, diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index c31040ebf1..713a356a03 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -7,10 +7,10 @@ use crate::loader::PluginHookLoadOutcome; use crate::loader::configured_curated_plugin_ids_from_codex_home; use crate::loader::curated_plugin_cache_version; use crate::loader::installed_plugin_telemetry_metadata; -use crate::loader::load_plugin_apps; +use crate::loader::load_plugin_apps_from_manifest; use crate::loader::load_plugin_hooks; use crate::loader::load_plugin_hooks_from_layer_stack; -use crate::loader::load_plugin_mcp_servers; +use crate::loader::load_plugin_mcp_servers_from_manifest; use crate::loader::load_plugin_skills; use crate::loader::load_plugins_from_layer_stack; use crate::loader::log_plugin_load_errors; @@ -27,6 +27,7 @@ use crate::marketplace::MarketplaceInterface; use crate::marketplace::MarketplaceListError; use crate::marketplace::MarketplaceListOutcome; use crate::marketplace::MarketplacePluginAuthPolicy; +use crate::marketplace::MarketplacePluginManifestFallback; use crate::marketplace::MarketplacePluginPolicy; use crate::marketplace::MarketplacePluginSource; use crate::marketplace::ResolvedMarketplacePlugin; @@ -315,6 +316,7 @@ pub struct ConfiguredMarketplacePlugin { pub policy: MarketplacePluginPolicy, pub interface: Option, pub keywords: Vec, + pub manifest_fallback: Option, pub installed: bool, pub enabled: bool, } @@ -1256,15 +1258,32 @@ impl PluginsManager { }; let store = self.store.clone(); let codex_home = self.codex_home.clone(); + let manifest_fallback_contents = resolved + .manifest_fallback + .contents_if_has_metadata() + .map(str::to_string); let result: StorePluginInstallResult = tokio::task::spawn_blocking(move || { let materialized = materialize_marketplace_plugin_source(codex_home.as_path(), &resolved.source) .map_err(PluginStoreError::Invalid)?; let source_path = materialized.path; - if let Some(plugin_version) = plugin_version { - store.install_with_version(source_path, resolved.plugin_id, plugin_version) - } else { - store.install(source_path, resolved.plugin_id) + match (plugin_version, manifest_fallback_contents.as_deref()) { + (Some(plugin_version), Some(manifest_contents)) => store + .install_with_version_and_fallback_manifest( + source_path, + resolved.plugin_id, + plugin_version, + manifest_contents, + ), + (Some(plugin_version), None) => { + store.install_with_version(source_path, resolved.plugin_id, plugin_version) + } + (None, Some(manifest_contents)) => store.install_with_fallback_manifest( + source_path, + resolved.plugin_id, + manifest_contents, + ), + (None, None) => store.install(source_path, resolved.plugin_id), } }) .await @@ -1394,6 +1413,7 @@ impl PluginsManager { let enabled = enabled_plugins.contains(&plugin_key); let mut interface = plugin.interface; let mut local_version = plugin.local_version; + let manifest_fallback = plugin.manifest_fallback.clone(); if installed && matches!(&plugin.source, MarketplacePluginSource::Git { .. }) && let Some(plugin_id) = plugin_id.as_ref() @@ -1424,6 +1444,7 @@ impl PluginsManager { policy: plugin.policy, keywords: plugin.keywords, interface, + manifest_fallback, }) }) .collect::>(); @@ -1491,6 +1512,10 @@ impl PluginsManager { let marketplace_name = plugin.plugin_id.marketplace_name.clone(); let plugin_key = plugin.plugin_id.as_key(); + let manifest_fallback = plugin + .manifest_fallback + .contents_if_has_metadata() + .map(|_| plugin.manifest_fallback.clone()); let (installed_plugins, enabled_plugins) = self.configured_plugin_states(config); let installed = installed_plugins.contains(&plugin_key); let installed_version = if installed { @@ -1518,6 +1543,7 @@ impl PluginsManager { .as_ref() .map(|manifest| manifest.keywords.clone()) .unwrap_or_default(), + manifest_fallback, installed, enabled: enabled_plugins.contains(&plugin_key), }, @@ -1604,9 +1630,18 @@ impl PluginsManager { "path does not exist or is not a directory".to_string(), )); } - let manifest = load_plugin_manifest(source_path.as_path()).ok_or_else(|| { - MarketplaceError::InvalidPlugin("missing or invalid plugin.json".to_string()) - })?; + let manifest = + if codex_utils_plugins::find_plugin_manifest_path(source_path.as_path()).is_some() { + load_plugin_manifest(source_path.as_path()) + } else { + plugin + .manifest_fallback + .as_ref() + .and_then(|fallback| fallback.parse_for_plugin_root(source_path.as_path())) + } + .ok_or_else(|| { + MarketplaceError::InvalidPlugin("missing or invalid plugin.json".to_string()) + })?; let description = manifest.description.clone(); let marketplace_category = plugin .interface @@ -1637,8 +1672,14 @@ impl PluginsManager { }) .collect(); let auth_mode = self.auth_mode(); - let mut app_declarations = load_plugin_apps(source_path.as_path()).await; - let mut mcp_servers = load_plugin_mcp_servers(source_path.as_path(), auth_mode).await; + let mut app_declarations = + load_plugin_apps_from_manifest(source_path.as_path(), &manifest.paths).await; + let mut mcp_servers = load_plugin_mcp_servers_from_manifest( + source_path.as_path(), + &manifest.paths, + /*plugin_policy*/ None, + ) + .await; if auth_mode.is_some() { apply_app_mcp_routing_policy( &mut app_declarations, diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 1aa11c6120..a4b30a1996 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -2241,6 +2241,98 @@ async fn install_plugin_uses_manifest_version_for_non_curated_plugins() { ); } +#[tokio::test] +async fn install_plugin_writes_marketplace_manifest_fallback_when_missing_plugin_json() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + let plugin_root = repo_root.join("plugins/quality-review"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(plugin_root.join("skills/thermo-nuclear-code-quality-review")).unwrap(); + fs::write( + plugin_root.join("skills/thermo-nuclear-code-quality-review/SKILL.md"), + "review skill", + ) + .unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "quality-review", + "description": "Strict code quality review focused on maintainability.", + "source": "./plugins/quality-review", + "author": { + "name": "Byron Grogan" + }, + "skills": [ + "./skills/thermo-nuclear-code-quality-review" + ], + "category": "code-review" + } + ] +}"#, + ) + .unwrap(); + + let result = PluginsManager::new(tmp.path().to_path_buf()) + .install_plugin(PluginInstallRequest { + plugin_name: "quality-review".to_string(), + marketplace_path: AbsolutePathBuf::try_from( + repo_root.join(".agents/plugins/marketplace.json"), + ) + .unwrap(), + }) + .await + .unwrap(); + + let installed_path = tmp.path().join("plugins/cache/debug/quality-review/local"); + assert_eq!( + result, + PluginInstallOutcome { + plugin_id: PluginId::new("quality-review".to_string(), "debug".to_string()).unwrap(), + plugin_version: "local".to_string(), + installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(), + auth_policy: MarketplacePluginAuthPolicy::OnInstall, + } + ); + assert!(!plugin_root.join(".codex-plugin/plugin.json").exists()); + assert!( + !tmp.path() + .join("plugins/.marketplace-plugin-source-staging") + .exists() + ); + + let manifest = crate::manifest::load_plugin_manifest(&installed_path).unwrap(); + assert_eq!(manifest.name, "quality-review"); + assert_eq!( + manifest.description.as_deref(), + Some("Strict code quality review focused on maintainability.") + ); + assert_eq!( + manifest.paths.skills, + vec![ + AbsolutePathBuf::try_from( + installed_path.join("skills/thermo-nuclear-code-quality-review") + ) + .unwrap() + ] + ); + let interface = manifest.interface.expect("fallback interface"); + assert_eq!(interface.developer_name.as_deref(), Some("Byron Grogan")); + assert_eq!(interface.category.as_deref(), Some("code-review")); + let fallback_json: serde_json::Value = serde_json::from_str( + &fs::read_to_string(installed_path.join(".codex-plugin/plugin.json")).unwrap(), + ) + .unwrap(); + assert_eq!( + fallback_json["author"], + serde_json::json!({ "name": "Byron Grogan" }) + ); + assert_eq!(fallback_json["category"], "code-review"); +} + #[tokio::test] async fn install_plugin_supports_git_subdir_marketplace_sources() { let tmp = tempfile::tempdir().unwrap(); @@ -2484,6 +2576,7 @@ enabled = false }, interface: None, keywords: Vec::new(), + manifest_fallback: None, installed: true, enabled: true, }, @@ -2503,6 +2596,7 @@ enabled = false }, interface: None, keywords: Vec::new(), + manifest_fallback: None, installed: true, enabled: false, }, @@ -2632,6 +2726,7 @@ plugins = true }, interface: None, keywords: Vec::new(), + manifest_fallback: None, installed: false, enabled: false, }] @@ -2768,6 +2863,142 @@ plugins = true assert!(api_key_outcome.plugin.apps.is_empty()); } +#[tokio::test] +async fn read_plugin_for_config_uses_marketplace_manifest_fallback_paths_for_local_source() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + let plugin_root = repo_root.join("sample-plugin"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + write_file( + &repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample-plugin", + "source": "./sample-plugin", + "apps": "./config/custom.app.json", + "mcpServers": { + "sample-mcp": { + "command": "sample-mcp" + } + } + } + ] +}"#, + ); + write_file( + &plugin_root.join("config/custom.app.json"), + r#"{"apps":{"sample-app":{"id":"connector_sample"}}}"#, + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +"#, + ); + + let config = load_config(tmp.path(), &repo_root).await; + let manager = PluginsManager::new(tmp.path().to_path_buf()); + let outcome = manager + .read_plugin_for_config( + &config, + &PluginReadRequest { + plugin_name: "sample-plugin".to_string(), + marketplace_path: AbsolutePathBuf::try_from( + repo_root.join(".agents/plugins/marketplace.json"), + ) + .unwrap(), + }, + ) + .await + .unwrap(); + + assert_eq!( + outcome.plugin.apps, + vec![AppConnectorId("connector_sample".to_string())] + ); + assert_eq!( + outcome.plugin.mcp_server_names, + vec!["sample-mcp".to_string()] + ); + + let listed_plugin = manager + .list_marketplaces_for_config( + &config, + &[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()], + /*include_openai_curated*/ false, + ) + .unwrap() + .marketplaces + .into_iter() + .find(|marketplace| marketplace.name == "debug") + .unwrap() + .plugins + .into_iter() + .find(|plugin| plugin.name == "sample-plugin") + .unwrap(); + let listed_detail = manager + .read_plugin_detail_for_marketplace_plugin(&config, "debug", listed_plugin) + .await + .unwrap(); + assert_eq!( + listed_detail.apps, + vec![AppConnectorId("connector_sample".to_string())] + ); + assert_eq!( + listed_detail.mcp_server_names, + vec!["sample-mcp".to_string()] + ); +} + +#[tokio::test] +async fn read_plugin_for_config_does_not_fallback_from_invalid_plugin_manifest() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + let plugin_root = repo_root.join("sample-plugin"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + write_file( + &repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample-plugin", + "source": "./sample-plugin", + "description": "Fallback metadata" + } + ] +}"#, + ); + write_file(&plugin_root.join(".codex-plugin/plugin.json"), "{"); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +"#, + ); + + let config = load_config(tmp.path(), &repo_root).await; + let err = PluginsManager::new(tmp.path().to_path_buf()) + .read_plugin_for_config( + &config, + &PluginReadRequest { + plugin_name: "sample-plugin".to_string(), + marketplace_path: AbsolutePathBuf::try_from( + repo_root.join(".agents/plugins/marketplace.json"), + ) + .unwrap(), + }, + ) + .await + .unwrap_err(); + + assert_eq!(err.to_string(), "missing or invalid plugin.json"); +} + #[tokio::test] async fn read_plugin_for_config_uses_user_layer_skill_settings_only() { let tmp = tempfile::tempdir().unwrap(); @@ -3150,8 +3381,11 @@ enabled = true .find(|marketplace| marketplace.name == "debug") .expect("debug marketplace should be listed"); + let mut plugins = marketplace.plugins; + assert!(plugins[0].manifest_fallback.is_some()); + plugins[0].manifest_fallback = None; assert_eq!( - marketplace.plugins, + plugins, vec![ConfiguredMarketplacePlugin { id: "toolkit@debug".to_string(), name: "toolkit".to_string(), @@ -3186,6 +3420,7 @@ enabled = true ..Default::default() }), keywords: Vec::new(), + manifest_fallback: None, installed: true, enabled: true, }] @@ -3266,6 +3501,7 @@ plugins = true }, interface: None, keywords: Vec::new(), + manifest_fallback: None, installed: false, enabled: false, }], @@ -3388,6 +3624,7 @@ plugins = true }, interface: None, keywords: Vec::new(), + manifest_fallback: None, installed: false, enabled: false, }], @@ -3815,6 +4052,7 @@ enabled = false }, interface: None, keywords: Vec::new(), + manifest_fallback: None, installed: false, enabled: true, }] @@ -3847,6 +4085,7 @@ enabled = false }, interface: None, keywords: Vec::new(), + manifest_fallback: None, installed: false, enabled: false, }] @@ -3937,6 +4176,7 @@ enabled = true }, interface: None, keywords: Vec::new(), + manifest_fallback: None, installed: false, enabled: true, }], diff --git a/codex-rs/core-plugins/src/marketplace.rs b/codex-rs/core-plugins/src/marketplace.rs index 82cbcf1415..456bd9004c 100644 --- a/codex-rs/core-plugins/src/marketplace.rs +++ b/codex-rs/core-plugins/src/marketplace.rs @@ -8,6 +8,7 @@ use codex_plugin::PluginIdError; use codex_protocol::protocol::Product; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; +use serde_json::Map as JsonMap; use serde_json::Value as JsonValue; use std::fs; use std::io; @@ -29,6 +30,7 @@ pub struct ResolvedMarketplacePlugin { pub policy: MarketplacePluginPolicy, pub interface: Option, pub manifest: Option, + pub manifest_fallback: MarketplacePluginManifestFallback, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -56,6 +58,57 @@ pub struct MarketplaceInterface { pub display_name: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MarketplacePluginManifestFallback { + contents: String, + has_metadata: bool, +} + +impl MarketplacePluginManifestFallback { + pub fn contents(&self) -> &str { + &self.contents + } + + pub(crate) fn contents_if_has_metadata(&self) -> Option<&str> { + self.has_metadata.then_some(self.contents()) + } + + pub(crate) fn parse_for_plugin_root( + &self, + plugin_root: &Path, + ) -> Option { + crate::manifest::parse_plugin_manifest( + plugin_root, + &fallback_plugin_manifest_path(plugin_root), + &self.contents, + ) + .ok() + } + + pub(crate) fn parse_for_listing(&self) -> Option { + // Git sources have no plugin root before install. Parse against a synthetic absolute root, + // then discard path-bearing fields so listings expose metadata only. + let mut manifest = crate::manifest::parse_plugin_manifest( + Path::new("/"), + Path::new("/.codex-plugin/plugin.json"), + &self.contents, + ) + .ok()?; + manifest.paths = crate::manifest::PluginManifestPaths { + skills: Vec::new(), + mcp_servers: None, + apps: None, + hooks: None, + }; + if let Some(interface) = manifest.interface.as_mut() { + interface.composer_icon = None; + interface.logo = None; + interface.screenshots.clear(); + } + Some(manifest) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct MarketplacePlugin { pub name: String, @@ -64,6 +117,7 @@ pub struct MarketplacePlugin { pub policy: MarketplacePluginPolicy, pub interface: Option, pub keywords: Vec, + pub manifest_fallback: Option, } #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -313,6 +367,10 @@ pub fn load_marketplace(path: &AbsolutePathBuf) -> Result return Err(err), }; + let manifest_fallback = plugin + .manifest_fallback + .contents_if_has_metadata() + .map(|_| plugin.manifest_fallback.clone()); let local_version = plugin .manifest .as_ref() @@ -329,6 +387,7 @@ pub fn load_marketplace(path: &AbsolutePathBuf) -> Result load_plugin_manifest(path.as_path()), + MarketplacePluginSource::Local { path } => { + if codex_utils_plugins::find_plugin_manifest_path(path.as_path()).is_some() { + load_plugin_manifest(path.as_path()) + } else if manifest_fallback.has_metadata { + manifest_fallback.parse_for_plugin_root(path.as_path()) + } else { + None + } + } + MarketplacePluginSource::Git { .. } if manifest_fallback.has_metadata => { + manifest_fallback.parse_for_listing() + } MarketplacePluginSource::Git { .. } => None, }; let interface = plugin_interface_with_marketplace_category( @@ -462,6 +535,7 @@ fn resolve_marketplace_plugin_entry( }, interface, manifest, + manifest_fallback, })) } @@ -765,6 +839,9 @@ struct RawMarketplaceManifestPlugin { policy: RawMarketplaceManifestPluginPolicy, #[serde(default)] category: Option, + #[serde(default)] + #[serde(flatten)] + manifest_fields: JsonMap, } #[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] @@ -822,6 +899,85 @@ fn resolve_marketplace_interface( } } +fn fallback_plugin_manifest_path(plugin_root: &Path) -> PathBuf { + plugin_root.join(".codex-plugin/plugin.json") +} + +fn marketplace_plugin_manifest_fallback( + name: &str, + category: Option<&str>, + manifest_fields: &JsonMap, +) -> MarketplacePluginManifestFallback { + let mut manifest = manifest_fields.clone(); + manifest.insert("name".to_string(), JsonValue::String(name.to_string())); + if let Some(category) = category { + manifest.insert( + "category".to_string(), + JsonValue::String(category.to_string()), + ); + } + if let Some(interface) = plugin_manifest_interface(manifest_fields, category) { + manifest.insert("interface".to_string(), interface); + } + + let contents = serde_json::to_string_pretty(&JsonValue::Object(manifest)) + .unwrap_or_else(|_| format!(r#"{{"name":"{name}"}}"#)); + MarketplacePluginManifestFallback { + contents, + has_metadata: !manifest_fields.is_empty() || category.is_some(), + } +} + +fn plugin_manifest_interface( + fields: &JsonMap, + category: Option<&str>, +) -> Option { + let mut interface = fields + .get("interface") + .and_then(JsonValue::as_object) + .cloned() + .unwrap_or_default(); + + if !interface.contains_key("displayName") + && let Some(display_name) = fields.get("displayName").and_then(JsonValue::as_str) + { + interface.insert( + "displayName".to_string(), + JsonValue::String(display_name.to_string()), + ); + } + if !interface.contains_key("developerName") + && let Some(author_name) = fields + .get("author") + .and_then(|author| author.get("name")) + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + interface.insert( + "developerName".to_string(), + JsonValue::String(author_name.to_string()), + ); + } + if !interface.contains_key("websiteUrl") + && !interface.contains_key("websiteURL") + && let Some(homepage) = fields.get("homepage").and_then(JsonValue::as_str) + { + interface.insert( + "websiteUrl".to_string(), + JsonValue::String(homepage.to_string()), + ); + } + if let Some(category) = category.map(str::trim).filter(|value| !value.is_empty()) { + interface.insert( + "category".to_string(), + JsonValue::String(category.to_string()), + ); + } + + (!interface.is_empty()).then_some(JsonValue::Object(interface)) +} + #[cfg(test)] #[path = "marketplace_tests.rs"] mod tests; diff --git a/codex-rs/core-plugins/src/marketplace_tests.rs b/codex-rs/core-plugins/src/marketplace_tests.rs index 61385f1db0..45aa2364e2 100644 --- a/codex-rs/core-plugins/src/marketplace_tests.rs +++ b/codex-rs/core-plugins/src/marketplace_tests.rs @@ -20,6 +20,17 @@ fn write_alternate_plugin_manifest(plugin_root: &Path, contents: &str) { fs::write(manifest_path, contents).unwrap(); } +fn minimal_manifest_fallback(name: &str) -> MarketplacePluginManifestFallback { + MarketplacePluginManifestFallback { + contents: format!( + r#"{{ + "name": "{name}" +}}"# + ), + has_metadata: false, + } +} + #[test] fn find_marketplace_plugin_finds_repo_marketplace_plugin() { let tmp = tempdir().unwrap(); @@ -65,6 +76,7 @@ fn find_marketplace_plugin_finds_repo_marketplace_plugin() { }, interface: None, manifest: None, + manifest_fallback: minimal_manifest_fallback("local-plugin"), } ); } @@ -108,6 +120,7 @@ fn find_marketplace_plugin_supports_alternate_layout_and_string_local_source() { }, interface: None, manifest: None, + manifest_fallback: minimal_manifest_fallback("string-source-plugin"), } ); } @@ -162,10 +175,219 @@ fn find_marketplace_plugin_supports_git_subdir_sources() { }, interface: None, manifest: None, + manifest_fallback: minimal_manifest_fallback("remote-plugin"), } ); } +#[test] +fn find_marketplace_plugin_builds_manifest_fallback_from_entry() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + let plugin_root = repo_root.join("plugins/quality-review"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(plugin_root.join("skills/thermo-nuclear-code-quality-review")).unwrap(); + fs::create_dir_all(plugin_root.join("skills/second-review")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r##"{ + "name": "team-marketplace", + "plugins": [ + { + "name": "quality-review", + "version": "1.2.3", + "description": "Strict code quality review focused on maintainability.", + "displayName": "Quality Review", + "source": "./plugins/quality-review", + "author": { + "name": "Byron Grogan" + }, + "homepage": "https://example.com/quality", + "repository": "https://github.com/example/quality-review", + "license": "MIT", + "skills": [ + "./skills/thermo-nuclear-code-quality-review", + "./skills/second-review" + ], + "commands": ["./commands/review.md"], + "mcpServers": { + "review": { + "type": "stdio", + "command": "review-mcp" + } + }, + "apps": "./apps/app.json", + "hooks": ["./hooks/session.json"], + "agents": [ + "./agents/thermo-nuclear-code-quality-review.md" + ], + "category": "code-review", + "keywords": ["quality", "review"], + "strict": false, + "interface": { + "shortDescription": "Interface short description.", + "longDescription": "Runs strict reviews focused on maintainability and boundaries.", + "category": "interface-category", + "capabilities": ["review", "quality"], + "privacyPolicyURL": "https://example.com/privacy", + "termsOfServiceUrl": "https://example.com/terms", + "defaultPrompt": [ + "Review this change", + "Find structural issues" + ], + "brandColor": "#00AAFF", + "composerIcon": "./assets/icon.svg", + "logo": "./assets/logo.png", + "screenshots": ["./assets/shot.png"] + } + } + ] +}"##, + ) + .unwrap(); + + let resolved = find_marketplace_plugin( + &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), + "quality-review", + ) + .unwrap(); + + let manifest = resolved.manifest.as_ref().expect("fallback manifest"); + assert_eq!(manifest.name, "quality-review"); + assert_eq!(manifest.version.as_deref(), Some("1.2.3")); + assert_eq!( + manifest.description.as_deref(), + Some("Strict code quality review focused on maintainability.") + ); + assert_eq!( + manifest.paths.skills, + vec![ + AbsolutePathBuf::try_from( + plugin_root.join("skills/thermo-nuclear-code-quality-review") + ) + .unwrap(), + AbsolutePathBuf::try_from(plugin_root.join("skills/second-review")).unwrap(), + ] + ); + let Some(crate::manifest::PluginManifestMcpServers::Object(mcp_servers)) = + manifest.paths.mcp_servers.as_ref() + else { + panic!("fallback mcpServers should be inline"); + }; + assert_eq!( + serde_json::from_str::(mcp_servers).unwrap(), + serde_json::json!({ + "review": { + "type": "stdio", + "command": "review-mcp" + } + }) + ); + assert_eq!( + manifest.paths.apps.as_ref(), + Some(&AbsolutePathBuf::try_from(plugin_root.join("apps/app.json")).unwrap()) + ); + assert_eq!( + manifest.paths.hooks.as_ref(), + Some(&crate::manifest::PluginManifestHooks::Paths(vec![ + AbsolutePathBuf::try_from(plugin_root.join("hooks/session.json")).unwrap() + ])) + ); + assert_eq!(manifest.keywords, vec!["quality", "review"]); + let interface = manifest.interface.as_ref().expect("fallback interface"); + assert_eq!( + interface, + &PluginManifestInterface { + display_name: Some("Quality Review".to_string()), + short_description: Some("Interface short description.".to_string()), + long_description: Some( + "Runs strict reviews focused on maintainability and boundaries.".to_string() + ), + developer_name: Some("Byron Grogan".to_string()), + category: Some("code-review".to_string()), + capabilities: vec!["review".to_string(), "quality".to_string()], + website_url: Some("https://example.com/quality".to_string()), + privacy_policy_url: Some("https://example.com/privacy".to_string()), + terms_of_service_url: Some("https://example.com/terms".to_string()), + default_prompt: Some(vec![ + "Review this change".to_string(), + "Find structural issues".to_string() + ]), + brand_color: Some("#00AAFF".to_string()), + composer_icon: Some( + AbsolutePathBuf::try_from(plugin_root.join("assets/icon.svg")).unwrap() + ), + logo: Some(AbsolutePathBuf::try_from(plugin_root.join("assets/logo.png")).unwrap()), + screenshots: vec![ + AbsolutePathBuf::try_from(plugin_root.join("assets/shot.png")).unwrap() + ], + } + ); + + let fallback_json: JsonValue = + serde_json::from_str(resolved.manifest_fallback.contents()).unwrap(); + assert_eq!( + fallback_json["skills"], + serde_json::json!([ + "./skills/thermo-nuclear-code-quality-review", + "./skills/second-review" + ]) + ); + assert_eq!( + fallback_json["mcpServers"], + serde_json::json!({ + "review": { + "type": "stdio", + "command": "review-mcp" + } + }) + ); + assert_eq!( + fallback_json["displayName"], + JsonValue::String("Quality Review".to_string()) + ); + assert_eq!( + fallback_json["interface"]["websiteUrl"], + JsonValue::String("https://example.com/quality".to_string()) + ); + assert_eq!( + fallback_json["interface"]["privacyPolicyURL"], + JsonValue::String("https://example.com/privacy".to_string()) + ); + assert!(fallback_json["interface"].get("privacyPolicyUrl").is_none()); + assert_eq!( + fallback_json["author"], + serde_json::json!({ "name": "Byron Grogan" }) + ); + assert_eq!( + fallback_json["agents"], + serde_json::json!(["./agents/thermo-nuclear-code-quality-review.md"]) + ); + assert_eq!( + fallback_json["commands"], + serde_json::json!(["./commands/review.md"]) + ); + assert_eq!(fallback_json["strict"], JsonValue::Bool(false)); + assert_eq!( + fallback_json["homepage"], + JsonValue::String("https://example.com/quality".to_string()) + ); + assert_eq!( + fallback_json["repository"], + JsonValue::String("https://github.com/example/quality-review".to_string()) + ); + assert_eq!( + fallback_json["license"], + JsonValue::String("MIT".to_string()) + ); + assert_eq!( + fallback_json["category"], + JsonValue::String("code-review".to_string()) + ); + assert!(resolved.manifest_fallback.has_metadata); +} + #[test] fn find_marketplace_plugin_normalizes_github_shorthand_with_dot_git_suffix() { let tmp = tempdir().unwrap(); @@ -415,6 +637,7 @@ fn list_marketplaces_supports_alternate_manifest_layout() { screenshots: Vec::new(), }), keywords: Vec::new(), + manifest_fallback: None, }], }] ); @@ -500,6 +723,7 @@ fn list_marketplaces_supports_repo_root_local_plugin_sources() { screenshots: Vec::new(), }), keywords: Vec::new(), + manifest_fallback: None, }], }] ); @@ -552,6 +776,7 @@ fn list_marketplaces_includes_plugins_without_discoverable_manifest() { }, interface: None, keywords: Vec::new(), + manifest_fallback: None, }], }] ); @@ -659,6 +884,7 @@ fn list_marketplaces_supports_explicit_api_marketplace_manifest_path() { }, interface: None, keywords: Vec::new(), + manifest_fallback: None, }], }] ); @@ -750,6 +976,7 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() { }, interface: None, keywords: Vec::new(), + manifest_fallback: None, }, MarketplacePlugin { name: "home-only".to_string(), @@ -764,6 +991,7 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() { }, interface: None, keywords: Vec::new(), + manifest_fallback: None, }, ], }, @@ -787,6 +1015,7 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() { }, interface: None, keywords: Vec::new(), + manifest_fallback: None, }, MarketplacePlugin { name: "repo-only".to_string(), @@ -801,6 +1030,7 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() { }, interface: None, keywords: Vec::new(), + manifest_fallback: None, }, ], }, @@ -880,6 +1110,7 @@ fn list_marketplaces_keeps_distinct_entries_for_same_name() { }, interface: None, keywords: Vec::new(), + manifest_fallback: None, }], }, Marketplace { @@ -899,6 +1130,7 @@ fn list_marketplaces_keeps_distinct_entries_for_same_name() { }, interface: None, keywords: Vec::new(), + manifest_fallback: None, }], }, ] @@ -974,6 +1206,7 @@ fn list_marketplaces_dedupes_multiple_roots_in_same_repo() { }, interface: None, keywords: Vec::new(), + manifest_fallback: None, }], }] ); @@ -1138,6 +1371,7 @@ fn list_marketplaces_skips_plugins_with_invalid_names_but_keeps_marketplace() { }, interface: None, keywords: Vec::new(), + manifest_fallback: None, }], }] ); @@ -1220,6 +1454,9 @@ fn list_marketplaces_keeps_remote_and_local_plugin_sources() { }, { "name": "git-subdir-plugin", + "version": "1.2.3", + "displayName": "Git Subdir Plugin", + "keywords": ["git", "remote"], "source": { "source": "git-subdir", "url": "owner/repo", @@ -1240,8 +1477,11 @@ fn list_marketplaces_keeps_remote_and_local_plugin_sources() { .marketplaces; assert_eq!(marketplaces.len(), 1); + let mut plugins = marketplaces[0].plugins.clone(); + assert!(plugins[2].manifest_fallback.is_some()); + plugins[2].manifest_fallback = None; assert_eq!( - marketplaces[0].plugins, + plugins, vec![ MarketplacePlugin { name: "local-plugin".to_string(), @@ -1257,6 +1497,7 @@ fn list_marketplaces_keeps_remote_and_local_plugin_sources() { }, interface: None, keywords: Vec::new(), + manifest_fallback: None, }, MarketplacePlugin { name: "url-plugin".to_string(), @@ -1274,10 +1515,11 @@ fn list_marketplaces_keeps_remote_and_local_plugin_sources() { }, interface: None, keywords: Vec::new(), + manifest_fallback: None, }, MarketplacePlugin { name: "git-subdir-plugin".to_string(), - local_version: None, + local_version: Some("1.2.3".to_string()), source: MarketplacePluginSource::Git { url: "https://github.com/owner/repo.git".to_string(), path: Some("plugins/example".to_string()), @@ -1289,8 +1531,12 @@ fn list_marketplaces_keeps_remote_and_local_plugin_sources() { authentication: MarketplacePluginAuthPolicy::OnInstall, products: None, }, - interface: None, - keywords: Vec::new(), + interface: Some(PluginManifestInterface { + display_name: Some("Git Subdir Plugin".to_string()), + ..Default::default() + }), + keywords: vec!["git".to_string(), "remote".to_string()], + manifest_fallback: None, }, ] ); diff --git a/codex-rs/core-plugins/src/store.rs b/codex-rs/core-plugins/src/store.rs index 19c3b0116f..f0c737e28d 100644 --- a/codex-rs/core-plugins/src/store.rs +++ b/codex-rs/core-plugins/src/store.rs @@ -1,5 +1,6 @@ use crate::manifest::PluginManifest; use crate::manifest::load_plugin_manifest; +use crate::manifest::parse_plugin_manifest; use codex_plugin::PluginId; use codex_plugin::validate_plugin_segment; use codex_utils_absolute_path::AbsolutePathBuf; @@ -30,6 +31,12 @@ pub struct PluginStore { data_root: AbsolutePathBuf, } +#[derive(Clone, Copy)] +enum InstallManifest<'a> { + OnDisk, + Fallback(&'a str), +} + impl PluginStore { pub fn new(codex_home: PathBuf) -> Self { Self::try_new(codex_home) @@ -104,8 +111,20 @@ impl PluginStore { source_path: AbsolutePathBuf, plugin_id: PluginId, ) -> Result { - let plugin_version = plugin_version_for_source(source_path.as_path())?; - self.install_with_version(source_path, plugin_id, plugin_version) + self.install_with_manifest(source_path, plugin_id, InstallManifest::OnDisk) + } + + pub(crate) fn install_with_fallback_manifest( + &self, + source_path: AbsolutePathBuf, + plugin_id: PluginId, + manifest_contents: &str, + ) -> Result { + self.install_with_manifest( + source_path, + plugin_id, + InstallManifest::Fallback(manifest_contents), + ) } pub fn install_with_version( @@ -113,6 +132,47 @@ impl PluginStore { source_path: AbsolutePathBuf, plugin_id: PluginId, plugin_version: String, + ) -> Result { + self.install_with_version_and_manifest( + source_path, + plugin_id, + plugin_version, + InstallManifest::OnDisk, + ) + } + + pub(crate) fn install_with_version_and_fallback_manifest( + &self, + source_path: AbsolutePathBuf, + plugin_id: PluginId, + plugin_version: String, + manifest_contents: &str, + ) -> Result { + self.install_with_version_and_manifest( + source_path, + plugin_id, + plugin_version, + InstallManifest::Fallback(manifest_contents), + ) + } + + fn install_with_manifest( + &self, + source_path: AbsolutePathBuf, + plugin_id: PluginId, + manifest: InstallManifest<'_>, + ) -> Result { + let manifest = resolve_install_manifest(source_path.as_path(), manifest); + let plugin_version = plugin_version_for_install_manifest(source_path.as_path(), manifest)?; + self.install_with_version_and_manifest(source_path, plugin_id, plugin_version, manifest) + } + + fn install_with_version_and_manifest( + &self, + source_path: AbsolutePathBuf, + plugin_id: PluginId, + plugin_version: String, + manifest: InstallManifest<'_>, ) -> Result { if !source_path.as_path().is_dir() { return Err(PluginStoreError::Invalid(format!( @@ -121,7 +181,8 @@ impl PluginStore { ))); } - let plugin_name = plugin_name_for_source(source_path.as_path())?; + let manifest = resolve_install_manifest(source_path.as_path(), manifest); + let plugin_name = plugin_name_for_source(source_path.as_path(), manifest)?; if plugin_name != plugin_id.plugin_name { return Err(PluginStoreError::Invalid(format!( "plugin.json name `{plugin_name}` does not match marketplace plugin name `{}`", @@ -134,6 +195,7 @@ impl PluginStore { source_path.as_path(), self.plugin_base_root(&plugin_id).as_path(), &plugin_version, + manifest, )?; Ok(PluginInstallResult { @@ -168,7 +230,37 @@ impl PluginStoreError { } pub fn plugin_version_for_source(source_path: &Path) -> Result { - let plugin_version = plugin_manifest_version_for_source(source_path)? + plugin_version_for_install_manifest(source_path, InstallManifest::OnDisk) +} + +pub(crate) fn plugin_version_for_source_with_fallback_manifest( + source_path: &Path, + manifest_contents: &str, +) -> Result { + let manifest = + resolve_install_manifest(source_path, InstallManifest::Fallback(manifest_contents)); + plugin_version_for_install_manifest(source_path, manifest) +} + +fn resolve_install_manifest<'a>( + source_path: &Path, + manifest: InstallManifest<'a>, +) -> InstallManifest<'a> { + // A real plugin manifest always wins. The fallback only fills the gap for marketplace + // sources that cannot be changed in place because they may be user-owned directories. + match manifest { + InstallManifest::Fallback(_) if find_plugin_manifest_path(source_path).is_some() => { + InstallManifest::OnDisk + } + manifest => manifest, + } +} + +fn plugin_version_for_install_manifest( + source_path: &Path, + manifest: InstallManifest<'_>, +) -> Result { + let plugin_version = plugin_manifest_version_for_source(source_path, manifest)? .unwrap_or_else(|| DEFAULT_PLUGIN_VERSION.to_string()); validate_plugin_version_segment(&plugin_version).map_err(PluginStoreError::Invalid)?; Ok(plugin_version) @@ -193,9 +285,20 @@ pub fn validate_plugin_version_segment(plugin_version: &str) -> Result<(), Strin Ok(()) } -fn plugin_manifest_for_source(source_path: &Path) -> Result { - load_plugin_manifest(source_path) - .ok_or_else(|| PluginStoreError::Invalid("missing or invalid plugin.json".to_string())) +fn plugin_manifest_for_source( + source_path: &Path, + manifest: InstallManifest<'_>, +) -> Result { + match manifest { + InstallManifest::OnDisk => load_plugin_manifest(source_path) + .ok_or_else(|| PluginStoreError::Invalid("missing or invalid plugin.json".to_string())), + InstallManifest::Fallback(contents) => parse_plugin_manifest( + source_path, + &source_path.join(".codex-plugin/plugin.json"), + contents, + ) + .map_err(|err| PluginStoreError::Invalid(format!("failed to parse plugin.json: {err}"))), + } } #[derive(Debug, Deserialize)] @@ -207,12 +310,17 @@ struct RawPluginManifestVersion { fn plugin_manifest_version_for_source( source_path: &Path, + manifest: InstallManifest<'_>, ) -> Result, PluginStoreError> { - let manifest_path = find_plugin_manifest_path(source_path) - .ok_or_else(|| PluginStoreError::Invalid("missing plugin.json".to_string()))?; - - let contents = fs::read_to_string(&manifest_path) - .map_err(|err| PluginStoreError::io("failed to read plugin.json", err))?; + let contents = match manifest { + InstallManifest::OnDisk => { + let manifest_path = find_plugin_manifest_path(source_path) + .ok_or_else(|| PluginStoreError::Invalid("missing plugin.json".to_string()))?; + fs::read_to_string(&manifest_path) + .map_err(|err| PluginStoreError::io("failed to read plugin.json", err))? + } + InstallManifest::Fallback(contents) => contents.to_string(), + }; let manifest: RawPluginManifestVersion = serde_json::from_str(&contents) .map_err(|err| PluginStoreError::Invalid(format!("failed to parse plugin.json: {err}")))?; let Some(version) = manifest.version else { @@ -232,8 +340,11 @@ fn plugin_manifest_version_for_source( Ok(Some(version.to_string())) } -fn plugin_name_for_source(source_path: &Path) -> Result { - let manifest = plugin_manifest_for_source(source_path)?; +fn plugin_name_for_source( + source_path: &Path, + manifest: InstallManifest<'_>, +) -> Result { + let manifest = plugin_manifest_for_source(source_path, manifest)?; let plugin_name = manifest.name; validate_plugin_segment(&plugin_name, "plugin name") @@ -261,6 +372,7 @@ fn replace_plugin_root_atomically( source: &Path, target_root: &Path, plugin_version: &str, + manifest: InstallManifest<'_>, ) -> Result<(), PluginStoreError> { let Some(parent) = target_root.parent() else { return Err(PluginStoreError::Invalid(format!( @@ -287,6 +399,21 @@ fn replace_plugin_root_atomically( let staged_root = staged_dir.path().join(plugin_dir_name); let staged_version_root = staged_root.join(plugin_version); copy_dir_recursive(source, &staged_version_root)?; + if let InstallManifest::Fallback(contents) = manifest { + // Inject the generated manifest into Store's existing atomic copy so install does not + // mutate the original source or require a second staging directory. + let manifest_path = staged_version_root.join(".codex-plugin/plugin.json"); + let Some(manifest_parent) = manifest_path.parent() else { + return Err(PluginStoreError::Invalid( + "plugin manifest path has no parent".to_string(), + )); + }; + fs::create_dir_all(manifest_parent).map_err(|err| { + PluginStoreError::io("failed to create plugin manifest directory", err) + })?; + fs::write(&manifest_path, contents) + .map_err(|err| PluginStoreError::io("failed to write fallback plugin manifest", err))?; + } let target_version_root = target_root.join(plugin_version); if target_root.exists() && !target_version_root.exists() { diff --git a/codex-rs/core-plugins/src/store_tests.rs b/codex-rs/core-plugins/src/store_tests.rs index 6bf2b93688..aa9b17cc29 100644 --- a/codex-rs/core-plugins/src/store_tests.rs +++ b/codex-rs/core-plugins/src/store_tests.rs @@ -192,7 +192,7 @@ fn install_with_version_uses_requested_cache_version() { } #[test] -fn install_uses_manifest_version_when_present() { +fn install_prefers_on_disk_manifest_version_over_fallback() { let tmp = tempdir().unwrap(); write_plugin_with_version( tmp.path(), @@ -203,9 +203,10 @@ fn install_uses_manifest_version_when_present() { let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(); let result = PluginStore::new(tmp.path().to_path_buf()) - .install( + .install_with_fallback_manifest( AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(), plugin_id.clone(), + r#"{"name":"sample-plugin","version":"9.9.9"}"#, ) .unwrap(); From bb72e151e50cfd4f6282ab86418188af3c83b5df Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 18 Jun 2026 16:13:07 -0700 Subject: [PATCH 054/112] [codex] Remove child AGENTS.md prompt experiment (#28993) ## Why `child_agents_md` is a disabled, under-development experiment that adds a second model-visible explanation of hierarchical `AGENTS.md` behavior. Keeping it leaves unused prompt, configuration, documentation, and test surface. ## What changed - remove the `ChildAgentsMd` feature and `child_agents_md` config schema entry - remove the hierarchical prompt asset, export, and instruction injection - remove feature-specific tests and documentation - keep the generic unstable-feature warning coverage using `apply_patch_streaming_events` Normal project `AGENTS.md` discovery and composition are unchanged. ## Testing - `just test -p codex-features` - `just test -p codex-prompts` - `just test -p codex-core agents_md` - `just test -p codex-core unstable_features_warning` --- codex-rs/core/config.schema.json | 6 -- codex-rs/core/src/agents_md.rs | 9 -- codex-rs/core/src/agents_md_tests.rs | 96 ------------------ .../core/tests/suite/hierarchical_agents.rs | 97 ------------------- codex-rs/core/tests/suite/mod.rs | 1 - .../tests/suite/unstable_features_warning.rs | 10 +- codex-rs/features/src/lib.rs | 8 -- codex-rs/features/src/tests.rs | 9 +- codex-rs/prompts/src/agents.rs | 1 - codex-rs/prompts/src/lib.rs | 2 - .../prompts/templates/agents/hierarchical.md | 7 -- docs/agents_md.md | 4 - 12 files changed, 11 insertions(+), 239 deletions(-) delete mode 100644 codex-rs/core/tests/suite/hierarchical_agents.rs delete mode 100644 codex-rs/prompts/src/agents.rs delete mode 100644 codex-rs/prompts/templates/agents/hierarchical.md diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 46220b5e9e..21c7975261 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -446,9 +446,6 @@ "browser_use_external": { "type": "boolean" }, - "child_agents_md": { - "type": "boolean" - }, "chronicle": { "type": "boolean" }, @@ -4689,9 +4686,6 @@ "browser_use_external": { "type": "boolean" }, - "child_agents_md": { - "type": "boolean" - }, "chronicle": { "type": "boolean" }, diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index 2465cb6d55..05a1a901fa 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -26,8 +26,6 @@ use codex_config::merge_toml_values; use codex_config::project_root_markers_from_config; use codex_exec_server::ExecutorFileSystem; use codex_extension_api::UserInstructions; -use codex_features::Feature; -use codex_prompts::HIERARCHICAL_AGENTS_MESSAGE; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use std::io; @@ -72,13 +70,6 @@ pub(crate) async fn load_project_instructions( } } - if config.features.enabled(Feature::ChildAgentsMd) { - loaded.entries.push(InstructionEntry { - contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(), - provenance: InstructionProvenance::Internal, - }); - } - (!loaded.is_empty()).then_some(loaded) } diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index ab027112a9..0e1a670fc2 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -909,32 +909,6 @@ async fn secondary_environment_invalid_utf8_does_not_suppress_other_docs() { assert!(loaded.text().contains("secondary\u{FFFD}doc")); } -#[tokio::test] -async fn child_agents_guidance_is_appended_once_after_environment_groups() { - let primary = tempfile::tempdir().expect("primary tempdir"); - let secondary = tempfile::tempdir().expect("secondary tempdir"); - fs::write(primary.path().join("AGENTS.md"), "primary doc").unwrap(); - fs::write(secondary.path().join("AGENTS.md"), "secondary doc").unwrap(); - let mut config = make_config(&primary, /*limit*/ 4096, /*instructions*/ None).await; - config.features.enable(Feature::ChildAgentsMd).unwrap(); - let environments = resolved_local_environments([ - ("primary", config.cwd.clone()), - ("secondary", secondary.abs()), - ]); - - let loaded = load_project_instructions( - &config.config, - /*user_instructions*/ None, - &environments, - ) - .await - .expect("instructions expected"); - let text = loaded.text(); - - assert_eq!(text.matches(HIERARCHICAL_AGENTS_MESSAGE).count(), 1); - assert!(text.ends_with(HIERARCHICAL_AGENTS_MESSAGE)); -} - /// If there are existing system instructions but AGENTS.md docs are /// missing we expect the original instructions to be returned unchanged. #[tokio::test] @@ -1098,32 +1072,6 @@ async fn agents_md_paths_preserve_symlinked_cwd() { assert_eq!(res, "project doc"); } -#[tokio::test] -async fn child_agents_message_after_global_instructions_uses_plain_separator() { - let tmp = tempfile::tempdir().expect("tempdir"); - let mut cfg = make_config(&tmp, /*limit*/ 4096, Some("global doc")).await; - cfg.features.enable(Feature::ChildAgentsMd).unwrap(); - - let loaded = load_agents_md(&cfg).await.expect("instructions expected"); - let global_agents = cfg.codex_home.join(DEFAULT_AGENTS_MD_FILENAME); - let expected = LoadedAgentsMd { - user_instructions: Some(UserInstructions { - text: "global doc".to_string(), - source: global_agents, - }), - entries: vec![InstructionEntry { - contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(), - provenance: InstructionProvenance::Internal, - }], - }; - - assert_eq!(loaded, expected); - assert_eq!( - loaded.text(), - format!("global doc\n\n{HIERARCHICAL_AGENTS_MESSAGE}") - ); -} - #[tokio::test] async fn instruction_sources_include_global_before_agents_md_docs() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -1162,50 +1110,6 @@ async fn instruction_sources_include_global_before_agents_md_docs() { ); } -#[tokio::test] -async fn child_agents_message_after_project_docs_is_not_an_instruction_source() { - let tmp = tempfile::tempdir().expect("tempdir"); - fs::write(tmp.path().join("AGENTS.md"), "project doc").unwrap(); - - let mut cfg = make_config(&tmp, /*limit*/ 4096, Some("global doc")).await; - cfg.features.enable(Feature::ChildAgentsMd).unwrap(); - let global_agents = cfg.codex_home.join(DEFAULT_AGENTS_MD_FILENAME); - fs::create_dir_all(&cfg.codex_home).unwrap(); - fs::write(&global_agents, "global doc").unwrap(); - - let loaded = load_agents_md(&cfg).await.expect("instructions expected"); - let project_agents = cfg.cwd.join("AGENTS.md"); - - let expected = LoadedAgentsMd { - user_instructions: Some(UserInstructions { - text: "global doc".to_string(), - source: global_agents.clone(), - }), - entries: vec![ - InstructionEntry { - contents: "project doc".to_string(), - provenance: project_provenance(project_agents.clone(), cfg.cwd.clone()), - }, - InstructionEntry { - contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(), - provenance: InstructionProvenance::Internal, - }, - ], - }; - assert_eq!(loaded, expected); - assert_eq!( - loaded.sources().collect::>(), - vec![ - PathUri::from_abs_path(&global_agents), - PathUri::from_abs_path(&project_agents), - ] - ); - assert_eq!( - loaded.text(), - format!("global doc{AGENTS_MD_SEPARATOR}project doc\n\n{HIERARCHICAL_AGENTS_MESSAGE}") - ); -} - /// AGENTS.override.md is preferred over AGENTS.md when both are present. #[tokio::test] async fn agents_local_md_preferred() { diff --git a/codex-rs/core/tests/suite/hierarchical_agents.rs b/codex-rs/core/tests/suite/hierarchical_agents.rs deleted file mode 100644 index c8c5da94b7..0000000000 --- a/codex-rs/core/tests/suite/hierarchical_agents.rs +++ /dev/null @@ -1,97 +0,0 @@ -use codex_features::Feature; -use codex_utils_path_uri::PathUri; -use core_test_support::responses::ev_completed; -use core_test_support::responses::ev_response_created; -use core_test_support::responses::mount_sse_once; -use core_test_support::responses::sse; -use core_test_support::responses::start_mock_server; -use core_test_support::test_codex::test_codex; - -const HIERARCHICAL_AGENTS_SNIPPET: &str = - "Files called AGENTS.md commonly appear in many places inside a container"; - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn hierarchical_agents_appends_to_project_doc_in_user_instructions() { - let server = start_mock_server().await; - let resp_mock = mount_sse_once( - &server, - sse(vec![ev_response_created("resp1"), ev_completed("resp1")]), - ) - .await; - - let mut builder = test_codex() - .with_config(|config| { - config - .features - .enable(Feature::ChildAgentsMd) - .expect("test config should allow feature update"); - }) - .with_workspace_setup(|cwd, fs| async move { - let agents_md = cwd.join("AGENTS.md"); - let agents_md_uri = PathUri::from_path(&agents_md)?; - fs.write_file(&agents_md_uri, b"be nice".to_vec(), /*sandbox*/ None) - .await?; - Ok::<(), anyhow::Error>(()) - }); - let test = builder - .build_with_remote_env(&server) - .await - .expect("build test codex"); - - test.submit_turn("hello").await.expect("submit turn"); - - let request = resp_mock.single_request(); - let user_messages = request.message_input_texts("user"); - let instructions = user_messages - .iter() - .find(|text| text.starts_with("# AGENTS.md instructions")) - .expect("instructions message"); - assert!( - instructions.contains("be nice"), - "expected AGENTS.md text included: {instructions}" - ); - let snippet_pos = instructions - .find(HIERARCHICAL_AGENTS_SNIPPET) - .expect("expected hierarchical agents snippet"); - let base_pos = instructions - .find("be nice") - .expect("expected AGENTS.md text"); - assert!( - snippet_pos > base_pos, - "expected hierarchical agents message appended after base instructions: {instructions}" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn hierarchical_agents_emits_when_no_project_doc() { - let server = start_mock_server().await; - let resp_mock = mount_sse_once( - &server, - sse(vec![ev_response_created("resp1"), ev_completed("resp1")]), - ) - .await; - - let mut builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::ChildAgentsMd) - .expect("test config should allow feature update"); - }); - let test = builder - .build_with_remote_env(&server) - .await - .expect("build test codex"); - - test.submit_turn("hello").await.expect("submit turn"); - - let request = resp_mock.single_request(); - let user_messages = request.message_input_texts("user"); - let instructions = user_messages - .iter() - .find(|text| text.starts_with("# AGENTS.md instructions")) - .expect("instructions message"); - assert!( - instructions.contains(HIERARCHICAL_AGENTS_SNIPPET), - "expected hierarchical agents message appended: {instructions}" - ); -} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 1e200ab486..f6e84f5ca2 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -57,7 +57,6 @@ mod extension_sandbox; mod fork_thread; #[cfg(not(target_os = "windows"))] mod guardian_review; -mod hierarchical_agents; #[cfg(not(target_os = "windows"))] mod hooks; #[cfg(not(target_os = "windows"))] diff --git a/codex-rs/core/tests/suite/unstable_features_warning.rs b/codex-rs/core/tests/suite/unstable_features_warning.rs index 783acc638b..a6612a20c0 100644 --- a/codex-rs/core/tests/suite/unstable_features_warning.rs +++ b/codex-rs/core/tests/suite/unstable_features_warning.rs @@ -21,14 +21,14 @@ async fn emits_warning_when_unstable_features_enabled_via_config() { let mut config = load_default_config_for_test(&home).await; config .features - .enable(Feature::ChildAgentsMd) + .enable(Feature::ApplyPatchStreamingEvents) .expect("test config should allow feature update"); let user_config_path = AbsolutePathBuf::from_absolute_path(config.codex_home.join(CONFIG_TOML_FILE)) .expect("absolute user config path"); config.config_layer_stack = config.config_layer_stack.with_user_config( &user_config_path, - toml! { features = { child_agents_md = true } }.into(), + toml! { features = { apply_patch_streaming_events = true } }.into(), ); let thread_manager = codex_core::test_support::thread_manager_with_models_provider( @@ -56,7 +56,7 @@ async fn emits_warning_when_unstable_features_enabled_via_config() { let EventMsg::Warning(WarningEvent { message }) = warning else { panic!("expected warning event"); }; - assert!(message.contains("child_agents_md")); + assert!(message.contains("apply_patch_streaming_events")); assert!(message.contains("Under-development features enabled")); assert!(message.contains("suppress_unstable_features_warning = true")); } @@ -67,7 +67,7 @@ async fn suppresses_warning_when_configured() { let mut config = load_default_config_for_test(&home).await; config .features - .enable(Feature::ChildAgentsMd) + .enable(Feature::ApplyPatchStreamingEvents) .expect("test config should allow feature update"); config.suppress_unstable_features_warning = true; let user_config_path = @@ -75,7 +75,7 @@ async fn suppresses_warning_when_configured() { .expect("absolute user config path"); config.config_layer_stack = config.config_layer_stack.with_user_config( &user_config_path, - toml! { features = { child_agents_md = true } }.into(), + toml! { features = { apply_patch_streaming_events = true } }.into(), ); let thread_manager = codex_core::test_support::thread_manager_with_models_provider( diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 56acb213e5..aa208d1ba0 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -132,8 +132,6 @@ pub enum Feature { LocalThreadStoreCompression, /// Enable the Chronicle sidecar for passive screen-context memories. Chronicle, - /// Append additional AGENTS.md guidance to user instructions. - ChildAgentsMd, /// Compress request bodies (zstd) when sending streaming requests to codex-backend. EnableRequestCompression, /// Start the managed network proxy for sandboxed sessions. @@ -910,12 +908,6 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, - FeatureSpec { - id: Feature::ChildAgentsMd, - key: "child_agents_md", - stage: Stage::UnderDevelopment, - default_enabled: false, - }, FeatureSpec { id: Feature::ApplyPatchFreeform, key: "apply_patch_freeform", diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 2e9d3203c0..7ee46f4ba2 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -707,12 +707,15 @@ fn materialize_resolved_enabled_writes_all_features_and_preserves_custom_config( #[test] fn unstable_warning_event_only_mentions_enabled_under_development_features() { let mut configured_features = Table::new(); - configured_features.insert("child_agents_md".to_string(), TomlValue::Boolean(true)); + configured_features.insert( + "apply_patch_streaming_events".to_string(), + TomlValue::Boolean(true), + ); configured_features.insert("personality".to_string(), TomlValue::Boolean(true)); configured_features.insert("unknown".to_string(), TomlValue::Boolean(true)); let mut features = Features::with_defaults(); - features.enable(Feature::ChildAgentsMd); + features.enable(Feature::ApplyPatchStreamingEvents); let warning = unstable_features_warning_event( Some(&configured_features), @@ -725,7 +728,7 @@ fn unstable_warning_event_only_mentions_enabled_under_development_features() { let EventMsg::Warning(WarningEvent { message }) = warning.msg else { panic!("expected warning event"); }; - assert!(message.contains("child_agents_md")); + assert!(message.contains("apply_patch_streaming_events")); assert!(!message.contains("personality")); assert!(message.contains("/tmp/config.toml")); } diff --git a/codex-rs/prompts/src/agents.rs b/codex-rs/prompts/src/agents.rs deleted file mode 100644 index e716d5bf94..0000000000 --- a/codex-rs/prompts/src/agents.rs +++ /dev/null @@ -1 +0,0 @@ -pub const HIERARCHICAL_AGENTS_MESSAGE: &str = include_str!("../templates/agents/hierarchical.md"); diff --git a/codex-rs/prompts/src/lib.rs b/codex-rs/prompts/src/lib.rs index 8830e445d4..5a30358393 100644 --- a/codex-rs/prompts/src/lib.rs +++ b/codex-rs/prompts/src/lib.rs @@ -1,4 +1,3 @@ -mod agents; mod apply_patch; mod compact; mod goals; @@ -7,7 +6,6 @@ mod realtime; mod review_exit; mod review_request; -pub use agents::HIERARCHICAL_AGENTS_MESSAGE; pub use apply_patch::APPLY_PATCH_TOOL_INSTRUCTIONS; pub use compact::SUMMARIZATION_PROMPT; pub use compact::SUMMARY_PREFIX; diff --git a/codex-rs/prompts/templates/agents/hierarchical.md b/codex-rs/prompts/templates/agents/hierarchical.md deleted file mode 100644 index 4f782078c8..0000000000 --- a/codex-rs/prompts/templates/agents/hierarchical.md +++ /dev/null @@ -1,7 +0,0 @@ -Files called AGENTS.md commonly appear in many places inside a container - at "/", in "~", deep within git repositories, or in any other directory; their location is not limited to version-controlled folders. - -Their purpose is to pass along human guidance to you, the agent. Such guidance can include coding standards, explanations of the project layout, steps for building or testing, and even wording that must accompany a GitHub pull-request description produced by the agent; all of it is to be followed. - -Each AGENTS.md governs the entire directory that contains it and every child directory beneath that point. Whenever you change a file, you have to comply with every AGENTS.md whose scope covers that file. Naming conventions, stylistic rules and similar directives are restricted to the code that falls inside that scope unless the document explicitly states otherwise. - -When two AGENTS.md files disagree, the one located deeper in the directory structure overrides the higher-level file, while instructions given directly in the prompt by the system, developer, or user outrank any AGENTS.md content. diff --git a/docs/agents_md.md b/docs/agents_md.md index 3df0facdf6..4fa02abd1d 100644 --- a/docs/agents_md.md +++ b/docs/agents_md.md @@ -1,7 +1,3 @@ # AGENTS.md For information about AGENTS.md, see [this documentation](https://developers.openai.com/codex/guides/agents-md). - -## Hierarchical agents message - -When the `child_agents_md` feature flag is enabled (via `[features]` in `config.toml`), Codex appends additional guidance about AGENTS.md scope and precedence to the user instructions message and emits that message even when no AGENTS.md is present. From 195c936fa2cef6b5c88d74f90d5f83840058ca27 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Thu, 18 Jun 2026 16:16:19 -0700 Subject: [PATCH 055/112] core: log AGENTS.md paths as URIs (#28989) ## Why No need to do path contortions when it's for our own logs. ## What Follow up on a previous PR's nit and update the path-types skill for future reference. --- .codex/skills/path-types/SKILL.md | 1 + codex-rs/core/src/agents_md.rs | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.codex/skills/path-types/SKILL.md b/.codex/skills/path-types/SKILL.md index 0062730013..87be423d58 100644 --- a/.codex/skills/path-types/SKILL.md +++ b/.codex/skills/path-types/SKILL.md @@ -34,6 +34,7 @@ Keep these requirements in mind while migrating code to conform with the above g * URIs should not yet be stored in rollouts, databases, or other persistent storage * path conversion errors: fail-closed for security-relevant paths, fail-open for UI/diagnostics * prefer small focused methods on `PathUri` or `LegacyAppPathString` over local helpers +* represent `PathUri` values as URIs in diagnostics It is OK if the conversion between paths and URIs is somewhat lossy as long as it will do the right thing for real users. diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index 05a1a901fa..d0dc7026a9 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -123,9 +123,9 @@ async fn read_agents_md( if size > remaining { tracing::warn!( - "Project doc `{}` exceeds remaining budget ({} bytes) - truncating.", - p.inferred_native_path_string(), - remaining, + path = %p, + remaining_bytes = remaining, + "project doc exceeds remaining budget; truncating" ); } From 346d2c163ff9191a37dc5f198ccd3cdd731430cc Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Thu, 18 Jun 2026 16:39:21 -0700 Subject: [PATCH 056/112] core: keep remote exec on reported shell (#28983) ## Why We need to avoid resolving shells on the app-server's host for remote environments. We might make it possible to do fancier shell resolution from remote envs but for now just require the model to produce a shell that matches the environment's default. This gets my e2e demo working for shell commands after #28854 moved shell resolution to PathUri and caused remote envs to hit the fallback shell when the shell wasn't available on the host. ## What Remote `exec_command` calls now accept only the environment's reported default shell name or exact path, and execute with that reported path. Other explicit shells return a concise error. A Wine-backed integration test covers explicit PowerShell execution in the Windows cwd. --- .../handlers/unified_exec/exec_command.rs | 25 ++++++- codex-rs/core/tests/suite/remote_env.rs | 71 +++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index 833f7a77b9..0e341f7256 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -1,3 +1,4 @@ +use std::path::Path; use std::sync::Arc; use crate::function_tool::FunctionCallError; @@ -31,6 +32,7 @@ use codex_otel::TOOL_CALL_UNIFIED_EXEC_METRIC; use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; +use codex_shell_command::shell_detect::detect_shell_type; use codex_tools::ToolName; use codex_tools::ToolSpec; use codex_utils_output_truncation::approx_token_count; @@ -172,7 +174,7 @@ impl ExecCommandHandler { ))); } }; - let args: ExecCommandArgs = match native_cwd.as_ref() { + let mut args: ExecCommandArgs = match native_cwd.as_ref() { Some(native_cwd) => { // The base path only resolves paths nested in the permissions config types. parse_arguments_with_base_path(&arguments, native_cwd)? @@ -195,7 +197,6 @@ impl ExecCommandHandler { ) .await; } - let process_id = manager.allocate_process_id().await; let shell_mode = shell_mode_for_environment(&turn.unified_exec_shell_mode, environment.as_ref()); // Remote environments may use a different OS and must build commands with their native @@ -205,6 +206,26 @@ impl ExecCommandHandler { .clone() .map(Arc::new) .unwrap_or_else(|| session.user_shell()); + // TODO(anp): Resolve requested shells in remote environments instead of restricting + // commands to the reported default shell. + if environment.is_remote() + && let Some(requested_shell) = args.shell.take() + { + let Some(remote_shell) = turn_environment.shell.as_ref() else { + return Err(FunctionCallError::RespondToModel(format!( + "environment `{}` does not report a shell", + turn_environment.environment_id + ))); + }; + if detect_shell_type(Path::new(&requested_shell)) != Some(remote_shell.shell_type) { + return Err(FunctionCallError::RespondToModel(format!( + "environment `{}` only supports `{}`", + turn_environment.environment_id, + remote_shell.name() + ))); + } + } + let process_id = manager.allocate_process_id().await; let resolved_command = get_command( &args, shell, diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 311485c112..63bb5deeda 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -228,6 +228,77 @@ async fn remote_test_env_exposes_target_shell_to_model() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn explicit_remote_shell_runs_in_remote_cwd() -> Result<()> { + const CALL_ID: &str = "remote-explicit-shell"; + + let (shell, command) = match core_test_support::test_environment() { + TestEnvironment::Docker { .. } => ( + "bash", + r#"case "$PWD" in /tmp/codex-core-test-cwd-*) ;; *) echo "unexpected cwd: $PWD" >&2; exit 1 ;; esac"#, + ), + TestEnvironment::WineExec => ( + "powershell", + r#"$cwd = (Get-Location).Path; if ($cwd -notlike 'C:\codex-core-test-cwd-*') { Write-Error "unexpected cwd: $cwd"; exit 1 }"#, + ), + TestEnvironment::Local => return Ok(()), + }; + + let server = start_mock_server().await; + let arguments = serde_json::to_string(&json!({ + "cmd": command, + "shell": shell, + "login": false, + "yield_time_ms": 10_000, + }))?; + let mut builder = test_codex().with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + }); + let test = builder.build_with_remote_env(&server).await?; + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(CALL_ID, "exec_command", &arguments), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + test.submit_turn_with_environments( + "run the remote shell in the remote cwd", + Some(vec![TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: PathUri::from_abs_path(&test.config.cwd), + }]), + ) + .await?; + let request = response_mock + .last_request() + .context("model should receive the command output")?; + let (output, success) = request + .function_call_output_content_and_success(CALL_ID) + .context("remote shell tool result should be present")?; + assert_ne!(success, Some(false)); + assert!( + output.is_some_and(|output| output.contains("Process exited with code 0")), + "remote shell command should exit successfully", + ); + + Ok(()) +} + fn absolute_path(path: PathBuf) -> AbsolutePathBuf { AbsolutePathBuf::try_from(path).expect("path should be absolute") } From e83b7841b0789d6f8b3252092d44e51a6e0e54ba Mon Sep 17 00:00:00 2001 From: xl-openai Date: Thu, 18 Jun 2026 16:45:58 -0700 Subject: [PATCH 057/112] [codex] Reuse parsed plugin skills during session startup (#28844) ## Summary - Preserve raw plugin skill-root snapshots in the matching loaded-plugin cache entry, keyed by the effective plugin root identity including namespace. - Pass those snapshots through `SkillsLoadInput` as an optional preload, so session startup reuses plugin parsing while ordinary skill loads pass `None`. - Keep plugin skill loading cohesive: the existing loaders accept the optional snapshots directly, and uncached or marketplace-detail paths do not create a cache. ## Why Plugin discovery already parses plugin skills to determine available capabilities. Cold session startup then scanned and parsed the same roots again while building the skills snapshot. This solves the same duplicate-work problem as #28623 while keeping ownership narrow: `PluginsManager` creates and owns `PluginSkillSnapshots` only for its loaded-plugin cache entry; `SkillsService` consumes an optional clone. Entry replacement or clearing naturally drops the snapshots, with no separate generation, capacity policy, or watcher coupling. ## Validation - `cargo clippy -p codex-core-skills --all-targets -- -D warnings` - `just test -p codex-core-plugins skills_service_reuses_skills_parsed_during_plugin_load` - `just test -p codex-core-skills namespaces_plugin_skills_using_provided_namespace` - `just fmt` --- codex-rs/app-server/src/skills_watcher.rs | 2 + codex-rs/core-plugins/src/loader.rs | 22 +- codex-rs/core-plugins/src/loader_tests.rs | 1 + codex-rs/core-plugins/src/manager.rs | 74 +++++-- codex-rs/core-plugins/src/manager_tests.rs | 61 +++++- .../core-plugins/src/tool_suggest_metadata.rs | 10 +- codex-rs/core-skills/src/lib.rs | 2 + codex-rs/core-skills/src/loader.rs | 108 ++++------ codex-rs/core-skills/src/loader_tests.rs | 194 ++++++++++-------- codex-rs/core-skills/src/root_loader.rs | 158 ++++++++++++++ codex-rs/core-skills/src/service.rs | 26 ++- codex-rs/core/src/agent/role_tests.rs | 4 +- codex-rs/core/src/session/session.rs | 4 +- codex-rs/core/src/session/tests.rs | 18 +- codex-rs/core/src/session/turn_context.rs | 10 +- codex-rs/ext/skills/src/provider/executor.rs | 19 +- .../tests/executor_file_system_authority.rs | 25 ++- 17 files changed, 527 insertions(+), 211 deletions(-) create mode 100644 codex-rs/core-skills/src/root_loader.rs diff --git a/codex-rs/app-server/src/skills_watcher.rs b/codex-rs/app-server/src/skills_watcher.rs index 086e076d4b..637fe963ea 100644 --- a/codex-rs/app-server/src/skills_watcher.rs +++ b/codex-rs/app-server/src/skills_watcher.rs @@ -114,6 +114,8 @@ impl SkillsWatcher { .skill_roots_for_config(&skills_input, Some(environment.get_filesystem())) .await .into_iter() + // Plugin roots are invalidated by plugin lifecycle operations. + .filter(|root| root.plugin_id.is_none()) .map(|root| WatchPath { path: root.path.into_path_buf(), recursive: true, diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index 279578d75e..54eb371686 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -21,6 +21,7 @@ use codex_config::HooksFile; use codex_config::types::McpServerConfig; use codex_config::types::PluginConfig; use codex_config::types::PluginMcpServerConfig; +use codex_core_skills::PluginSkillSnapshots; use codex_core_skills::SkillMetadata; use codex_core_skills::config_rules::SkillConfigRules; use codex_core_skills::config_rules::resolve_disabled_skill_paths; @@ -74,6 +75,7 @@ enum PluginLoadScope<'a> { AllCapabilities { restriction_product: Option, skill_config_rules: &'a SkillConfigRules, + plugin_skill_snapshots: Option<&'a PluginSkillSnapshots>, }, HooksOnly, } @@ -115,6 +117,7 @@ pub(crate) async fn load_plugins_from_layer_stack( config_layer_stack: &ConfigLayerStack, extra_plugins: HashMap, store: &PluginStore, + plugin_skill_snapshots: Option<&PluginSkillSnapshots>, restriction_product: Option, prefer_remote_curated_conflicts: bool, ) -> Vec> { @@ -127,6 +130,7 @@ pub(crate) async fn load_plugins_from_layer_stack( PluginLoadScope::AllCapabilities { restriction_product, skill_config_rules: &skill_config_rules, + plugin_skill_snapshots, }, ) .await @@ -745,6 +749,7 @@ async fn load_plugin( PluginLoadScope::AllCapabilities { restriction_product, skill_config_rules, + plugin_skill_snapshots, } => { loaded_plugin.manifest_name = Some(manifest.display_name().to_string()); loaded_plugin.manifest_description = manifest.description.clone(); @@ -755,6 +760,7 @@ async fn load_plugin( &manifest, *restriction_product, skill_config_rules, + *plugin_skill_snapshots, ) .await; let has_enabled_skills = resolved_skills.has_enabled_skills(); @@ -851,10 +857,17 @@ pub async fn load_plugin_skills( manifest: &PluginManifest, restriction_product: Option, skill_config_rules: &SkillConfigRules, + plugin_skill_snapshots: Option<&PluginSkillSnapshots>, ) -> ResolvedPluginSkills { - load_plugin_skill_inventory(plugin_root, plugin_id, manifest, restriction_product) - .await - .resolve(skill_config_rules) + load_plugin_skill_inventory( + plugin_root, + plugin_id, + manifest, + restriction_product, + plugin_skill_snapshots, + ) + .await + .resolve(skill_config_rules) } pub(crate) async fn load_plugin_skill_inventory( @@ -862,6 +875,7 @@ pub(crate) async fn load_plugin_skill_inventory( plugin_id: &PluginId, manifest: &PluginManifest, restriction_product: Option, + plugin_skill_snapshots: Option<&PluginSkillSnapshots>, ) -> PluginSkillInventory { let roots = plugin_skill_roots(plugin_root, &manifest.paths) .into_iter() @@ -874,7 +888,7 @@ pub(crate) async fn load_plugin_skill_inventory( plugin_root: Some(plugin_root.clone()), }) .collect::>(); - let outcome = load_skills_from_roots(roots).await; + let outcome = load_skills_from_roots(roots, plugin_skill_snapshots).await; let had_errors = !outcome.errors.is_empty(); let skills = outcome .skills diff --git a/codex-rs/core-plugins/src/loader_tests.rs b/codex-rs/core-plugins/src/loader_tests.rs index 133103b78a..ee22256fd1 100644 --- a/codex-rs/core-plugins/src/loader_tests.rs +++ b/codex-rs/core-plugins/src/loader_tests.rs @@ -160,6 +160,7 @@ enabled = true &stack, HashMap::new(), &store, + /*plugin_skill_snapshots*/ None, Some(Product::Codex), /*prefer_remote_curated_conflicts*/ false, ) diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 713a356a03..9d6c096336 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -61,6 +61,7 @@ use codex_config::set_user_plugin_enabled; use codex_config::types::PluginConfig; use codex_config::types::ToolSuggestDisabledTool; use codex_config::types::ToolSuggestDiscoverableType; +use codex_core_skills::PluginSkillSnapshots; use codex_core_skills::SkillMetadata; use codex_core_skills::config_rules::SkillConfigRules; use codex_core_skills::config_rules::skill_config_rules_from_stack; @@ -370,6 +371,7 @@ pub struct PluginsManager { struct LoadedPluginsCacheEntry { key: PluginLoadCacheKey, plugins: Vec, + plugin_skill_snapshots: PluginSkillSnapshots, } #[derive(Default)] @@ -385,6 +387,16 @@ struct PluginLoadCacheKey { remote_plugin_enabled: bool, } +impl PluginLoadCacheKey { + fn from_config(config: &PluginsConfigInput) -> Self { + Self { + configured_plugins: configured_plugins_from_stack(&config.config_layer_stack), + skill_config_rules: skill_config_rules_from_stack(&config.config_layer_stack), + remote_plugin_enabled: config.remote_plugin_enabled, + } + } +} + impl PluginsManager { pub fn new(codex_home: PathBuf) -> Self { Self::new_with_options(codex_home, Some(Product::Codex), /*auth_mode*/ None) @@ -470,6 +482,24 @@ impl PluginsManager { .await } + /// Returns skill snapshots parsed while loading the matching plugin cache entry. + pub fn plugin_skill_snapshots_for_config( + &self, + config: &PluginsConfigInput, + ) -> Option { + if !config.plugins_enabled { + return None; + } + let key = PluginLoadCacheKey::from_config(config); + self.loaded_plugins_cache + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .entry + .as_ref() + .filter(|cached| cached.key == key) + .map(|cached| cached.plugin_skill_snapshots.clone()) + } + #[instrument( name = "plugins_for_config", level = "info", @@ -489,11 +519,7 @@ impl PluginsManager { return PluginLoadOutcome::default(); } - let cache_key = PluginLoadCacheKey { - configured_plugins: configured_plugins_from_stack(&config.config_layer_stack), - skill_config_rules: skill_config_rules_from_stack(&config.config_layer_stack), - remote_plugin_enabled: config.remote_plugin_enabled, - }; + let cache_key = PluginLoadCacheKey::from_config(config); if !force_reload && let Some(plugins) = self.cached_loaded_plugins(&cache_key) { return self.resolve_loaded_plugins_for_auth(plugins); } @@ -506,16 +532,23 @@ impl PluginsManager { return self.resolve_loaded_plugins_for_auth(plugins); } let cache_generation = self.loaded_plugins_cache_generation(); + let plugin_skill_snapshots = PluginSkillSnapshots::for_plugin_load(); let plugins = load_plugins_from_layer_stack( &config.config_layer_stack, self.remote_installed_plugin_configs(), &self.store, + Some(&plugin_skill_snapshots), self.restriction_product, config.remote_plugin_enabled, ) .await; log_plugin_load_errors(&plugins); - self.cache_loaded_plugins_if_current(cache_generation, cache_key, plugins.clone()); + self.cache_loaded_plugins_if_current( + cache_generation, + cache_key, + plugins.clone(), + plugin_skill_snapshots, + ); self.resolve_loaded_plugins_for_auth(plugins) } @@ -589,6 +622,7 @@ impl PluginsManager { config_layer_stack, self.remote_installed_plugin_configs(), &self.store, + /*plugin_skill_snapshots*/ None, self.restriction_product, config.remote_plugin_enabled, ) @@ -626,19 +660,13 @@ impl PluginsManager { } fn cached_loaded_plugins(&self, key: &PluginLoadCacheKey) -> Option> { - match self.loaded_plugins_cache.read() { - Ok(cache) => cache - .entry - .as_ref() - .filter(|cached| cached.key == *key) - .map(|cached| cached.plugins.clone()), - Err(err) => err - .into_inner() - .entry - .as_ref() - .filter(|cached| cached.key == *key) - .map(|cached| cached.plugins.clone()), - } + self.loaded_plugins_cache + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .entry + .as_ref() + .filter(|cached| cached.key == *key) + .map(|cached| cached.plugins.clone()) } fn loaded_plugins_cache_generation(&self) -> u64 { @@ -653,13 +681,18 @@ impl PluginsManager { generation: u64, key: PluginLoadCacheKey, plugins: Vec, + plugin_skill_snapshots: PluginSkillSnapshots, ) { let mut cache = match self.loaded_plugins_cache.write() { Ok(cache) => cache, Err(err) => err.into_inner(), }; if cache.generation == generation { - cache.entry = Some(LoadedPluginsCacheEntry { key, plugins }); + cache.entry = Some(LoadedPluginsCacheEntry { + key, + plugins, + plugin_skill_snapshots, + }); } } @@ -1659,6 +1692,7 @@ impl PluginsManager { &codex_core_skills::config_rules::skill_config_rules_from_stack( &config.config_layer_stack, ), + /*plugin_skill_snapshots*/ None, ) .await; let plugin_data_root = self.store.plugin_data_root(&plugin_id); diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index a4b30a1996..cc8ee19137 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -37,6 +37,9 @@ use codex_config::McpServerConfig; use codex_config::McpServerOAuthConfig; use codex_config::McpServerToolConfig; use codex_config::types::McpServerTransportConfig; +use codex_core_skills::PluginSkillSnapshots; +use codex_core_skills::SkillsLoadInput; +use codex_core_skills::SkillsService; use codex_core_skills::config_rules::SkillConfigRules; use codex_login::CodexAuth; use codex_plugin::AppDeclaration; @@ -1476,6 +1479,7 @@ async fn load_plugin_skills_dedupes_overlapping_manifest_roots() { &manifest, /*restriction_product*/ None, &SkillConfigRules::default(), + /*plugin_skill_snapshots*/ None, ) .await; @@ -2039,6 +2043,55 @@ async fn plugin_cache_ignores_unrelated_session_overrides() { assert_eq!(second.plugins()[0].mcp_servers.len(), 1); } +#[tokio::test] +async fn skills_service_reuses_skills_parsed_during_plugin_load() { + let codex_home = TempDir::new().unwrap(); + let codex_home_abs = codex_home.path().to_path_buf().abs(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + write_plugin( + codex_home.path().join("plugins/cache/test").as_path(), + "sample/local", + "sample", + ); + let skill_path = plugin_root.join("skills/SKILL.md"); + write_file(&skill_path, "---\nname: search\ndescription: first\n---\n"); + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + &plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true), + ); + + let config = load_config(codex_home.path(), codex_home.path()).await; + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + let plugin_outcome = manager.plugins_for_config(&config).await; + let plugin_skill_snapshots = manager.plugin_skill_snapshots_for_config(&config); + write_file(&skill_path, "---\nname: search\ndescription: second\n---\n"); + + let skills_input = SkillsLoadInput::new( + codex_home_abs.clone(), + plugin_outcome.effective_plugin_skill_roots(), + config.config_layer_stack.clone(), + /*bundled_skills_enabled*/ false, + ) + .with_plugin_skill_snapshots(plugin_skill_snapshots); + let skills_service = SkillsService::new(codex_home_abs, /*bundled_skills_enabled*/ false); + let cached = skills_service + .snapshot_for_config(&skills_input, /*fs*/ None) + .await; + + assert_eq!( + cached + .outcome() + .skills + .iter() + .map(|skill| skill.description.as_str()) + .collect::>(), + vec!["first"] + ); +} + #[test] fn loaded_plugins_cache_invalidation_rejects_stale_load_completion() { let codex_home = TempDir::new().unwrap(); @@ -2051,7 +2104,12 @@ fn loaded_plugins_cache_invalidation_rejects_stale_load_completion() { let stale_generation = manager.loaded_plugins_cache_generation(); manager.clear_loaded_plugins_cache(); - manager.cache_loaded_plugins_if_current(stale_generation, cache_key.clone(), Vec::new()); + manager.cache_loaded_plugins_if_current( + stale_generation, + cache_key.clone(), + Vec::new(), + PluginSkillSnapshots::for_plugin_load(), + ); assert_eq!(manager.cached_loaded_plugins(&cache_key), None); } @@ -5164,6 +5222,7 @@ async fn load_plugins_ignores_project_config_files() { &stack, std::collections::HashMap::new(), &PluginStore::new(codex_home.path().to_path_buf()), + /*plugin_skill_snapshots*/ None, Some(Product::Codex), /*prefer_remote_curated_conflicts*/ false, ) diff --git a/codex-rs/core-plugins/src/tool_suggest_metadata.rs b/codex-rs/core-plugins/src/tool_suggest_metadata.rs index 2a667405e8..310bec8654 100644 --- a/codex-rs/core-plugins/src/tool_suggest_metadata.rs +++ b/codex-rs/core-plugins/src/tool_suggest_metadata.rs @@ -210,8 +210,14 @@ async fn load_plugin_metadata( } let manifest = load_plugin_manifest(plugin_root.as_path()) .ok_or_else(|| "missing or invalid plugin.json".to_string())?; - let skill_inventory = - load_plugin_skill_inventory(plugin_root, &plugin_id, &manifest, restriction_product).await; + let skill_inventory = load_plugin_skill_inventory( + plugin_root, + &plugin_id, + &manifest, + restriction_product, + /*plugin_skill_snapshots*/ None, + ) + .await; let mut mcp_server_names = load_plugin_mcp_servers(plugin_root.as_path(), /*auth_mode*/ None) .await diff --git a/codex-rs/core-skills/src/lib.rs b/codex-rs/core-skills/src/lib.rs index 31e5a7c531..53ffcddd07 100644 --- a/codex-rs/core-skills/src/lib.rs +++ b/codex-rs/core-skills/src/lib.rs @@ -6,6 +6,7 @@ mod mention_counts; pub mod model; pub mod remote; pub mod render; +mod root_loader; pub mod service; mod skill_instructions; pub mod system; @@ -29,6 +30,7 @@ pub use render::SkillRenderReport; pub use render::build_available_skills; pub use render::default_skill_metadata_budget; pub use render::render_available_skills_body; +pub use root_loader::PluginSkillSnapshots; pub use service::SkillsLoadInput; pub use service::SkillsService; pub use skill_instructions::SkillInstructions; diff --git a/codex-rs/core-skills/src/loader.rs b/codex-rs/core-skills/src/loader.rs index 64861c45eb..2a3a3189be 100644 --- a/codex-rs/core-skills/src/loader.rs +++ b/codex-rs/core-skills/src/loader.rs @@ -1,6 +1,5 @@ use crate::model::SkillDependencies; use crate::model::SkillError; -use crate::model::SkillFileSystemsByPath; use crate::model::SkillInterface; use crate::model::SkillLoadOutcome; use crate::model::SkillMetadata; @@ -24,7 +23,6 @@ use codex_utils_plugins::PluginSkillRoot; use codex_utils_plugins::plugin_namespace_for_skill_path; use dirs::home_dir; use serde::Deserialize; -use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; use std::error::Error; @@ -161,77 +159,51 @@ pub struct SkillRoot { pub plugin_root: Option, } -pub async fn load_skills_from_roots(roots: I) -> SkillLoadOutcome +pub async fn load_skills_from_roots( + roots: I, + plugin_skill_snapshots: Option<&crate::PluginSkillSnapshots>, +) -> SkillLoadOutcome where I: IntoIterator, { + crate::root_loader::load_and_merge_skill_roots(roots, plugin_skill_snapshots).await +} + +#[derive(Clone)] +pub(crate) struct SkillRootSnapshot { + pub(crate) root: AbsolutePathBuf, + pub(crate) skills: Vec, + pub(crate) errors: Vec, + pub(crate) file_system: Arc, +} + +pub(crate) async fn load_skill_root(root: SkillRoot) -> SkillRootSnapshot { + let SkillRoot { + path, + scope, + file_system, + plugin_id, + plugin_namespace, + plugin_root, + } = root; + let root = canonicalize_for_skill_identity(file_system.as_ref(), &path).await; let mut outcome = SkillLoadOutcome::default(); - let mut skill_roots: Vec = Vec::new(); - let mut skill_root_by_path: HashMap = HashMap::new(); - let mut file_systems_by_skill_path: HashMap> = - HashMap::new(); - for root in roots { - let fs = root.file_system; - let root_path = canonicalize_for_skill_identity(fs.as_ref(), &root.path).await; - let skills_before_root = outcome.skills.len(); - discover_skills_under_root( - fs.as_ref(), - &root_path, - root.scope, - root.plugin_id.as_deref(), - root.plugin_namespace.as_deref(), - root.plugin_root.as_ref(), - &mut outcome, - ) - .await; - for skill in &outcome.skills[skills_before_root..] { - if !skill_roots.contains(&root_path) { - skill_roots.push(root_path.clone()); - } - skill_root_by_path - .entry(skill.path_to_skills_md.clone()) - .or_insert_with(|| root_path.clone()); - file_systems_by_skill_path - .entry(skill.path_to_skills_md.clone()) - .or_insert_with(|| Arc::clone(&fs)); - } + discover_skills_under_root( + file_system.as_ref(), + &root, + scope, + plugin_id.as_deref(), + plugin_namespace.as_deref(), + plugin_root.as_ref(), + &mut outcome, + ) + .await; + SkillRootSnapshot { + root, + skills: outcome.skills, + errors: outcome.errors, + file_system, } - - let mut seen: HashSet = HashSet::new(); - outcome - .skills - .retain(|skill| seen.insert(skill.path_to_skills_md.clone())); - let retained_skill_paths: HashSet = outcome - .skills - .iter() - .map(|skill| skill.path_to_skills_md.clone()) - .collect(); - skill_root_by_path.retain(|path, _| retained_skill_paths.contains(path)); - let used_roots: HashSet = skill_root_by_path.values().cloned().collect(); - skill_roots.retain(|root| used_roots.contains(root)); - file_systems_by_skill_path.retain(|path, _| retained_skill_paths.contains(path)); - outcome.skill_roots = skill_roots; - outcome.skill_root_by_path = Arc::new(skill_root_by_path); - outcome.file_systems_by_skill_path = SkillFileSystemsByPath::new(file_systems_by_skill_path); - - fn scope_rank(scope: SkillScope) -> u8 { - // Higher-priority scopes first (matches root scan order for dedupe). - match scope { - SkillScope::Repo => 0, - SkillScope::User => 1, - SkillScope::System => 2, - SkillScope::Admin => 3, - } - } - - outcome.skills.sort_by(|a, b| { - scope_rank(a.scope) - .cmp(&scope_rank(b.scope)) - .then_with(|| a.name.cmp(&b.name)) - .then_with(|| a.path_to_skills_md.cmp(&b.path_to_skills_md)) - }); - - outcome } pub(crate) async fn skill_roots( diff --git a/codex-rs/core-skills/src/loader_tests.rs b/codex-rs/core-skills/src/loader_tests.rs index 3a6ae9b1c4..e22ed33f3d 100644 --- a/codex-rs/core-skills/src/loader_tests.rs +++ b/codex-rs/core-skills/src/loader_tests.rs @@ -128,6 +128,7 @@ async fn load_skills_for_test(config: &TestConfig) -> SkillLoadOutcome { /*home_dir*/ None, ) .await, + /*plugin_skill_snapshots*/ None, ) .await } @@ -315,7 +316,7 @@ async fn loads_skills_from_home_agents_dir_for_user_scope() -> anyhow::Result<() Some(&home_folder_abs), ) .await; - let outcome = load_skills_from_roots(roots).await; + let outcome = load_skills_from_roots(roots, /*plugin_skill_snapshots*/ None).await; assert!( outcome.errors.is_empty(), "unexpected errors: {:?}", @@ -845,14 +846,17 @@ interface: ); let plugin_root_abs = plugin_root.abs(); - let outcome = load_skills_from_roots([SkillRoot { - path: plugin_root.join("skills").abs(), - scope: SkillScope::User, - file_system: Arc::clone(&LOCAL_FS), - plugin_id: Some("twilio-developer-kit@test".to_string()), - plugin_namespace: None, - plugin_root: Some(plugin_root_abs.clone()), - }]) + let outcome = load_skills_from_roots( + [SkillRoot { + path: plugin_root.join("skills").abs(), + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: Some("twilio-developer-kit@test".to_string()), + plugin_namespace: None, + plugin_root: Some(plugin_root_abs.clone()), + }], + /*plugin_skill_snapshots*/ None, + ) .await; assert!( @@ -903,14 +907,17 @@ interface: "##, ); - let outcome = load_skills_from_roots([SkillRoot { - path: plugin_root.join("skills").abs(), - scope: SkillScope::User, - file_system: Arc::clone(&LOCAL_FS), - plugin_id: Some("twilio-developer-kit@test".to_string()), - plugin_namespace: None, - plugin_root: Some(plugin_root.abs()), - }]) + let outcome = load_skills_from_roots( + [SkillRoot { + path: plugin_root.join("skills").abs(), + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: Some("twilio-developer-kit@test".to_string()), + plugin_namespace: None, + plugin_root: Some(plugin_root.abs()), + }], + /*plugin_skill_snapshots*/ None, + ) .await; assert!( @@ -1050,14 +1057,17 @@ async fn loads_skills_via_symlinked_subdir_for_admin_scope() { fs::create_dir_all(admin_root.path()).unwrap(); symlink_dir(shared.path(), &admin_root.path().join("shared")); - let outcome = load_skills_from_roots([SkillRoot { - path: admin_root.path().abs(), - scope: SkillScope::Admin, - file_system: Arc::clone(&LOCAL_FS), - plugin_id: None, - plugin_namespace: None, - plugin_root: None, - }]) + let outcome = load_skills_from_roots( + [SkillRoot { + path: admin_root.path().abs(), + scope: SkillScope::Admin, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, + plugin_namespace: None, + plugin_root: None, + }], + /*plugin_skill_snapshots*/ None, + ) .await; assert!( @@ -1133,14 +1143,17 @@ async fn system_scope_ignores_symlinked_subdir() { fs::create_dir_all(&system_root).unwrap(); symlink_dir(shared.path(), &system_root.join("shared")); - let outcome = load_skills_from_roots([SkillRoot { - path: system_root.abs(), - scope: SkillScope::System, - file_system: Arc::clone(&LOCAL_FS), - plugin_id: None, - plugin_namespace: None, - plugin_root: None, - }]) + let outcome = load_skills_from_roots( + [SkillRoot { + path: system_root.abs(), + scope: SkillScope::System, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, + plugin_namespace: None, + plugin_root: None, + }], + /*plugin_skill_snapshots*/ None, + ) .await; assert!( outcome.errors.is_empty(), @@ -1168,14 +1181,17 @@ async fn respects_max_scan_depth_for_user_scope() { ); let skills_root = codex_home.path().join("skills"); - let outcome = load_skills_from_roots([SkillRoot { - path: skills_root.abs(), - scope: SkillScope::User, - file_system: Arc::clone(&LOCAL_FS), - plugin_id: None, - plugin_namespace: None, - plugin_root: None, - }]) + let outcome = load_skills_from_roots( + [SkillRoot { + path: skills_root.abs(), + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, + plugin_namespace: None, + plugin_root: None, + }], + /*plugin_skill_snapshots*/ None, + ) .await; assert!( @@ -1276,14 +1292,17 @@ async fn namespaces_plugin_skills_using_provided_namespace() { ) .unwrap(); - let outcome = load_skills_from_roots([SkillRoot { - path: plugin_root.join("skills").abs(), - scope: SkillScope::User, - file_system: Arc::clone(&LOCAL_FS), - plugin_id: Some("sample@test".to_string()), - plugin_namespace: Some("sample".to_string()), - plugin_root: Some(plugin_root.abs()), - }]) + let outcome = load_skills_from_roots( + [SkillRoot { + path: plugin_root.join("skills").abs(), + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: Some("sample@test".to_string()), + plugin_namespace: Some("sample".to_string()), + plugin_root: Some(plugin_root.abs()), + }], + /*plugin_skill_snapshots*/ None, + ) .await; assert!( @@ -1322,14 +1341,17 @@ async fn plugin_skill_name_length_limit_allows_max_qualified_name() { ) .unwrap(); - let outcome = load_skills_from_roots([SkillRoot { - path: plugin_root.join("skills").abs(), - scope: SkillScope::User, - file_system: Arc::clone(&LOCAL_FS), - plugin_id: Some("sample@test".to_string()), - plugin_namespace: Some(plugin_name.clone()), - plugin_root: Some(plugin_root.abs()), - }]) + let outcome = load_skills_from_roots( + [SkillRoot { + path: plugin_root.join("skills").abs(), + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: Some("sample@test".to_string()), + plugin_namespace: Some(plugin_name.clone()), + plugin_root: Some(plugin_root.abs()), + }], + /*plugin_skill_snapshots*/ None, + ) .await; assert!( @@ -1368,14 +1390,17 @@ async fn plugin_skill_name_length_limit_rejects_overlong_qualified_name() { ) .unwrap(); - let outcome = load_skills_from_roots([SkillRoot { - path: plugin_root.join("skills").abs(), - scope: SkillScope::User, - file_system: Arc::clone(&LOCAL_FS), - plugin_id: Some("sample@test".to_string()), - plugin_namespace: Some(plugin_name.clone()), - plugin_root: Some(plugin_root.abs()), - }]) + let outcome = load_skills_from_roots( + [SkillRoot { + path: plugin_root.join("skills").abs(), + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: Some("sample@test".to_string()), + plugin_namespace: Some(plugin_name.clone()), + plugin_root: Some(plugin_root.abs()), + }], + /*plugin_skill_snapshots*/ None, + ) .await; assert_eq!(outcome.skills, Vec::new()); @@ -1807,24 +1832,27 @@ async fn deduplicates_by_path_preferring_first_root() { let skill_path = write_skill_at(root.path(), "dupe", "dupe-skill", "from repo"); - let outcome = load_skills_from_roots([ - SkillRoot { - path: root.path().abs(), - scope: SkillScope::Repo, - file_system: Arc::clone(&LOCAL_FS), - plugin_id: None, - plugin_namespace: None, - plugin_root: None, - }, - SkillRoot { - path: root.path().abs(), - scope: SkillScope::User, - file_system: Arc::clone(&LOCAL_FS), - plugin_id: None, - plugin_namespace: None, - plugin_root: None, - }, - ]) + let outcome = load_skills_from_roots( + [ + SkillRoot { + path: root.path().abs(), + scope: SkillScope::Repo, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, + plugin_namespace: None, + plugin_root: None, + }, + SkillRoot { + path: root.path().abs(), + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, + plugin_namespace: None, + plugin_root: None, + }, + ], + /*plugin_skill_snapshots*/ None, + ) .await; assert!( diff --git a/codex-rs/core-skills/src/root_loader.rs b/codex-rs/core-skills/src/root_loader.rs new file mode 100644 index 0000000000..a31c8a2753 --- /dev/null +++ b/codex-rs/core-skills/src/root_loader.rs @@ -0,0 +1,158 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::fmt; +use std::sync::Arc; +use std::sync::Mutex; + +use codex_utils_plugins::PluginSkillRoot; + +use crate::SkillLoadOutcome; +use crate::loader::SkillRoot; +use crate::loader::SkillRootSnapshot; +use crate::loader::load_skill_root; +use crate::model::SkillFileSystemsByPath; + +/// Parsed plugin skill-root snapshots produced by one plugin load. +/// +/// Clones share the same snapshots. The plugins manager stores them with the corresponding loaded +/// plugins and passes a clone to skill loading as an optional preload. +#[derive(Clone)] +pub struct PluginSkillSnapshots { + snapshots_by_root: Arc>>, +} + +impl PluginSkillSnapshots { + /// Creates an empty snapshot collection for a plugin load to populate. + pub fn for_plugin_load() -> Self { + Self { + snapshots_by_root: Arc::new(Mutex::new(HashMap::new())), + } + } +} + +impl fmt::Debug for PluginSkillSnapshots { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PluginSkillSnapshots") + .finish_non_exhaustive() + } +} + +pub(crate) async fn load_and_merge_skill_roots( + roots: I, + plugin_skill_snapshots: Option<&PluginSkillSnapshots>, +) -> SkillLoadOutcome +where + I: IntoIterator, +{ + let mut root_snapshots = Vec::new(); + for root in roots { + let cache_key = match ( + root.plugin_id.clone(), + root.plugin_namespace.clone(), + root.plugin_root.clone(), + ) { + (Some(plugin_id), Some(plugin_namespace), Some(plugin_root)) => Some(PluginSkillRoot { + path: root.path.clone(), + plugin_id, + plugin_namespace, + plugin_root, + }), + _ => None, + }; + let cached_snapshot = cache_key.as_ref().and_then(|cache_key| { + let plugin_skill_snapshots = plugin_skill_snapshots?; + plugin_skill_snapshots + .snapshots_by_root + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(cache_key) + .cloned() + }); + let snapshot = match cached_snapshot { + Some(snapshot) => snapshot, + None => { + let snapshot = load_skill_root(root).await; + if let Some(plugin_skill_snapshots) = plugin_skill_snapshots + && let Some(cache_key) = cache_key + { + plugin_skill_snapshots + .snapshots_by_root + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(cache_key, snapshot.clone()); + } + snapshot + } + }; + root_snapshots.push(snapshot); + } + + merge_skill_root_snapshots(root_snapshots) +} + +fn merge_skill_root_snapshots(snapshots: Vec) -> SkillLoadOutcome { + fn scope_rank(scope: codex_protocol::protocol::SkillScope) -> u8 { + use codex_protocol::protocol::SkillScope; + + // Higher-priority scopes first (matches root scan order for dedupe). + match scope { + SkillScope::Repo => 0, + SkillScope::User => 1, + SkillScope::System => 2, + SkillScope::Admin => 3, + } + } + + let mut outcome = SkillLoadOutcome::default(); + let mut skill_roots = Vec::new(); + let mut skill_root_by_path = HashMap::new(); + let mut file_systems_by_skill_path = HashMap::new(); + + for snapshot in snapshots { + let SkillRootSnapshot { + root, + skills, + errors, + file_system, + } = snapshot; + if !skills.is_empty() && !skill_roots.contains(&root) { + skill_roots.push(root.clone()); + } + for skill in &skills { + skill_root_by_path + .entry(skill.path_to_skills_md.clone()) + .or_insert_with(|| root.clone()); + file_systems_by_skill_path + .entry(skill.path_to_skills_md.clone()) + .or_insert_with(|| Arc::clone(&file_system)); + } + outcome.skills.extend(skills); + outcome.errors.extend(errors); + } + + let mut seen = HashSet::new(); + outcome + .skills + .retain(|skill| seen.insert(skill.path_to_skills_md.clone())); + let retained_skill_paths = outcome + .skills + .iter() + .map(|skill| skill.path_to_skills_md.clone()) + .collect::>(); + skill_root_by_path.retain(|path, _| retained_skill_paths.contains(path)); + let used_roots = skill_root_by_path.values().cloned().collect::>(); + skill_roots.retain(|root| used_roots.contains(root)); + file_systems_by_skill_path.retain(|path, _| retained_skill_paths.contains(path)); + outcome.skill_roots = skill_roots; + outcome.skill_root_by_path = Arc::new(skill_root_by_path); + outcome.file_systems_by_skill_path = SkillFileSystemsByPath::new(file_systems_by_skill_path); + + outcome.skills.sort_by(|a, b| { + scope_rank(a.scope) + .cmp(&scope_rank(b.scope)) + .then_with(|| a.name.cmp(&b.name)) + .then_with(|| a.path_to_skills_md.cmp(&b.path_to_skills_md)) + }); + + outcome +} diff --git a/codex-rs/core-skills/src/service.rs b/codex-rs/core-skills/src/service.rs index a6a1e30f57..1c956d79b9 100644 --- a/codex-rs/core-skills/src/service.rs +++ b/codex-rs/core-skills/src/service.rs @@ -14,6 +14,7 @@ use tracing::instrument; use tracing::warn; use crate::HostSkillsSnapshot; +use crate::PluginSkillSnapshots; use crate::SkillLoadOutcome; use crate::build_implicit_skill_path_indexes; use crate::config_rules::SkillConfigRules; @@ -32,6 +33,7 @@ pub struct SkillsLoadInput { pub effective_skill_roots: Vec, pub config_layer_stack: ConfigLayerStack, pub bundled_skills_enabled: bool, + plugin_skill_snapshots: Option, } impl SkillsLoadInput { @@ -46,8 +48,18 @@ impl SkillsLoadInput { effective_skill_roots, config_layer_stack, bundled_skills_enabled, + plugin_skill_snapshots: None, } } + + /// Attaches plugin skill snapshots parsed during plugin loading, when available. + pub fn with_plugin_skill_snapshots( + mut self, + plugin_skill_snapshots: Option, + ) -> Self { + self.plugin_skill_snapshots = plugin_skill_snapshots; + self + } } /// Owns host skill discovery, immutable snapshots, cache invalidation, and extra roots. @@ -124,7 +136,8 @@ impl SkillsService { } let snapshot = HostSkillsSnapshot::new(Arc::new( - self.build_skill_outcome(roots, &skill_config_rules).await, + self.build_skill_outcome(input, roots, &skill_config_rules) + .await, )); let mut cache = self .cache_by_config @@ -180,7 +193,8 @@ impl SkillsService { } let skill_config_rules = skill_config_rules_from_stack(&input.config_layer_stack); let snapshot = HostSkillsSnapshot::new(Arc::new( - self.build_skill_outcome(roots, &skill_config_rules).await, + self.build_skill_outcome(input, roots, &skill_config_rules) + .await, )); if use_cwd_cache { let mut cache = self @@ -195,13 +209,13 @@ impl SkillsService { #[instrument(level = "trace", skip_all)] async fn build_skill_outcome( &self, + input: &SkillsLoadInput, roots: Vec, skill_config_rules: &SkillConfigRules, ) -> SkillLoadOutcome { - let outcome = crate::filter_skill_load_outcome_for_product( - load_skills_from_roots(roots).await, - self.restriction_product, - ); + let outcome = load_skills_from_roots(roots, input.plugin_skill_snapshots.as_ref()).await; + let outcome = + crate::filter_skill_load_outcome_for_product(outcome, self.restriction_product); let disabled_paths = resolve_disabled_skill_paths(&outcome.skills, skill_config_rules); finalize_skill_outcome(outcome, disabled_paths) } diff --git a/codex-rs/core/src/agent/role_tests.rs b/codex-rs/core/src/agent/role_tests.rs index e258c0ad90..b56531c90c 100644 --- a/codex-rs/core/src/agent/role_tests.rs +++ b/codex-rs/core/src/agent/role_tests.rs @@ -421,7 +421,9 @@ enabled = false let plugins_input = config.plugins_config_input(); let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await; let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots(); - let skills_input = skills_load_input_from_config(&config, effective_skill_roots); + let plugin_skill_snapshots = plugins_manager.plugin_skill_snapshots_for_config(&plugins_input); + let skills_input = skills_load_input_from_config(&config, effective_skill_roots) + .with_plugin_skill_snapshots(plugin_skill_snapshots); let snapshot = skills_service .snapshot_for_config( &skills_input, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index af2d1abeb8..b7ceb165bb 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -445,7 +445,9 @@ async fn warm_plugins_and_skills_for_session_init( let plugins_input = config.plugins_config_input(); let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await; let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots(); - let skills_input = skills_load_input_from_config(config.as_ref(), effective_skill_roots); + let plugin_skill_snapshots = plugins_manager.plugin_skill_snapshots_for_config(&plugins_input); + let skills_input = skills_load_input_from_config(config.as_ref(), effective_skill_roots) + .with_plugin_skill_snapshots(plugin_skill_snapshots); skills_service .snapshot_for_config(&skills_input, fs) .await diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 0b1cc045c4..93faae5483 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -5050,13 +5050,18 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { turn_environments: Arc::clone(&turn_environments), }; + let plugins_input = per_turn_config.plugins_config_input(); let plugin_outcome = services .plugins_manager - .plugins_for_config(&per_turn_config.plugins_config_input()) + .plugins_for_config(&plugins_input) .await; let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots(); + let plugin_skill_snapshots = services + .plugins_manager + .plugin_skill_snapshots_for_config(&plugins_input); let skills_input = - crate::skills_load_input_from_config(&per_turn_config, effective_skill_roots); + crate::skills_load_input_from_config(&per_turn_config, effective_skill_roots) + .with_plugin_skill_snapshots(plugin_skill_snapshots); let skill_fs = environment.get_filesystem(); let skills_snapshot = services .skills_service @@ -7099,13 +7104,18 @@ where turn_environments: Arc::clone(&turn_environments), }; + let plugins_input = per_turn_config.plugins_config_input(); let plugin_outcome = services .plugins_manager - .plugins_for_config(&per_turn_config.plugins_config_input()) + .plugins_for_config(&plugins_input) .await; let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots(); + let plugin_skill_snapshots = services + .plugins_manager + .plugin_skill_snapshots_for_config(&plugins_input); let skills_input = - crate::skills_load_input_from_config(&per_turn_config, effective_skill_roots); + crate::skills_load_input_from_config(&per_turn_config, effective_skill_roots) + .with_plugin_skill_snapshots(plugin_skill_snapshots); let skill_fs = environment.get_filesystem(); let skills_snapshot = services .skills_service diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 91880bdefa..c03bdab7cf 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -722,13 +722,19 @@ impl Session { .or(model_info.multi_agent_version) .unwrap_or_else(|| per_turn_config.multi_agent_version_from_features()), }; + let plugins_input = per_turn_config.plugins_config_input(); let plugin_outcome = self .services .plugins_manager - .plugins_for_config(&per_turn_config.plugins_config_input()) + .plugins_for_config(&plugins_input) .await; let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots(); - let skills_input = skills_load_input_from_config(&per_turn_config, effective_skill_roots); + let plugin_skill_snapshots = self + .services + .plugins_manager + .plugin_skill_snapshots_for_config(&plugins_input); + let skills_input = skills_load_input_from_config(&per_turn_config, effective_skill_roots) + .with_plugin_skill_snapshots(plugin_skill_snapshots); let fs = primary_turn_environment .map(|turn_environment| turn_environment.environment.get_filesystem()); let skills_snapshot = self diff --git a/codex-rs/ext/skills/src/provider/executor.rs b/codex-rs/ext/skills/src/provider/executor.rs index 8da0265d09..75234dfbee 100644 --- a/codex-rs/ext/skills/src/provider/executor.rs +++ b/codex-rs/ext/skills/src/provider/executor.rs @@ -76,14 +76,17 @@ impl SkillProvider for ExecutorSkillProvider { }; let file_system = environment.get_filesystem(); let outcome = filter_skill_load_outcome_for_product( - load_skills_from_roots([SkillRoot { - path: root_path.clone(), - scope: SkillScope::User, - file_system: Arc::clone(&file_system), - plugin_id: None, - plugin_namespace: None, - plugin_root: None, - }]) + load_skills_from_roots( + [SkillRoot { + path: root_path.clone(), + scope: SkillScope::User, + file_system: Arc::clone(&file_system), + plugin_id: None, + plugin_namespace: None, + plugin_root: None, + }], + /*plugin_skill_snapshots*/ None, + ) .await, self.restriction_product, ); diff --git a/codex-rs/ext/skills/tests/executor_file_system_authority.rs b/codex-rs/ext/skills/tests/executor_file_system_authority.rs index bc2a87a740..e4b09e96a0 100644 --- a/codex-rs/ext/skills/tests/executor_file_system_authority.rs +++ b/codex-rs/ext/skills/tests/executor_file_system_authority.rs @@ -188,17 +188,20 @@ async fn skill_loading_and_reads_use_the_supplied_executor_file_system() { assert!(!alias_root.as_path().exists()); assert!(!canonical_root.as_path().exists()); - let outcome = load_skills_from_roots([SkillRoot { - path: alias_root.clone(), - scope: SkillScope::User, - file_system: Arc::new(SyntheticFileSystem { - alias_root, - canonical_root: canonical_root.clone(), - }), - plugin_id: None, - plugin_namespace: None, - plugin_root: None, - }]) + let outcome = load_skills_from_roots( + [SkillRoot { + path: alias_root.clone(), + scope: SkillScope::User, + file_system: Arc::new(SyntheticFileSystem { + alias_root, + canonical_root: canonical_root.clone(), + }), + plugin_id: None, + plugin_namespace: None, + plugin_root: None, + }], + /*plugin_skill_snapshots*/ None, + ) .await; assert_eq!(outcome.errors, Vec::new()); assert_eq!(outcome.skills.len(), 1); From 5c12034e424f84af3c1e69f063203b19918fa374 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 18 Jun 2026 17:00:49 -0700 Subject: [PATCH 058/112] core: add UUIDv7 context window IDs (#28953) ## Why The token-budget context currently identifies a context window by its thread-local sequence number. A UUIDv7 gives the model a stable opaque identity that remains fixed for a window and rotates when compaction or `new_context` starts the next one. ## What changed - Preserve the existing monotonic value as `window_number` and add a UUIDv7 `window_id` to `CompactedItem`. - Generate and rotate the UUID with auto-compaction window state, persist it alongside the number, and reconstruct it on resume and rollback. - Accept legacy compacted rollout records where the numeric `window_id` represented the window number. - Use the UUID only in token-budget context; existing request headers and metadata continue using `thread_id:window_number`. ## Testing - `just test -p codex-protocol compacted_item::tests` - `just test -p codex-core token_budget` --- .../src/protocol/thread_history.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 1 + codex-rs/core/src/compact.rs | 3 +- codex-rs/core/src/compact_remote.rs | 3 +- codex-rs/core/src/compact_remote_v2.rs | 3 +- .../core/src/context/token_budget_context.rs | 7 +- codex-rs/core/src/session/mod.rs | 22 ++-- .../src/session/rollout_reconstruction.rs | 48 ++++++-- .../session/rollout_reconstruction_tests.rs | 8 ++ codex-rs/core/src/session/tests.rs | 34 ++++-- .../core/src/state/auto_compact_window.rs | 40 +++++-- codex-rs/core/src/state/session.rs | 21 ++-- ..._new_context_window_tool_full_context.snap | 2 +- codex-rs/core/tests/suite/token_budget.rs | 108 +++++++++++++----- codex-rs/protocol/src/compacted_item.rs | 94 +++++++++++++++ codex-rs/protocol/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 8 +- codex-rs/rollout/src/metadata_tests.rs | 1 + .../thread-store/src/thread_metadata_sync.rs | 1 + 19 files changed, 319 insertions(+), 87 deletions(-) create mode 100644 codex-rs/protocol/src/compacted_item.rs diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 6b27e72d46..30221d9442 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -3340,6 +3340,7 @@ mod tests { RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: None, + window_number: None, window_id: None, }), RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 2d4b221a9e..fdd9b08375 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -1105,6 +1105,7 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() { RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(replacement_history), + window_number: None, window_id: None, }), RolloutItem::TurnContext(turn_context.to_turn_context_item()), diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 9d79138e87..d7d2f57bf7 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -302,7 +302,7 @@ async fn run_compact_task_inner_impl( let user_messages = collect_user_messages(history_items); let mut new_history = build_compacted_history(Vec::new(), &user_messages, &summary_text); - let window_id = sess.advance_auto_compact_window_id().await; + let (window_number, window_id) = sess.advance_auto_compact_window().await; if matches!( initial_context_injection, @@ -319,6 +319,7 @@ async fn run_compact_task_inner_impl( let compacted_item = CompactedItem { message: summary_text.clone(), replacement_history: Some(new_history.clone()), + window_number: Some(window_number), window_id: Some(window_id), }; sess.replace_compacted_history(new_history, reference_context_item, compacted_item) diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index c88d8062d6..1eebc23172 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -258,7 +258,7 @@ async fn run_remote_compact_task_inner_impl( &responses_metadata, ) .await?; - let new_window_id = sess.advance_auto_compact_window_id().await; + let (new_window_number, new_window_id) = sess.advance_auto_compact_window().await; new_history = process_compacted_history( sess.as_ref(), turn_context.as_ref(), @@ -274,6 +274,7 @@ async fn run_remote_compact_task_inner_impl( let compacted_item = CompactedItem { message: String::new(), replacement_history: Some(new_history.clone()), + window_number: Some(new_window_number), window_id: Some(new_window_id), }; // Install is the semantic boundary where the compact endpoint's output becomes live diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index f9c2ddc710..099b9c30b8 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -292,7 +292,7 @@ async fn run_remote_compact_task_inner_impl( let (compacted_history, retained_images) = build_v2_compacted_history(&prompt_input, compaction_output); analytics_details.retained_image_count = Some(retained_images); - let new_window_id = sess.advance_auto_compact_window_id().await; + let (new_window_number, new_window_id) = sess.advance_auto_compact_window().await; let new_history = process_compacted_history( sess.as_ref(), turn_context.as_ref(), @@ -308,6 +308,7 @@ async fn run_remote_compact_task_inner_impl( let compacted_item = CompactedItem { message: String::new(), replacement_history: Some(new_history.clone()), + window_number: Some(new_window_number), window_id: Some(new_window_id), }; compaction_trace.record_installed(&CompactionCheckpointTracePayload { diff --git a/codex-rs/core/src/context/token_budget_context.rs b/codex-rs/core/src/context/token_budget_context.rs index 37b355d51a..b21b617355 100644 --- a/codex-rs/core/src/context/token_budget_context.rs +++ b/codex-rs/core/src/context/token_budget_context.rs @@ -1,15 +1,16 @@ use super::ContextualUserFragment; use codex_protocol::ThreadId; +use uuid::Uuid; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct TokenBudgetContext { thread_id: ThreadId, - window_id: u64, + window_id: Uuid, tokens_left: i64, } impl TokenBudgetContext { - pub(crate) fn new(thread_id: ThreadId, window_id: u64, tokens_left: i64) -> Self { + pub(crate) fn new(thread_id: ThreadId, window_id: Uuid, tokens_left: i64) -> Self { Self { thread_id, window_id, @@ -36,7 +37,7 @@ impl ContextualUserFragment for TokenBudgetContext { let window_id = self.window_id; let tokens_left = self.tokens_left; format!( - "Thread id {thread_id}.\nCurrent context window {window_id}.\nYou have {tokens_left} tokens left in this context window." + "Thread id {thread_id}.\nCurrent context window id {window_id}.\nYou have {tokens_left} tokens left in this context window." ) } } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 5d579f14a8..d721a162dc 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1352,6 +1352,7 @@ impl Session { mut history, previous_turn_settings, reference_context_item, + window_number, window_id, } = self .reconstruct_history_from_rollout(turn_context, rollout_items) @@ -1370,7 +1371,8 @@ impl Session { { let mut state = self.state.lock().await; state.replace_history(history, reference_context_item); - state.set_auto_compact_window_id(window_id); + let window_id = window_id.unwrap_or_else(|| state.auto_compact_window_id()); + state.restore_auto_compact_window(window_number, window_id); state.set_previous_turn_settings(previous_turn_settings.clone()); } let prefix_tokens = if matches!( @@ -3231,13 +3233,14 @@ impl Session { pub(crate) async fn current_window_id(&self) -> String { let state = self.state.lock().await; let thread_id = self.thread_id; - let window_id = state.auto_compact_window_id(); - format!("{thread_id}:{window_id}") + let window_number = state.auto_compact_window_number(); + format!("{thread_id}:{window_number}") } - pub(crate) async fn advance_auto_compact_window_id(&self) -> u64 { + pub(crate) async fn advance_auto_compact_window(&self) -> (u64, String) { let mut state = self.state.lock().await; - state.advance_auto_compact_window_id() + let (window_number, window_id) = state.advance_auto_compact_window(); + (window_number, window_id.to_string()) } pub(crate) async fn request_new_context_window(&self) { @@ -3249,11 +3252,11 @@ impl Session { &self, turn_context: &TurnContext, ) -> Option { - let window_id = { + let window = { let mut state = self.state.lock().await; state.start_new_context_window_if_requested() }; - let window_id = window_id?; + let (window_number, window_id) = window?; let context_items = self.build_initial_context(turn_context).await; let turn_context_item = turn_context.to_turn_context_item(); let replacement_history = context_items; @@ -3265,7 +3268,8 @@ impl Session { RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(replacement_history), - window_id: Some(window_id), + window_number: Some(window_number), + window_id: Some(window_id.to_string()), }), RolloutItem::TurnContext(turn_context_item), ]) @@ -3275,7 +3279,7 @@ impl Session { state.queue_pending_session_start_source(codex_hooks::SessionStartSource::Compact); } self.recompute_token_usage(turn_context).await; - Some(window_id) + Some(window_number) } pub(crate) async fn reference_context_item(&self) -> Option { diff --git a/codex-rs/core/src/session/rollout_reconstruction.rs b/codex-rs/core/src/session/rollout_reconstruction.rs index ab4838b2ff..22d0990e32 100644 --- a/codex-rs/core/src/session/rollout_reconstruction.rs +++ b/codex-rs/core/src/session/rollout_reconstruction.rs @@ -1,5 +1,6 @@ use super::*; use crate::context_manager::is_user_turn_boundary; +use uuid::Uuid; // Return value of `Session::reconstruct_history_from_rollout`, bundling the rebuilt history with // the resume/fork hydration metadata derived from the same replay. @@ -8,7 +9,14 @@ pub(super) struct RolloutReconstruction { pub(super) history: Vec, pub(super) previous_turn_settings: Option, pub(super) reference_context_item: Option, - pub(super) window_id: u64, + pub(super) window_number: u64, + pub(super) window_id: Option, +} + +#[derive(Debug, Clone, Copy)] +struct ReconstructedWindow { + number: u64, + id: Option, } #[derive(Debug, Default)] @@ -34,7 +42,7 @@ struct ActiveReplaySegment<'a> { previous_turn_settings: Option, reference_context_item: TurnReferenceContextItem, base_replacement_history: Option<&'a [ResponseItem]>, - window_id: Option, + window: Option, } fn turn_ids_are_compatible(active_turn_id: Option<&str>, item_turn_id: Option<&str>) -> bool { @@ -47,7 +55,7 @@ fn finalize_active_segment<'a>( base_replacement_history: &mut Option<&'a [ResponseItem]>, previous_turn_settings: &mut Option, reference_context_item: &mut TurnReferenceContextItem, - window_id: &mut Option, + window: &mut Option, pending_rollback_turns: &mut usize, ) { // Thread rollback drops the newest surviving real user-message boundaries. In replay, that @@ -68,8 +76,8 @@ fn finalize_active_segment<'a>( *base_replacement_history = Some(segment_base_replacement_history); } - if window_id.is_none() { - *window_id = active_segment.window_id; + if window.is_none() { + *window = active_segment.window; } // `previous_turn_settings` come from the newest surviving user turn that established them. @@ -104,7 +112,7 @@ impl Session { let mut base_replacement_history: Option<&[ResponseItem]> = None; let mut previous_turn_settings = None; let mut reference_context_item = TurnReferenceContextItem::NeverSet; - let mut window_id = None; + let mut window = None; // Rollback is "drop the newest N user turns". While scanning in reverse, that becomes // "skip the next N user-turn segments we finalize". let mut pending_rollback_turns = 0usize; @@ -120,8 +128,13 @@ impl Session { RolloutItem::Compacted(compacted) => { let active_segment = active_segment.get_or_insert_with(ActiveReplaySegment::default); - if active_segment.window_id.is_none() { - active_segment.window_id = compacted.window_id; + if active_segment.window.is_none() + && let Some(window_number) = compacted.window_number + { + active_segment.window = Some(ReconstructedWindow { + number: window_number, + id: compacted.window_id.as_deref().and_then(parse_uuid_v7), + }); } // Looking backward, compaction clears any older baseline unless a newer // `TurnContextItem` in this same segment has already re-established it. @@ -210,7 +223,7 @@ impl Session { &mut base_replacement_history, &mut previous_turn_settings, &mut reference_context_item, - &mut window_id, + &mut window, &mut pending_rollback_turns, ); } @@ -245,12 +258,12 @@ impl Session { &mut base_replacement_history, &mut previous_turn_settings, &mut reference_context_item, - &mut window_id, + &mut window, &mut pending_rollback_turns, ); } - let fallback_window_id = u64::try_from( + let fallback_window_number = u64::try_from( rollout_items .iter() .filter(|item| matches!(item, RolloutItem::Compacted(_))) @@ -326,11 +339,22 @@ impl Session { reference_context_item }; + let window = window.unwrap_or(ReconstructedWindow { + number: fallback_window_number, + id: None, + }); RolloutReconstruction { history: history.raw_items().to_vec(), previous_turn_settings, reference_context_item, - window_id: window_id.unwrap_or(fallback_window_id), + window_number: window.number, + window_id: window.id, } } } + +fn parse_uuid_v7(value: &str) -> Option { + Uuid::parse_str(value) + .ok() + .filter(|uuid| uuid.get_version_num() == 7) +} diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 084553f15a..0b551ce728 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -826,6 +826,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(Vec::new()), + window_number: None, window_id: None, }), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -883,6 +884,7 @@ async fn record_initial_history_resumed_does_not_seed_reference_context_item_aft RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(Vec::new()), + window_number: None, window_id: None, }), ]; @@ -909,6 +911,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_does_ RolloutItem::Compacted(CompactedItem { message: "legacy summary".to_string(), replacement_history: None, + window_number: None, window_id: None, }), ]; @@ -941,6 +944,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::Compacted(CompactedItem { message: "legacy summary".to_string(), replacement_history: None, + window_number: None, window_id: None, }), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1035,6 +1039,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(Vec::new()), + window_number: None, window_id: None, }), RolloutItem::TurnContext(previous_context_item), @@ -1185,6 +1190,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(Vec::new()), + window_number: None, window_id: None, }), ]; @@ -1419,6 +1425,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(Vec::new()), + window_number: None, window_id: None, }), ]; @@ -1583,6 +1590,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(Vec::new()), + window_number: None, window_id: None, }), // A newer TurnStarted replaces the incomplete compacted turn without a matching diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 93faae5483..67dd6becfc 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -168,6 +168,7 @@ use tokio::sync::Semaphore; use tokio::time::sleep; use tokio::time::timeout; use tracing_opentelemetry::OpenTelemetrySpanExt; +use uuid::Uuid; use codex_protocol::mcp::CallToolResult as McpCallToolResult; use pretty_assertions::assert_eq; @@ -1592,7 +1593,13 @@ async fn reconstruct_history_matches_live_compactions() { .await; assert_eq!(expected, reconstructed.history); - assert_eq!(2, reconstructed.window_id); + assert_eq!(2, reconstructed.window_number); + assert_eq!( + reconstructed + .window_id + .map(|window_id| window_id.get_version_num()), + Some(7) + ); } #[tokio::test] @@ -1622,10 +1629,12 @@ async fn reconstruct_history_uses_replacement_history_verbatim() { metadata: None, }, ]; + let window_id = Uuid::now_v7(); let rollout_items = vec![RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(replacement_history.clone()), - window_id: Some(42), + window_number: Some(42), + window_id: Some(window_id.to_string()), })]; let reconstructed = session @@ -1633,7 +1642,8 @@ async fn reconstruct_history_uses_replacement_history_verbatim() { .await; assert_eq!(reconstructed.history, replacement_history); - assert_eq!(42, reconstructed.window_id); + assert_eq!(42, reconstructed.window_number); + assert_eq!(Some(window_id), reconstructed.window_id); } #[tokio::test] @@ -3014,6 +3024,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio user_message("turn 1 user"), user_message("summary after compaction"), ]; + let compacted_window_id = Uuid::now_v7(); sess.persist_rollout_items(&[ RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -3055,7 +3066,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::Compacted(CompactedItem { message: "summary after compaction".to_string(), replacement_history: Some(compacted_history.clone()), - window_id: Some(7), + window_number: Some(7), + window_id: Some(compacted_window_id.to_string()), }), RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: compact_turn_id, @@ -3105,7 +3117,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio .await; { let mut state = sess.state.lock().await; - state.set_auto_compact_window_id(/*window_id*/ 99); + state.restore_auto_compact_window(/*window_number*/ 99, Uuid::now_v7()); } handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await; @@ -3114,6 +3126,10 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio assert_eq!(sess.clone_history().await.raw_items(), compacted_history); assert!(sess.reference_context_item().await.is_none()); + assert_eq!( + sess.state.lock().await.auto_compact_window_id(), + compacted_window_id + ); assert!(sess.current_window_id().await.ends_with(":7")); } @@ -9846,10 +9862,12 @@ async fn sample_rollout( let user_messages1 = collect_user_messages(&snapshot1); let rebuilt1 = compact::build_compacted_history(Vec::new(), &user_messages1, summary1); live_history.replace(rebuilt1); + let (window_number, window_id) = session.advance_auto_compact_window().await; rollout_items.push(RolloutItem::Compacted(CompactedItem { message: summary1.to_string(), replacement_history: None, - window_id: None, + window_number: Some(window_number), + window_id: Some(window_id), })); let user2 = ResponseItem::Message { @@ -9889,10 +9907,12 @@ async fn sample_rollout( let user_messages2 = collect_user_messages(&snapshot2); let rebuilt2 = compact::build_compacted_history(Vec::new(), &user_messages2, summary2); live_history.replace(rebuilt2); + let (window_number, window_id) = session.advance_auto_compact_window().await; rollout_items.push(RolloutItem::Compacted(CompactedItem { message: summary2.to_string(), replacement_history: None, - window_id: None, + window_number: Some(window_number), + window_id: Some(window_id), })); let user3 = ResponseItem::Message { diff --git a/codex-rs/core/src/state/auto_compact_window.rs b/codex-rs/core/src/state/auto_compact_window.rs index 1e3eceadad..e1fde27504 100644 --- a/codex-rs/core/src/state/auto_compact_window.rs +++ b/codex-rs/core/src/state/auto_compact_window.rs @@ -1,4 +1,5 @@ use codex_protocol::protocol::TokenUsage; +use uuid::Uuid; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct AutoCompactWindowSnapshot { @@ -13,7 +14,8 @@ enum AutoCompactWindowPrefill { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) struct AutoCompactWindow { - window_id: u64, + window_number: u64, + window_id: Uuid, new_context_window_requested: bool, /// Absolute input-token baseline for the current compaction window. /// @@ -26,7 +28,8 @@ pub(super) struct AutoCompactWindow { impl AutoCompactWindow { pub(super) fn new() -> Self { Self { - window_id: 0, + window_number: 0, + window_id: Uuid::now_v7(), new_context_window_requested: false, prefill_input_tokens: None, } @@ -36,18 +39,24 @@ impl AutoCompactWindow { self.prefill_input_tokens = None; } - pub(super) fn window_id(&self) -> u64 { + pub(super) fn window_number(&self) -> u64 { + self.window_number + } + + pub(super) fn window_id(&self) -> Uuid { self.window_id } - pub(super) fn set_window_id(&mut self, window_id: u64) { + pub(super) fn restore(&mut self, window_number: u64, window_id: Uuid) { + self.window_number = window_number; self.window_id = window_id; } - pub(super) fn advance_window_id(&mut self) -> u64 { - self.window_id = self.window_id.saturating_add(1); + pub(super) fn advance(&mut self) -> (u64, Uuid) { + self.window_number = self.window_number.saturating_add(1); + self.window_id = Uuid::now_v7(); self.new_context_window_requested = false; - self.window_id + (self.window_number, self.window_id) } pub(super) fn request_new_context_window(&mut self) { @@ -108,15 +117,22 @@ mod tests { fn tracks_prefill_and_window_boundaries() { let mut window = AutoCompactWindow::new(); - assert_eq!(window.window_id(), 0); - window.set_window_id(/*window_id*/ 3); - assert_eq!(window.window_id(), 3); + assert_eq!(window.window_number(), 0); + assert_eq!(window.window_id().get_version_num(), 7); + let restored_window_id = Uuid::now_v7(); + window.restore(/*window_number*/ 3, restored_window_id); + assert_eq!(window.window_number(), 3); + assert_eq!(window.window_id(), restored_window_id); window.request_new_context_window(); assert!(window.take_new_context_window_request()); assert!(!window.take_new_context_window_request()); window.request_new_context_window(); - assert_eq!(window.advance_window_id(), 4); - assert_eq!(window.window_id(), 4); + let (window_number, window_id) = window.advance(); + assert_eq!(window_number, 4); + assert_eq!(window.window_number(), 4); + assert_eq!(window.window_id(), window_id); + assert_eq!(window_id.get_version_num(), 7); + assert_ne!(window_id, restored_window_id); assert!(!window.take_new_context_window_request()); assert_eq!( diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 2bd9da5ce3..83cef227f8 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -6,6 +6,7 @@ use codex_sandboxing::policy_transforms::merge_permission_profiles; use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; +use uuid::Uuid; use super::AdditionalContextStore; use super::auto_compact_window::AutoCompactWindow; @@ -147,30 +148,34 @@ impl SessionState { self.auto_compact_window.snapshot() } - pub(crate) fn auto_compact_window_id(&self) -> u64 { + pub(crate) fn auto_compact_window_number(&self) -> u64 { + self.auto_compact_window.window_number() + } + + pub(crate) fn auto_compact_window_id(&self) -> Uuid { self.auto_compact_window.window_id() } - pub(crate) fn set_auto_compact_window_id(&mut self, window_id: u64) { - self.auto_compact_window.set_window_id(window_id); + pub(crate) fn restore_auto_compact_window(&mut self, window_number: u64, window_id: Uuid) { + self.auto_compact_window.restore(window_number, window_id); } - pub(crate) fn advance_auto_compact_window_id(&mut self) -> u64 { - self.auto_compact_window.advance_window_id() + pub(crate) fn advance_auto_compact_window(&mut self) -> (u64, Uuid) { + self.auto_compact_window.advance() } pub(crate) fn request_new_context_window(&mut self) { self.auto_compact_window.request_new_context_window(); } - pub(crate) fn start_new_context_window_if_requested(&mut self) -> Option { + pub(crate) fn start_new_context_window_if_requested(&mut self) -> Option<(u64, Uuid)> { if !self.auto_compact_window.take_new_context_window_request() { return None; } - let window_id = self.auto_compact_window.advance_window_id(); + let window = self.auto_compact_window.advance(); self.auto_compact_window.clear_prefill(); - Some(window_id) + Some(window) } pub(crate) fn token_info(&self) -> Option { diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap b/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap index e3dddb45ca..12bab10ec8 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap @@ -8,7 +8,7 @@ Scenario: New context window tool installs fresh full context before the next fo 00:message/developer[3]: [01] [02] - [03] \nThread id .\nCurrent context window 1.\nYou have 121600 tokens left in this context window.\n + [03] \nThread id .\nCurrent context window id .\nYou have 121600 tokens left in this context window.\n 01:message/user:> 02:function_call/update_plan 03:function_call_output:Plan updated diff --git a/codex-rs/core/tests/suite/token_budget.rs b/codex-rs/core/tests/suite/token_budget.rs index c64251e24a..db70d3f33c 100644 --- a/codex-rs/core/tests/suite/token_budget.rs +++ b/codex-rs/core/tests/suite/token_budget.rs @@ -4,6 +4,7 @@ use codex_model_provider_info::built_in_model_providers; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use core_test_support::PathBufExt; +use core_test_support::assert_regex_match; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; use core_test_support::responses::ResponsesRequest; @@ -80,17 +81,17 @@ async fn token_budget_context_is_only_emitted_with_full_context() -> Result<()> assert_eq!(requests.len(), 2); let thread_id = test.session_configured.thread_id; - let expected = vec![format!( - "\nThread id {thread_id}.\nCurrent context window 0.\nYou have {EFFECTIVE_CONTEXT_WINDOW} tokens left in this context window.\n" - )]; - assert_eq!( - token_budget_texts(&requests[0]), - expected, - "initial full context should report context window 0" + let initial_token_budget = token_budget_texts(&requests[0]); + assert_eq!(initial_token_budget.len(), 1); + assert_regex_match( + &format!( + r"^\nThread id {thread_id}\.\nCurrent context window id [0-9a-f-]{{36}}\.\nYou have {EFFECTIVE_CONTEXT_WINDOW} tokens left in this context window\.\n$" + ), + &initial_token_budget[0], ); assert_eq!( token_budget_texts(&requests[1]), - expected, + initial_token_budget, "steady-state context update should not advance the context window" ); @@ -144,9 +145,15 @@ async fn token_budget_remaining_context_emits_on_first_threshold_crossing() -> R assert_eq!(requests.len(), 5); let thread_id = test.session_configured.thread_id; - let full_context = format!( - "\nThread id {thread_id}.\nCurrent context window 0.\nYou have 9500 tokens left in this context window.\n" + let full_context = token_budget_texts(&requests[0]); + assert_eq!(full_context.len(), 1); + assert_regex_match( + &format!( + r"^\nThread id {thread_id}\.\nCurrent context window id [0-9a-f-]{{36}}\.\nYou have 9500 tokens left in this context window\.\n$" + ), + &full_context[0], ); + let full_context = full_context[0].clone(); let threshold_25 = "\nYou have 7000 tokens left in this context window.\n" .to_string(); @@ -233,16 +240,18 @@ async fn get_context_remaining_returns_token_budget_remaining_fragment() -> Resu ); let thread_id = test.session_configured.thread_id; - let full_context = format!( - "\nThread id {thread_id}.\nCurrent context window 0.\nYou have 9500 tokens left in this context window.\n" - ); let remaining_context = "\nYou have 7000 tokens left in this context window.\n" .to_string(); - assert_eq!( - token_budget_texts(&requests[1]), - vec![full_context, remaining_context.clone()] + let token_budgets = token_budget_texts(&requests[1]); + assert_eq!(token_budgets.len(), 2); + assert_regex_match( + &format!( + r"^\nThread id {thread_id}\.\nCurrent context window id [0-9a-f-]{{36}}\.\nYou have 9500 tokens left in this context window\.\n$" + ), + &token_budgets[0], ); + assert_eq!(token_budgets[1], remaining_context); assert_eq!( requests[2].function_call_output_content_and_success(call_id), Some((Some(remaining_context), None)) @@ -362,13 +371,31 @@ async fn token_budget_context_uses_new_window_after_compaction() -> Result<()> { assert_eq!(requests.len(), 3); let thread_id = test.session_configured.thread_id; - assert_eq!( - token_budget_texts(&requests[2]), - vec![format!( - "\nThread id {thread_id}.\nCurrent context window 1.\nYou have {EFFECTIVE_CONTEXT_WINDOW} tokens left in this context window.\n" - )], - "post-compaction full context should report context window 1" - ); + let initial_token_budget = token_budget_texts(&requests[0]); + assert_eq!(initial_token_budget.len(), 1); + let initial_window_id = assert_regex_match( + &format!( + r"^\nThread id {thread_id}\.\nCurrent context window id ([0-9a-f-]{{36}})\.\nYou have {EFFECTIVE_CONTEXT_WINDOW} tokens left in this context window\.\n$" + ), + &initial_token_budget[0], + ) + .get(1) + .expect("window id capture") + .as_str() + .to_string(); + let post_compaction_token_budget = token_budget_texts(&requests[2]); + assert_eq!(post_compaction_token_budget.len(), 1); + let post_compaction_window_id = assert_regex_match( + &format!( + r"^\nThread id {thread_id}\.\nCurrent context window id ([0-9a-f-]{{36}})\.\nYou have {EFFECTIVE_CONTEXT_WINDOW} tokens left in this context window\.\n$" + ), + &post_compaction_token_budget[0], + ) + .get(1) + .expect("window id capture") + .as_str() + .to_string(); + assert_ne!(post_compaction_window_id, initial_window_id); Ok(()) } @@ -429,12 +456,31 @@ async fn new_context_tool_starts_new_window_before_follow_up() -> Result<()> { "new_context should be exposed when token budget is enabled" ); let thread_id = test.session_configured.thread_id; - assert_eq!( - token_budget_texts(&requests[2]), - vec![format!( - "\nThread id {thread_id}.\nCurrent context window 1.\nYou have {EFFECTIVE_CONTEXT_WINDOW} tokens left in this context window.\n" - )] - ); + let initial_token_budget = token_budget_texts(&requests[0]); + assert_eq!(initial_token_budget.len(), 1); + let initial_window_id = assert_regex_match( + &format!( + r"^\nThread id {thread_id}\.\nCurrent context window id ([0-9a-f-]{{36}})\.\nYou have {EFFECTIVE_CONTEXT_WINDOW} tokens left in this context window\.\n$" + ), + &initial_token_budget[0], + ) + .get(1) + .expect("window id capture") + .as_str() + .to_string(); + let new_window_token_budget = token_budget_texts(&requests[2]); + assert_eq!(new_window_token_budget.len(), 1); + let window_id = assert_regex_match( + &format!( + r"^\nThread id {thread_id}\.\nCurrent context window id ([0-9a-f-]{{36}})\.\nYou have {EFFECTIVE_CONTEXT_WINDOW} tokens left in this context window\.\n$" + ), + &new_window_token_budget[0], + ) + .get(1) + .expect("window id capture") + .as_str() + .to_string(); + assert_ne!(window_id, initial_window_id); assert!( !requests[2].body_contains_text("request new context window"), "new_context should drop the prior window history before continuing the turn" @@ -448,7 +494,9 @@ async fn new_context_tool_starts_new_window_before_follow_up() -> Result<()> { &[("Final Follow-Up Request", &requests[2])], &ContextSnapshotOptions::default(), ); - let snapshot = snapshot.replace(&thread_id.to_string(), ""); + let snapshot = snapshot + .replace(&thread_id.to_string(), "") + .replace(&window_id, ""); insta::assert_snapshot!( "token_budget_new_context_window_tool_full_context", snapshot diff --git a/codex-rs/protocol/src/compacted_item.rs b/codex-rs/protocol/src/compacted_item.rs new file mode 100644 index 0000000000..3415373898 --- /dev/null +++ b/codex-rs/protocol/src/compacted_item.rs @@ -0,0 +1,94 @@ +use crate::models::ResponseItem; +use crate::protocol::CompactedItem; +use serde::Deserialize; + +// Before `window_number` was introduced, the numeric window number was serialized as +// `window_id`. Accept that shape so existing rollouts remain resumable. +impl<'de> Deserialize<'de> for CompactedItem { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let serialized = SerializedCompactedItem::deserialize(deserializer)?; + let mut window_number = serialized.window_number; + let window_id = match serialized.window_id { + Some(SerializedWindowId::Id(window_id)) => Some(window_id), + Some(SerializedWindowId::LegacyWindowNumber(legacy_window_number)) => { + window_number.get_or_insert(legacy_window_number); + None + } + None => None, + }; + Ok(Self { + message: serialized.message, + replacement_history: serialized.replacement_history, + window_number, + window_id, + }) + } +} + +#[derive(Deserialize)] +struct SerializedCompactedItem { + message: String, + #[serde(default)] + replacement_history: Option>, + #[serde(default)] + window_number: Option, + #[serde(default)] + window_id: Option, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum SerializedWindowId { + Id(String), + LegacyWindowNumber(u64), +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::Result; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[test] + fn serializes_window_number_and_id() -> Result<()> { + let item = CompactedItem { + message: "summary".to_string(), + replacement_history: None, + window_number: Some(3), + window_id: Some("019b3f6e-7a10-7cc3-8b6e-1d09e2f7a001".to_string()), + }; + + assert_eq!( + serde_json::to_value(item)?, + json!({ + "message": "summary", + "window_number": 3, + "window_id": "019b3f6e-7a10-7cc3-8b6e-1d09e2f7a001", + }) + ); + Ok(()) + } + + #[test] + fn migrates_legacy_numeric_window_id() -> Result<()> { + let item = serde_json::from_value::(json!({ + "message": "summary", + "window_id": 3, + }))?; + + assert_eq!( + item, + CompactedItem { + message: "summary".to_string(), + replacement_history: None, + window_number: Some(3), + window_id: None, + } + ); + Ok(()) + } +} diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index 3be16d08b7..c50e95d37f 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -10,6 +10,7 @@ pub use thread_id::ThreadId; pub use tool_name::ToolName; pub mod approvals; pub mod capabilities; +mod compacted_item; pub mod config_types; pub mod dynamic_tools; pub mod error; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 258150aa84..285114d8b1 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2971,13 +2971,17 @@ pub enum RolloutItem { EventMsg(EventMsg), } -#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] +#[derive(Serialize, Clone, Debug, PartialEq, JsonSchema, TS)] pub struct CompactedItem { pub message: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub replacement_history: Option>, + /// Monotonic position of this context window within the thread. #[serde(default, skip_serializing_if = "Option::is_none")] - pub window_id: Option, + pub window_number: Option, + /// UUIDv7 identity of this context window. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub window_id: Option, } impl From for ResponseItem { diff --git a/codex-rs/rollout/src/metadata_tests.rs b/codex-rs/rollout/src/metadata_tests.rs index 01e56f569d..4467439552 100644 --- a/codex-rs/rollout/src/metadata_tests.rs +++ b/codex-rs/rollout/src/metadata_tests.rs @@ -154,6 +154,7 @@ fn builder_from_items_falls_back_to_filename() { let items = vec![RolloutItem::Compacted(CompactedItem { message: "noop".to_string(), replacement_history: None, + window_number: None, window_id: None, })]; diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index b2953d7ef9..b45bb2d635 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -483,6 +483,7 @@ mod tests { let item = RolloutItem::Compacted(CompactedItem { message: "compacted".to_string(), replacement_history: None, + window_number: None, window_id: None, }); From 7e37354a587920eaf65fbb3c3005f7b8e6c7c208 Mon Sep 17 00:00:00 2001 From: Alex Daley Date: Thu, 18 Jun 2026 20:08:04 -0400 Subject: [PATCH 059/112] [plugins] Refresh plugin and tool caches after remote install (#28951) Summary - Refresh the installed remote-plugin snapshot and Codex Apps tools after completing a remote JIT install. - Gate `completed: true` on every expected `app_connector_id` appearing after the uncached `tools/list` refresh, while continuing to skip local bundle verification for server-side installs. - Keep the cached recommendations response and filter refreshed installed remote IDs locally, so this does not add another recommendations fetch. - Add regression coverage for tools appearing after the hard refresh and remaining absent after the refresh. The resumed model request sees the refreshed tool router when installation completes. Root Cause - Remote suggestions from `openai-curated-remote` returned `true` before taking the existing connector refresh path, leaving the resumed turn with the pre-install Apps tool catalog. Validation - `just test -p codex-core request_plugin_install` - `just test -p codex-core-plugins recommended_plugin_candidates_filter_installed_and_disabled_plugins` - `just test -p codex-core-plugins` - `just fix -p codex-core-plugins` - `just fix -p codex-core` - `just fmt` - `just test -p codex-core` was not fully clean locally: 2,729 passed, 26 failed, and 16 skipped. The failures were dominated by local Seatbelt/network/timing issues, including plugin-install timeouts under full-suite contention; the focused plugin-install runs pass. --- codex-rs/core-plugins/src/manager.rs | 15 + codex-rs/core-plugins/src/manager_tests.rs | 6 +- .../tools/handlers/request_plugin_install.rs | 45 ++- .../core/tests/common/apps_test_server.rs | 71 ++++- .../tests/suite/request_plugin_install.rs | 286 +++++++++++++++--- 5 files changed, 370 insertions(+), 53 deletions(-) diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 9d6c096336..5e2224bb7c 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -39,6 +39,7 @@ use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeError; use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome; use crate::marketplace_upgrade::configured_git_marketplace_names; use crate::marketplace_upgrade::upgrade_configured_git_marketplaces; +use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; use crate::remote::RecommendedPluginsMode; use crate::remote::RemoteInstalledPlugin; use crate::remote::RemotePluginCatalogError; @@ -1098,6 +1099,19 @@ impl PluginsManager { .iter() .map(|plugin| plugin.config_name.as_str()) .collect::>(); + let installed_remote_plugin_ids = { + let cache = match self.remote_installed_plugins_cache.read() { + Ok(cache) => cache, + Err(err) => err.into_inner(), + }; + cache + .as_deref() + .unwrap_or_default() + .iter() + .filter(|plugin| plugin.marketplace_name == REMOTE_GLOBAL_MARKETPLACE_NAME) + .map(|plugin| plugin.id.clone()) + .collect::>() + }; let disabled_plugin_ids = input .disabled_tools .iter() @@ -1109,6 +1123,7 @@ impl PluginsManager { .into_iter() .filter(|plugin| { !installed_plugin_ids.contains(plugin.config_id.as_str()) + && !installed_remote_plugin_ids.contains(plugin.remote_plugin_id.as_str()) && !disabled_plugin_ids.contains(plugin.config_id.as_str()) }) .map(|plugin| { diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index cc8ee19137..4f448ff160 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -4464,8 +4464,6 @@ plugins = true remote_plugin = true "#, ); - write_cached_plugin(tmp.path(), REMOTE_GLOBAL_MARKETPLACE_NAME, "linear"); - let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/ps/plugins/suggested")) @@ -4496,7 +4494,9 @@ remote_plugin = true let mut config = load_config(tmp.path(), tmp.path()).await; config.chatgpt_base_url = server.uri(); let manager = PluginsManager::new(tmp.path().to_path_buf()); - manager.write_remote_installed_plugins_cache(vec![remote_installed_plugin("linear")]); + let mut installed_linear = remote_installed_plugin("linear"); + installed_linear.id = "plugin_linear".to_string(); + manager.write_remote_installed_plugins_cache(vec![installed_linear]); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let disabled_tools = [ToolSuggestDisabledTool::plugin( "github@openai-curated-remote", diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install.rs b/codex-rs/core/src/tools/handlers/request_plugin_install.rs index 8047213140..ff7b421fd6 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install.rs @@ -328,7 +328,27 @@ async fn verify_request_plugin_install_completed( }), DiscoverableTool::Plugin(plugin) => { if is_remote_plugin_install_suggestion(&plugin.id) { - return true; + let (_, accessible_connectors) = tokio::join!( + refresh_remote_installed_plugins_cache_after_install( + session, + turn, + auth, + plugin.id.as_str(), + ), + refresh_missing_requested_connectors( + session, + turn, + auth, + &plugin.app_connector_ids, + plugin.id.as_str(), + ) + ); + return accessible_connectors.is_some_and(|accessible_connectors| { + all_requested_connectors_picked_up( + &plugin.app_connector_ids, + &accessible_connectors, + ) + }); } session.reload_user_config_layer().await; @@ -351,6 +371,29 @@ async fn verify_request_plugin_install_completed( } } +async fn refresh_remote_installed_plugins_cache_after_install( + session: &crate::session::session::Session, + turn: &crate::session::turn_context::TurnContext, + auth: Option<&codex_login::CodexAuth>, + tool_id: &str, +) { + let plugins_manager = &session.services.plugins_manager; + let plugins_config = turn.config.plugins_config_input(); + if let Err(err) = plugins_manager + .build_and_cache_remote_installed_plugin_marketplaces( + &plugins_config, + auth, + &[REMOTE_GLOBAL_MARKETPLACE_NAME], + /*on_effective_plugins_changed*/ None, + ) + .await + { + warn!( + "failed to refresh remote installed plugins cache after plugin install request for {tool_id}: {err:#}" + ); + } +} + fn is_remote_plugin_install_suggestion(plugin_id: &str) -> bool { plugin_id .rsplit_once('@') diff --git a/codex-rs/core/tests/common/apps_test_server.rs b/codex-rs/core/tests/common/apps_test_server.rs index 8a671ea5bc..0849087d0b 100644 --- a/codex-rs/core/tests/common/apps_test_server.rs +++ b/codex-rs/core/tests/common/apps_test_server.rs @@ -7,6 +7,8 @@ use codex_login::CodexAuth; use codex_models_manager::bundled_models_response; use serde_json::Value; use serde_json::json; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; use wiremock::Mock; use wiremock::MockServer; use wiremock::Request; @@ -59,6 +61,13 @@ pub enum AppsTestToolLoading { Searchable, } +#[derive(Clone, Copy)] +enum AppsTestToolsListBehavior { + AlwaysAvailable, + AvailableAfterInitialList, + AlwaysUnavailable, +} + impl AppsTestServer { pub async fn mount(server: &MockServer) -> Result { Self::mount_with_connector_name(server, CONNECTOR_NAME).await @@ -73,6 +82,7 @@ impl AppsTestServer { CONNECTOR_DESCRIPTION.to_string(), /*searchable*/ true, /*include_app_only_tool*/ false, + AppsTestToolsListBehavior::AlwaysAvailable, ) .await; Ok(Self { @@ -92,6 +102,7 @@ impl AppsTestServer { CONNECTOR_DESCRIPTION.to_string(), /*searchable*/ false, /*include_app_only_tool*/ false, + AppsTestToolsListBehavior::AlwaysAvailable, ) .await; Ok(Self { @@ -111,6 +122,42 @@ impl AppsTestServer { CONNECTOR_DESCRIPTION.to_string(), matches!(tool_loading, AppsTestToolLoading::Searchable), /*include_app_only_tool*/ true, + AppsTestToolsListBehavior::AlwaysAvailable, + ) + .await; + Ok(Self { + chatgpt_base_url: server.uri(), + }) + } + + pub async fn mount_with_tools_available_after_initial_list( + server: &MockServer, + ) -> Result { + Self::mount_with_tools_list_behavior( + server, + AppsTestToolsListBehavior::AvailableAfterInitialList, + ) + .await + } + + pub async fn mount_without_tools(server: &MockServer) -> Result { + Self::mount_with_tools_list_behavior(server, AppsTestToolsListBehavior::AlwaysUnavailable) + .await + } + + async fn mount_with_tools_list_behavior( + server: &MockServer, + tools_list_behavior: AppsTestToolsListBehavior, + ) -> Result { + mount_oauth_metadata(server).await; + mount_connectors_directory(server).await; + mount_streamable_http_json_rpc( + server, + CONNECTOR_NAME.to_string(), + CONNECTOR_DESCRIPTION.to_string(), + /*searchable*/ false, + /*include_app_only_tool*/ false, + tools_list_behavior, ) .await; Ok(Self { @@ -264,6 +311,7 @@ async fn mount_streamable_http_json_rpc( connector_description: String, searchable: bool, include_app_only_tool: bool, + tools_list_behavior: AppsTestToolsListBehavior, ) { Mock::given(method("POST")) .and(path_regex("^/api/codex/apps/?$")) @@ -272,6 +320,8 @@ async fn mount_streamable_http_json_rpc( connector_description, searchable, include_app_only_tool, + tools_list_behavior, + tools_list_calls: AtomicUsize::new(0), }) .mount(server) .await; @@ -282,6 +332,8 @@ struct CodexAppsJsonRpcResponder { connector_description: String, searchable: bool, include_app_only_tool: bool, + tools_list_behavior: AppsTestToolsListBehavior, + tools_list_calls: AtomicUsize, } impl Respond for CodexAppsJsonRpcResponder { @@ -327,6 +379,12 @@ impl Respond for CodexAppsJsonRpcResponder { } "notifications/initialized" => ResponseTemplate::new(202), "tools/list" => { + let list_index = self.tools_list_calls.fetch_add(1, Ordering::SeqCst); + let tools_available = match self.tools_list_behavior { + AppsTestToolsListBehavior::AlwaysAvailable => true, + AppsTestToolsListBehavior::AvailableAfterInitialList => list_index > 0, + AppsTestToolsListBehavior::AlwaysUnavailable => false, + }; let id = body.get("id").cloned().unwrap_or(Value::Null); let mut response = json!({ "jsonrpc": "2.0", @@ -428,7 +486,15 @@ impl Respond for CodexAppsJsonRpcResponder { "nextCursor": null } }); - if self.searchable + if !tools_available + && let Some(tools) = response + .pointer_mut("/result/tools") + .and_then(Value::as_array_mut) + { + tools.clear(); + } + if tools_available + && self.searchable && let Some(tools) = response .pointer_mut("/result/tools") .and_then(Value::as_array_mut) @@ -455,7 +521,8 @@ impl Respond for CodexAppsJsonRpcResponder { })); } } - if self.include_app_only_tool + if tools_available + && self.include_app_only_tool && let Some(tools) = response .pointer_mut("/result/tools") .and_then(Value::as_array_mut) diff --git a/codex-rs/core/tests/suite/request_plugin_install.rs b/codex-rs/core/tests/suite/request_plugin_install.rs index f3da1bea44..f307fce05a 100644 --- a/codex-rs/core/tests/suite/request_plugin_install.rs +++ b/codex-rs/core/tests/suite/request_plugin_install.rs @@ -11,6 +11,7 @@ use codex_login::CodexAuth; use codex_models_manager::bundled_models_response; use codex_protocol::approvals::ElicitationAction; use codex_protocol::approvals::ElicitationRequest; +use codex_protocol::approvals::ElicitationRequestEvent; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; @@ -38,6 +39,7 @@ use core_test_support::wait_for_event_match; use serde_json::Value; use serde_json::json; use wiremock::Mock; +use wiremock::MockGuard; use wiremock::ResponseTemplate; use wiremock::matchers::method; use wiremock::matchers::path; @@ -47,6 +49,11 @@ const TOOL_SEARCH_TOOL_NAME: &str = "tool_search"; const LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME: &str = "list_available_plugins_to_install"; const REQUEST_PLUGIN_INSTALL_TOOL_NAME: &str = "request_plugin_install"; const DISCOVERABLE_GMAIL_ID: &str = "connector_68df038e0ba48191908c8434991bbac2"; +const REMOTE_CALENDAR_PLUGIN_CONFIG_ID: &str = "calendar@openai-curated-remote"; +const REMOTE_CALENDAR_PLUGIN_ID: &str = "plugin_calendar"; +const CALENDAR_CONNECTOR_ID: &str = "calendar"; +const CALENDAR_NAMESPACE: &str = "mcp__codex_apps__calendar"; +const CALENDAR_CREATE_EVENT_TOOL: &str = "_create_event"; fn tool_names(body: &Value) -> Vec { body.get("tools") @@ -133,6 +140,134 @@ async fn build_test( builder.build(server).await } +async fn start_install_turn(test: &TestCodex, prompt: &str) -> Result { + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: prompt.to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: ThreadSettingsOverrides { + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: test.session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, + }) + .await?; + + Ok(wait_for_event_match(&test.codex, |event| match event { + EventMsg::ElicitationRequest(request) => Some(request.clone()), + _ => None, + }) + .await) +} + +async fn resolve_install_elicitation( + test: &TestCodex, + elicitation: ElicitationRequestEvent, + decision: ElicitationAction, +) -> Result<()> { + test.codex + .submit(Op::ResolveElicitation { + server_name: elicitation.server_name, + request_id: elicitation.id, + decision, + content: None, + meta: None, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + Ok(()) +} + +async fn mount_remote_calendar_recommendation(server: &wiremock::MockServer) { + Mock::given(method("GET")) + .and(path("/ps/plugins/suggested")) + .and(query_param("scope", "GLOBAL")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "enabled": true, + "plugins": [{ + "id": REMOTE_CALENDAR_PLUGIN_ID, + "name": "calendar", + "status": "ENABLED", + "installation_policy": "AVAILABLE", + "release": { + "display_name": "Calendar", + "app_ids": [CALENDAR_CONNECTOR_ID] + } + }] + }))) + .expect(1) + .mount(server) + .await; +} + +fn remote_installed_plugins_response(plugins: Vec) -> ResponseTemplate { + ResponseTemplate::new(200).set_body_json(json!({ + "plugins": plugins, + "pagination": { + "next_page_token": null + } + })) +} + +async fn mount_empty_remote_installed_plugins(server: &wiremock::MockServer) -> MockGuard { + Mock::given(method("GET")) + .and(path("/ps/plugins/installed")) + .respond_with(remote_installed_plugins_response(Vec::new())) + .mount_as_scoped(server) + .await +} + +async fn mount_remote_calendar_installed_plugins(server: &wiremock::MockServer) { + Mock::given(method("GET")) + .and(path("/ps/plugins/installed")) + .and(query_param("scope", "GLOBAL")) + .respond_with(remote_installed_plugins_response(vec![json!({ + "id": REMOTE_CALENDAR_PLUGIN_ID, + "name": "calendar", + "scope": "GLOBAL", + "status": "ENABLED", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "display_name": "Calendar", + "description": "Manage calendar events.", + "interface": {} + }, + "enabled": true + })])) + .with_priority(1) + .mount(server) + .await; + for scope in ["WORKSPACE", "USER"] { + Mock::given(method("GET")) + .and(path("/ps/plugins/installed")) + .and(query_param("scope", scope)) + .respond_with(remote_installed_plugins_response(Vec::new())) + .with_priority(1) + .mount(server) + .await; + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn explicit_false_preserves_legacy_workflow() -> Result<()> { skip_if_no_network!(Ok(())); @@ -278,6 +413,10 @@ async fn endpoint_recommendation_adds_install_identity_only_to_elicitation_metad { skip_if_no_network!(Ok(())); + run_remote_plugin_install_metadata_case().await +} + +async fn run_remote_plugin_install_metadata_case() -> Result<()> { const REMOTE_PLUGIN_ID: &str = "plugin_connector_github"; const APP_CONNECTOR_ID: &str = "connector_github"; @@ -325,40 +464,7 @@ async fn endpoint_recommendation_adds_install_identity_only_to_elicitation_metad ) .await; let test = build_test(&server, &apps_server).await?; - let (sandbox_policy, permission_profile) = - turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); - - test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "use GitHub".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: ThreadSettingsOverrides { - approval_policy: Some(AskForApproval::Never), - sandbox_policy: Some(sandbox_policy), - permission_profile, - collaboration_mode: Some(CollaborationMode { - mode: ModeKind::Default, - settings: Settings { - model: test.session_configured.model.clone(), - reasoning_effort: None, - developer_instructions: None, - }, - }), - ..Default::default() - }, - }) - .await?; - - let elicitation = wait_for_event_match(&test.codex, |event| match event { - EventMsg::ElicitationRequest(request) => Some(request.clone()), - _ => None, - }) - .await; + let elicitation = start_install_turn(&test, "use GitHub").await?; let ElicitationRequest::Form { meta: Some(meta), .. } = &elicitation.request @@ -368,19 +474,7 @@ async fn endpoint_recommendation_adds_install_identity_only_to_elicitation_metad assert_eq!(meta["remote_plugin_id"], REMOTE_PLUGIN_ID); assert_eq!(meta["app_connector_ids"], json!([APP_CONNECTOR_ID])); - test.codex - .submit(Op::ResolveElicitation { - server_name: elicitation.server_name, - request_id: elicitation.id, - decision: ElicitationAction::Decline, - content: None, - meta: None, - }) - .await?; - wait_for_event(&test.codex, |event| { - matches!(event, EventMsg::TurnComplete(_)) - }) - .await; + resolve_install_elicitation(&test, elicitation, ElicitationAction::Decline).await?; let requests = mock.requests(); assert_eq!(requests.len(), 2); @@ -392,6 +486,104 @@ async fn endpoint_recommendation_adds_install_identity_only_to_elicitation_metad Ok(()) } +#[derive(Clone, Copy)] +enum RefreshedAppsTools { + Available, + Missing, +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_plugin_install_refreshes_plugin_and_apps_tool_caches() -> Result<()> { + skip_if_no_network!(Ok(())); + + run_remote_plugin_install_refresh_case(RefreshedAppsTools::Available).await?; + run_remote_plugin_install_refresh_case(RefreshedAppsTools::Missing).await +} + +async fn run_remote_plugin_install_refresh_case(refreshed_tools: RefreshedAppsTools) -> Result<()> { + let server = start_mock_server().await; + let apps_server = match refreshed_tools { + RefreshedAppsTools::Available => { + AppsTestServer::mount_with_tools_available_after_initial_list(&server).await? + } + RefreshedAppsTools::Missing => AppsTestServer::mount_without_tools(&server).await?, + }; + mount_remote_calendar_recommendation(&server).await; + let initial_remote_installed_plugins = mount_empty_remote_installed_plugins(&server).await; + + let install_call_id = "install-calendar"; + let suggest_reason = "Use Calendar for this request"; + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + install_call_id, + REQUEST_PLUGIN_INSTALL_TOOL_NAME, + &serde_json::to_string(&json!({ + "plugin_id": REMOTE_CALENDAR_PLUGIN_CONFIG_ID, + "suggest_reason": suggest_reason + }))?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + let test = build_test(&server, &apps_server).await?; + + let elicitation = start_install_turn(&test, "use Calendar").await?; + mount_remote_calendar_installed_plugins(&server).await; + drop(initial_remote_installed_plugins); + resolve_install_elicitation(&test, elicitation, ElicitationAction::Accept).await?; + + let requests = mock.requests(); + assert_eq!(requests.len(), 2); + assert!( + requests[0] + .tool_by_name(CALENDAR_NAMESPACE, CALENDAR_CREATE_EVENT_TOOL) + .is_none(), + "calendar tool should be absent before the remote install" + ); + let completed = matches!(refreshed_tools, RefreshedAppsTools::Available); + assert_eq!( + serde_json::from_str::( + &requests[1] + .function_call_output_text(install_call_id) + .expect("install tool output") + )?, + json!({ + "completed": completed, + "user_confirmed": true, + "tool_type": "plugin", + "action_type": "install", + "tool_id": REMOTE_CALENDAR_PLUGIN_CONFIG_ID, + "tool_name": "Calendar", + "suggest_reason": suggest_reason + }) + ); + assert_eq!( + requests[1] + .tool_by_name(CALENDAR_NAMESPACE, CALENDAR_CREATE_EVENT_TOOL) + .is_some(), + completed, + "the resumed router should reflect the refreshed Apps tools" + ); + assert!( + !tool_names(&requests[1].body_json()) + .iter() + .any(|name| name == REQUEST_PLUGIN_INSTALL_TOOL_NAME), + "the refreshed installed-plugin cache should filter the cached recommendation" + ); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn endpoint_mode_with_no_eligible_candidates_exposes_no_suggestion_tools() -> Result<()> { skip_if_no_network!(Ok(())); From 8e7c213f8f520c65e65578e59f6a090437bcddaa Mon Sep 17 00:00:00 2001 From: Peter Bakkum Date: Thu, 18 Jun 2026 17:11:21 -0700 Subject: [PATCH 060/112] Always use AVAS for realtime WebRTC calls (#28856) ## Summary - Remove the realtime `architecture` selector from core protocol, app-server protocol, config parsing, generated schemas, and callers. - Always create WebRTC realtime calls with the AVAS query params: `intent=quicksilver&architecture=avas`. - Keep direct websocket realtime behavior on the existing config/default path, while WebRTC starts without an explicit version now default to realtime v1 because AVAS requires v1. ## Notes - WebRTC realtime now means AVAS. If a caller explicitly asks to start WebRTC with realtime v2, Codex rejects that request because the AVAS WebRTC path only supports realtime v1. Websocket realtime is separate and can still use realtime v2. - The old `[realtime] architecture = "realtimeapi" | "avas"` config knob is removed. Local configs that still set it will need to delete that line. - Some app-server tests that were only trying to exercise realtime v2 protocol behavior now use websocket transport, because WebRTC is intentionally locked to AVAS/v1. Separate WebRTC tests cover the AVAS query params, v1 startup, SDP flow, and sideband join. ## Validation - Merged fresh `origin/main` at `83e6a786a2`. - `just fmt` - `just write-config-schema` - `just write-app-server-schema` - `git diff --check` - `just test -p codex-api -p codex-core -p codex-app-server-protocol -p codex-app-server realtime` (176 passed) - `just test -p codex-protocol -p codex-config` (413 passed) --- .../schema/json/ClientRequest.json | 7 - .../codex_app_server_protocol.schemas.json | 7 - .../codex_app_server_protocol.v2.schemas.json | 7 - .../RealtimeConversationArchitecture.ts | 5 - .../schema/typescript/index.ts | 1 - .../src/protocol/common.rs | 8 - .../src/protocol/v2/realtime.rs | 4 - codex-rs/app-server/README.md | 8 +- .../src/request_processors/turn_processor.rs | 1 - .../tests/suite/v2/experimental_api.rs | 2 - .../tests/suite/v2/realtime_conversation.rs | 164 +++++++++++------ .../codex-api/src/endpoint/realtime_call.rs | 90 +++++---- codex-rs/config/src/config_toml.rs | 3 - codex-rs/core/config.schema.json | 10 - codex-rs/core/src/client.rs | 9 +- codex-rs/core/src/config/config_tests.rs | 4 - codex-rs/core/src/config/mod.rs | 1 - codex-rs/core/src/realtime_conversation.rs | 62 +++---- codex-rs/core/tests/suite/compact_remote.rs | 1 - .../core/tests/suite/realtime_conversation.rs | 172 +++++++++++++----- codex-rs/protocol/src/protocol.rs | 11 -- 21 files changed, 335 insertions(+), 242 deletions(-) delete mode 100644 codex-rs/app-server-protocol/schema/typescript/RealtimeConversationArchitecture.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index e183c88b08..275d0fc13e 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2119,13 +2119,6 @@ ], "type": "object" }, - "RealtimeConversationArchitecture": { - "enum": [ - "realtimeapi", - "avas" - ], - "type": "string" - }, "RealtimeConversationVersion": { "enum": [ "v1", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 0a18c588f3..cb9c7a6225 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -14330,13 +14330,6 @@ "title": "RawResponseItemCompletedNotification", "type": "object" }, - "RealtimeConversationArchitecture": { - "enum": [ - "realtimeapi", - "avas" - ], - "type": "string" - }, "RealtimeConversationVersion": { "enum": [ "v1", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 0ddfdb62bd..df16d65884 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -10754,13 +10754,6 @@ "title": "RawResponseItemCompletedNotification", "type": "object" }, - "RealtimeConversationArchitecture": { - "enum": [ - "realtimeapi", - "avas" - ], - "type": "string" - }, "RealtimeConversationVersion": { "enum": [ "v1", diff --git a/codex-rs/app-server-protocol/schema/typescript/RealtimeConversationArchitecture.ts b/codex-rs/app-server-protocol/schema/typescript/RealtimeConversationArchitecture.ts deleted file mode 100644 index 4467e4a0f7..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/RealtimeConversationArchitecture.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type RealtimeConversationArchitecture = "realtimeapi" | "avas"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 786d4c72a8..f7db65d5d8 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -54,7 +54,6 @@ export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction"; export type { ParsedCommand } from "./ParsedCommand"; export type { Personality } from "./Personality"; export type { PlanType } from "./PlanType"; -export type { RealtimeConversationArchitecture } from "./RealtimeConversationArchitecture"; export type { RealtimeConversationVersion } from "./RealtimeConversationVersion"; export type { RealtimeOutputModality } from "./RealtimeOutputModality"; export type { RealtimeVoice } from "./RealtimeVoice"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 3336f74a63..4e9a785399 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1702,7 +1702,6 @@ mod tests { use codex_protocol::account::PlanType; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; use codex_protocol::parse_command::ParsedCommand; - use codex_protocol::protocol::RealtimeConversationArchitecture; use codex_protocol::protocol::RealtimeConversationVersion; use codex_protocol::protocol::RealtimeOutputModality; use codex_protocol::protocol::RealtimeVoice; @@ -3139,7 +3138,6 @@ mod tests { let request = ClientRequest::ThreadRealtimeStart { request_id: RequestId::Integer(9), params: v2::ThreadRealtimeStartParams { - architecture: Some(RealtimeConversationArchitecture::Avas), client_managed_handoffs: Some(true), codex_responses_as_items: None, codex_response_item_prefix: None, @@ -3160,7 +3158,6 @@ mod tests { "method": "thread/realtime/start", "id": 9, "params": { - "architecture": "avas", "threadId": "thr_123", "clientManagedHandoffs": true, "codexResponsesAsItems": null, @@ -3186,7 +3183,6 @@ mod tests { let default_prompt_request = ClientRequest::ThreadRealtimeStart { request_id: RequestId::Integer(9), params: v2::ThreadRealtimeStartParams { - architecture: None, client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, @@ -3207,7 +3203,6 @@ mod tests { "method": "thread/realtime/start", "id": 9, "params": { - "architecture": null, "threadId": "thr_123", "clientManagedHandoffs": null, "codexResponsesAsItems": null, @@ -3228,7 +3223,6 @@ mod tests { let null_prompt_request = ClientRequest::ThreadRealtimeStart { request_id: RequestId::Integer(9), params: v2::ThreadRealtimeStartParams { - architecture: None, client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, @@ -3249,7 +3243,6 @@ mod tests { "method": "thread/realtime/start", "id": 9, "params": { - "architecture": null, "threadId": "thr_123", "clientManagedHandoffs": null, "codexResponsesAsItems": null, @@ -3436,7 +3429,6 @@ mod tests { let request = ClientRequest::ThreadRealtimeStart { request_id: RequestId::Integer(1), params: v2::ThreadRealtimeStartParams { - architecture: None, client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs b/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs index b30ecd7b62..4b8b2056ea 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs @@ -1,6 +1,5 @@ use codex_protocol::protocol::ConversationTextRole; use codex_protocol::protocol::RealtimeAudioFrame as CoreRealtimeAudioFrame; -use codex_protocol::protocol::RealtimeConversationArchitecture; use codex_protocol::protocol::RealtimeConversationVersion; use codex_protocol::protocol::RealtimeOutputModality; use codex_protocol::protocol::RealtimeVoice; @@ -67,9 +66,6 @@ impl From for CoreRealtimeAudioFrame { #[ts(export_to = "v2/")] pub struct ThreadRealtimeStartParams { pub thread_id: String, - /// Overrides the configured realtime architecture for this session only. - #[ts(optional = nullable)] - pub architecture: Option, /// Leaves Codex response handoffs to the client's explicit append calls instead of forwarding /// them automatically. Defaults to false. #[ts(optional = nullable)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 871f9cee79..5035ebf718 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -172,7 +172,7 @@ Example with notification opt-out: - `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success. - `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Review and manual compaction turns reject `turn/steer`. - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. -- `thread/realtime/start` — start a thread-scoped realtime session (experimental); pass `outputModality: "text"` or `outputModality: "audio"` to choose model output, optionally pass `model` and `version` to override configured realtime selection for this session only, and pass `includeStartupContext: false` to omit Codex's generated startup context. By default, automatic Codex text follows the protocol's speakable output path. Pass `clientManagedHandoffs: true` to disable automatic Codex response delivery so only the client's explicit append calls produce handoffs. Pass `codexResponsesAsItems: true` to send automatic Codex responses as realtime conversation items instead, and optionally pass `codexResponseItemPrefix` to prepend experiment instructions to those items. For V1 sessions, pass `codexResponseHandoffPrefix` while item mode is disabled to route automatic Codex commentary through `conversation.handoff.append` with that prefix; final answers remain unprefixed. Returns `{}` and streams `thread/realtime/*` notifications. Omit `transport` for the websocket transport, or pass `{ "type": "webrtc", "sdp": "..." }` to create a WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`. +- `thread/realtime/start` — start a thread-scoped realtime session (experimental); pass `outputModality: "text"` or `outputModality: "audio"` to choose model output, optionally pass `model` and, for websocket transport only, `version` to override configured realtime selection for this session only, and pass `includeStartupContext: false` to omit Codex's generated startup context. By default, automatic Codex text follows the protocol's speakable output path. Pass `clientManagedHandoffs: true` to disable automatic Codex response delivery so only the client's explicit append calls produce handoffs. Pass `codexResponsesAsItems: true` to send automatic Codex responses as realtime conversation items instead, and optionally pass `codexResponseItemPrefix` to prepend experiment instructions to those items. For V1 sessions, pass `codexResponseHandoffPrefix` while item mode is disabled to route automatic Codex commentary through `conversation.handoff.append` with that prefix; final answers remain unprefixed. Returns `{}` and streams `thread/realtime/*` notifications. Omit `transport` for the websocket transport, or pass `{ "type": "webrtc", "sdp": "..." }` to create an AVAS/v1 WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`. Explicit `version: "v2"` requests are rejected for WebRTC. - `thread/realtime/appendAudio` — append an input audio chunk to the active realtime session (experimental); returns `{}`. - `thread/realtime/appendText` — append text input to the active realtime session with a required `role` of `user`, `developer`, or `assistant` (experimental); returns `{}`. Older clients that omit `role` default to `user`. - `thread/realtime/appendSpeech` — append text that the realtime model should speak to the user (experimental); returns `{}`. @@ -885,8 +885,12 @@ Then send `offer.sdp` to app-server. Core uses `experimental_realtime_ws_backend Omit `prompt` to use Codex's default realtime backend prompt. Send `prompt: null` or `prompt: ""` when the session should start without that default backend prompt. -Clients may also pass `model` and `version` on `thread/realtime/start` to select a +Clients may also pass `model` on `thread/realtime/start` to select a different realtime session configuration without changing thread or user config. +For websocket transport, clients may pass `version` to select the realtime +protocol version for this session only. WebRTC sessions always use AVAS with +realtime v1; omitting `version` uses v1, and explicitly passing `version: "v2"` +is rejected. Pass `includeStartupContext: false` to skip Codex's startup context for this session while still using the selected backend prompt. Pass `clientManagedHandoffs: true` to suppress automatic Codex response handoffs diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index bd7aaf1ba0..b7408a8c3b 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -941,7 +941,6 @@ impl TurnRequestProcessor { request_id, thread.as_ref(), Op::RealtimeConversationStart(ConversationStartParams { - architecture: params.architecture, client_managed_handoffs: params.client_managed_handoffs.unwrap_or(false), codex_responses_as_items: params.codex_responses_as_items.unwrap_or(false), codex_response_item_prefix: params.codex_response_item_prefix, diff --git a/codex-rs/app-server/tests/suite/v2/experimental_api.rs b/codex-rs/app-server/tests/suite/v2/experimental_api.rs index 62ad4c0677..d9d3469936 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_api.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_api.rs @@ -81,7 +81,6 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R let request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { - architecture: None, client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, @@ -198,7 +197,6 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result< let request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { - architecture: None, client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, diff --git a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index d9f7d6fd37..199e3a72d5 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -78,6 +78,7 @@ use wiremock::matchers::path; use wiremock::matchers::path_regex; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); +const DELEGATED_SHELL_TURN_TIMEOUT: Duration = Duration::from_secs(30); const DELEGATED_SHELL_TOOL_TIMEOUT_MS: u64 = 30_000; const STARTUP_CONTEXT_HEADER: &str = "Startup context from Codex."; const V2_STEERING_ACKNOWLEDGEMENT: &str = @@ -347,7 +348,6 @@ impl RealtimeE2eHarness { let start_request_id = self .mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { - architecture: None, client_managed_handoffs, thread_id: self.thread_id.clone(), codex_response_item_prefix: codex_responses_as_items @@ -385,6 +385,58 @@ impl RealtimeE2eHarness { Ok(StartedWebrtcRealtime { started, sdp }) } + async fn start_websocket_realtime(&mut self) -> Result { + self.start_websocket_realtime_with_codex_responses_as_items( + /*codex_responses_as_items*/ None, + ) + .await + } + + async fn start_websocket_realtime_with_codex_response_items( + &mut self, + ) -> Result { + self.start_websocket_realtime_with_codex_responses_as_items( + /*codex_responses_as_items*/ Some(true), + ) + .await + } + + async fn start_websocket_realtime_with_codex_responses_as_items( + &mut self, + codex_responses_as_items: Option, + ) -> Result { + let start_request_id = self + .mcp + .send_thread_realtime_start_request(ThreadRealtimeStartParams { + thread_id: self.thread_id.clone(), + client_managed_handoffs: None, + codex_response_item_prefix: codex_responses_as_items + .unwrap_or(false) + .then(|| RESPONSE_ITEM_PREFIX.to_string()), + codex_response_handoff_prefix: None, + codex_responses_as_items, + model: None, + output_modality: RealtimeOutputModality::Audio, + include_startup_context: None, + prompt: Some(Some("backend prompt".to_string())), + realtime_session_id: None, + transport: None, + version: None, + voice: None, + }) + .await?; + let start_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + self.mcp + .read_stream_until_response_message(RequestId::Integer(start_request_id)), + ) + .await??; + let _: ThreadRealtimeStartResponse = to_response(start_response)?; + + self.read_notification::("thread/realtime/started") + .await + } + async fn read_notification(&mut self, method: &str) -> Result { read_notification(&mut self.mcp, method).await } @@ -619,7 +671,6 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { - architecture: None, client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, @@ -911,7 +962,6 @@ async fn realtime_start_can_skip_startup_context() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { - architecture: None, client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, @@ -1010,7 +1060,6 @@ async fn realtime_text_output_modality_requests_text_output_and_final_transcript let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { - architecture: None, client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, @@ -1192,7 +1241,6 @@ async fn realtime_conversation_stop_emits_closed_notification() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { - architecture: None, client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, @@ -1297,7 +1345,6 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> { let thread_id = thread_start.thread.id; let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { - architecture: None, client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, @@ -1326,7 +1373,7 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> { read_notification::(&mut mcp, "thread/realtime/started") .await?; assert_eq!(started.thread_id, thread_id); - assert_eq!(started.version, RealtimeConversationVersion::V2); + assert_eq!(started.version, RealtimeConversationVersion::V1); let sdp_notification = read_notification::(&mut mcp, "thread/realtime/sdp").await?; @@ -1353,7 +1400,7 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> { ); assert_eq!( realtime_server.single_handshake().uri(), - "/v1/realtime?call_id=rtc_app_test" + "/v1/realtime?intent=quicksilver&call_id=rtc_app_test" ); let stop_request_id = mcp @@ -1382,7 +1429,10 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> { let request = call_capture.single_request(); assert_eq!(request.url.path(), "/v1/realtime/calls"); - assert_eq!(request.url.query(), None); + assert_eq!( + request.url.query(), + Some("intent=quicksilver&architecture=avas") + ); assert_eq!( request .headers @@ -1391,8 +1441,7 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> { Some("multipart/form-data; boundary=codex-realtime-call-boundary") ); let body = String::from_utf8(request.body).context("multipart body should be utf-8")?; - let session = r#"{"tool_choice":"auto","type":"realtime","model":"gpt-realtime-1.5","instructions":"backend prompt\n\nstartup context","output_modalities":["audio"],"audio":{"input":{"format":{"type":"audio/pcm","rate":24000},"noise_reduction":{"type":"near_field"},"transcription":{"model":"gpt-4o-mini-transcribe"},"turn_detection":{"type":"server_vad","interrupt_response":true,"create_response":true,"silence_duration_ms":500}},"output":{"format":{"type":"audio/pcm","rate":24000},"voice":"marin"}},"tools":[{"type":"function","name":"background_agent","description":"Send a user request to the background agent. Use this as the default action. Do not rephrase the user's ask or rewrite it in your own words; pass along the user's own words. If the background agent is idle, this starts a new task and returns the final result to the user. If the background agent is already working on a task, this sends the request as guidance to steer that previous task. If the user asks to do something next, later, after this, or once current work finishes, call this tool so the work is actually queued instead of merely promising to do it later.","parameters":{"type":"object","properties":{"prompt":{"type":"string","description":"The user request to delegate to the background agent."}},"required":["prompt"],"additionalProperties":false}},{"type":"function","name":"remain_silent","description":"Call this when the best response is to say nothing. Use it instead of speaking after hidden system/control messages, after background agent updates in silent modes, or whenever acknowledging aloud would be distracting. This tool has no user-visible effect.","parameters":{"type":"object","properties":{},"additionalProperties":false}}]}"#; - let session = normalized_json_string(session)?; + let session = normalized_json_string(v1_session_create_json())?; assert_eq!( body, format!( @@ -1785,9 +1834,9 @@ async fn realtime_automatic_standalone_output_is_item_and_append_speaks() -> Res .await?; let started = harness - .start_webrtc_realtime_with_codex_response_items("v=offer\r\n") + .start_websocket_realtime_with_codex_response_items() .await?; - assert_eq!(started.started.version, RealtimeConversationVersion::V2); + assert_eq!(started.version, RealtimeConversationVersion::V2); assert_eq!( harness.sideband_outbound_request(/*request_index*/ 0).await["type"].as_str(), Some("session.update") @@ -1868,9 +1917,9 @@ async fn realtime_automatic_handoff_output_is_item_and_append_speaks() -> Result .await?; let started = harness - .start_webrtc_realtime_with_codex_response_items("v=offer\r\n") + .start_websocket_realtime_with_codex_response_items() .await?; - assert_eq!(started.started.version, RealtimeConversationVersion::V2); + assert_eq!(started.version, RealtimeConversationVersion::V2); assert_eq!( harness.sideband_outbound_request(/*request_index*/ 0).await["type"].as_str(), Some("session.update") @@ -1920,7 +1969,7 @@ async fn realtime_automatic_handoff_output_is_item_and_append_speaks() -> Result } #[tokio::test] -async fn webrtc_v2_assistant_output_without_handoff_reaches_realtime_context() -> Result<()> { +async fn websocket_v2_assistant_output_without_handoff_reaches_realtime_context() -> Result<()> { skip_if_no_network!(Ok(())); let final_answer = "long output ".repeat(1_000); @@ -1951,9 +2000,9 @@ async fn webrtc_v2_assistant_output_without_handoff_reaches_realtime_context() - .await?; let started = harness - .start_webrtc_realtime_with_codex_response_items("v=offer\r\n") + .start_websocket_realtime_with_codex_response_items() .await?; - assert_eq!(started.started.version, RealtimeConversationVersion::V2); + assert_eq!(started.version, RealtimeConversationVersion::V2); let request_id = harness .mcp @@ -2000,10 +2049,10 @@ async fn webrtc_v2_assistant_output_without_handoff_reaches_realtime_context() - } #[tokio::test] -async fn webrtc_v2_forwards_audio_and_text_between_client_and_sideband() -> Result<()> { +async fn websocket_v2_forwards_audio_and_text_between_client_and_sideband() -> Result<()> { skip_if_no_network!(Ok(())); - // Phase 1: create a v2 WebRTC conversation whose sideband sends transcript + output audio + // Phase 1: create a v2 websocket conversation whose sideband sends transcript + output audio // after the client has had a chance to append input. let mut harness = RealtimeE2eHarness::new( RealtimeTestVersion::V2, @@ -2028,13 +2077,13 @@ async fn webrtc_v2_forwards_audio_and_text_between_client_and_sideband() -> Resu ) .await?; - let started = harness.start_webrtc_realtime("v=offer\r\n").await?; - assert_eq!(started.started.version, RealtimeConversationVersion::V2); + let started = harness.start_websocket_realtime().await?; + assert_eq!(started.version, RealtimeConversationVersion::V2); assert_v2_session_update(&harness.sideband_outbound_request(/*request_index*/ 0).await)?; // Phase 2: drive app-server as the client would: append audio, append text, then receive // transcript/audio notifications that came from the sideband socket. - let thread_id = started.started.thread_id.clone(); + let thread_id = started.thread_id.clone(); harness.append_audio(thread_id.clone()).await?; harness.append_text(thread_id, "hello").await?; @@ -2083,7 +2132,7 @@ async fn webrtc_v2_forwards_audio_and_text_between_client_and_sideband() -> Resu /// Text input is append-only, so app-server should send the user message without /// requesting a new realtime response. #[tokio::test] -async fn webrtc_v2_text_input_is_append_only_while_response_is_active() -> Result<()> { +async fn websocket_v2_text_input_is_append_only_while_response_is_active() -> Result<()> { skip_if_no_network!(Ok(())); // Phase 1: script a server-side response that becomes active after the first @@ -2112,8 +2161,8 @@ async fn webrtc_v2_text_input_is_append_only_while_response_is_active() -> Resul ) .await?; - let started = harness.start_webrtc_realtime("v=offer\r\n").await?; - assert_eq!(started.started.version, RealtimeConversationVersion::V2); + let started = harness.start_websocket_realtime().await?; + assert_eq!(started.version, RealtimeConversationVersion::V2); // From here on, `sideband_outbound_request(n)` reads outbound messages to // the fake Realtime API sideband websocket. These are not client-facing @@ -2122,7 +2171,7 @@ async fn webrtc_v2_text_input_is_append_only_while_response_is_active() -> Resul // Phase 2: send the first text turn. Text input is append-only, so this // sends only the user text item. - let thread_id = started.started.thread_id.clone(); + let thread_id = started.thread_id.clone(); harness.append_text(thread_id.clone(), "first").await?; assert_v2_user_text_item( &harness.sideband_outbound_request(/*request_index*/ 1).await, @@ -2157,7 +2206,7 @@ async fn webrtc_v2_text_input_is_append_only_while_response_is_active() -> Resul /// Regression coverage for append-only Realtime V2 text input when the active /// response is cancelled instead of completed. #[tokio::test] -async fn webrtc_v2_text_input_is_append_only_when_response_is_cancelled() -> Result<()> { +async fn websocket_v2_text_input_is_append_only_when_response_is_cancelled() -> Result<()> { skip_if_no_network!(Ok(())); // Phase 1: script a server-side response that becomes active after the first @@ -2180,13 +2229,13 @@ async fn webrtc_v2_text_input_is_append_only_when_response_is_cancelled() -> Res ) .await?; - let started = harness.start_webrtc_realtime("v=offer\r\n").await?; - assert_eq!(started.started.version, RealtimeConversationVersion::V2); + let started = harness.start_websocket_realtime().await?; + assert_eq!(started.version, RealtimeConversationVersion::V2); assert_v2_session_update(&harness.sideband_outbound_request(/*request_index*/ 0).await)?; // Phase 2: send the first text turn. Text input is append-only, so this // sends only the user text item. - let thread_id = started.started.thread_id.clone(); + let thread_id = started.thread_id.clone(); harness.append_text(thread_id.clone(), "first").await?; assert_v2_user_text_item( &harness.sideband_outbound_request(/*request_index*/ 1).await, @@ -2218,8 +2267,7 @@ async fn webrtc_v2_text_input_is_append_only_when_response_is_cancelled() -> Res /// output to realtime and then requests a new `response.create` so realtime can /// react to that final output. #[tokio::test] -async fn webrtc_v2_background_agent_tool_call_delegates_and_returns_function_output() -> Result<()> -{ +async fn websocket_v2_background_agent_returns_function_output() -> Result<()> { skip_if_no_network!(Ok(())); // Phase 1: script a v2 background agent function call and a delegated Responses turn that @@ -2268,8 +2316,8 @@ async fn webrtc_v2_background_agent_tool_call_delegates_and_returns_function_out ) .await?; - let started = harness.start_webrtc_realtime("v=offer\r\n").await?; - assert_eq!(started.started.version, RealtimeConversationVersion::V2); + let started = harness.start_websocket_realtime().await?; + assert_eq!(started.version, RealtimeConversationVersion::V2); // Phase 2: wait for the delegated turn lifecycle kicked off by the v2 function-call item. let turn_started = harness @@ -2316,7 +2364,7 @@ async fn webrtc_v2_background_agent_tool_call_delegates_and_returns_function_out /// task. App-server acknowledges that steering message to realtime and then /// emits `response.create` so realtime can speak that acknowledgement. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn webrtc_v2_background_agent_steering_ack_requests_response_create() -> Result<()> { +async fn websocket_v2_background_agent_steering_ack_requests_response_create() -> Result<()> { skip_if_no_network!(Ok(())); // Phase 1: gate the delegated Responses turn from the first tool call so @@ -2356,8 +2404,8 @@ async fn webrtc_v2_background_agent_steering_ack_requests_response_create() -> R ) .await?; - let started = harness.start_webrtc_realtime("v=offer\r\n").await?; - assert_eq!(started.started.version, RealtimeConversationVersion::V2); + let started = harness.start_websocket_realtime().await?; + assert_eq!(started.version, RealtimeConversationVersion::V2); assert_v2_session_update(&harness.sideband_outbound_request(/*request_index*/ 0).await)?; let turn_started = harness .read_notification::("turn/started") @@ -2399,7 +2447,7 @@ async fn webrtc_v2_background_agent_steering_ack_requests_response_create() -> R } #[tokio::test] -async fn webrtc_v2_background_agent_progress_is_sent_before_function_output() -> Result<()> { +async fn websocket_v2_background_agent_progress_is_sent_before_function_output() -> Result<()> { skip_if_no_network!(Ok(())); let mut harness = RealtimeE2eHarness::new( @@ -2418,8 +2466,8 @@ async fn webrtc_v2_background_agent_progress_is_sent_before_function_output() -> ) .await?; - let started = harness.start_webrtc_realtime("v=offer\r\n").await?; - assert_eq!(started.started.version, RealtimeConversationVersion::V2); + let started = harness.start_websocket_realtime().await?; + assert_eq!(started.version, RealtimeConversationVersion::V2); let turn_completed = harness .read_notification::("turn/completed") @@ -2441,7 +2489,7 @@ async fn webrtc_v2_background_agent_progress_is_sent_before_function_output() -> } #[tokio::test] -async fn webrtc_v2_tool_call_delegated_turn_can_execute_shell_tool() -> Result<()> { +async fn websocket_v2_tool_call_delegated_turn_can_execute_shell_tool() -> Result<()> { skip_if_no_network!(Ok(())); // Phase 1: keep the two mocked OpenAI conversations explicit. The realtime sideband only @@ -2475,7 +2523,7 @@ async fn webrtc_v2_tool_call_delegated_turn_can_execute_shell_tool() -> Result<( ) .await?; - let _ = harness.start_webrtc_realtime("v=offer\r\n").await?; + let _ = harness.start_websocket_realtime().await?; // Phase 2: observe the delegated background agent turn executing the requested shell command. let started_command = wait_for_started_command_execution(&mut harness.mcp).await?; @@ -2503,9 +2551,12 @@ async fn webrtc_v2_tool_call_delegated_turn_can_execute_shell_tool() -> Result<( // Phase 3: verify the shell output reached Responses and the final delegated answer returned // to realtime as a single function-call-output item. - let turn_completed = harness - .read_notification::("turn/completed") - .await?; + let turn_completed = read_notification_with_timeout::( + &mut harness.mcp, + "turn/completed", + DELEGATED_SHELL_TURN_TIMEOUT, + ) + .await?; assert_eq!(turn_completed.thread_id, harness.thread_id); let requests = harness.main_loop_responses_requests().await?; @@ -2531,7 +2582,7 @@ async fn webrtc_v2_tool_call_delegated_turn_can_execute_shell_tool() -> Result<( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn webrtc_v2_tool_call_does_not_block_sideband_audio() -> Result<()> { +async fn websocket_v2_tool_call_does_not_block_sideband_audio() -> Result<()> { skip_if_no_network!(Ok(())); // Phase 1: gate the delegated Responses stream so the sideband can send audio while the tool @@ -2574,7 +2625,7 @@ async fn webrtc_v2_tool_call_does_not_block_sideband_audio() -> Result<()> { ) .await?; - let _ = harness.start_webrtc_realtime("v=offer\r\n").await?; + let _ = harness.start_websocket_realtime().await?; let _ = harness .read_notification::("turn/started") .await?; @@ -2649,7 +2700,6 @@ async fn realtime_webrtc_start_surfaces_backend_error() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { - architecture: None, client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, @@ -2716,7 +2766,6 @@ async fn realtime_conversation_requires_feature_flag() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { - architecture: None, client_managed_handoffs: None, codex_responses_as_items: None, codex_response_item_prefix: None, @@ -2752,9 +2801,17 @@ async fn realtime_conversation_requires_feature_flag() -> Result<()> { async fn read_notification( mcp: &mut TestAppServer, method: &str, +) -> Result { + read_notification_with_timeout(mcp, method, DEFAULT_TIMEOUT).await +} + +async fn read_notification_with_timeout( + mcp: &mut TestAppServer, + method: &str, + timeout_duration: Duration, ) -> Result { let notification = timeout( - DEFAULT_TIMEOUT, + timeout_duration, mcp.read_stream_until_notification_message(method), ) .await??; @@ -2970,7 +3027,10 @@ fn assert_call_create_multipart( session: &str, ) -> Result<()> { assert_eq!(request.url.path(), "/v1/realtime/calls"); - assert_eq!(request.url.query(), None); + assert_eq!( + request.url.query(), + Some("intent=quicksilver&architecture=avas") + ); assert_eq!( request .headers diff --git a/codex-rs/codex-api/src/endpoint/realtime_call.rs b/codex-rs/codex-api/src/endpoint/realtime_call.rs index e2ade0266c..af70609136 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_call.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_call.rs @@ -1,4 +1,5 @@ use crate::auth::SharedAuthProvider; +use crate::endpoint::realtime_websocket::RealtimeEventParser; use crate::endpoint::realtime_websocket::RealtimeSessionConfig; use crate::endpoint::realtime_websocket::session_update_session_json; use crate::endpoint::session::EndpointSession; @@ -9,7 +10,6 @@ use codex_client::HttpTransport; use codex_client::Request; use codex_client::RequestBody; use codex_client::RequestTelemetry; -use codex_protocol::protocol::RealtimeConversationArchitecture; use http::HeaderMap; use http::HeaderValue; use http::Method; @@ -120,27 +120,12 @@ impl RealtimeCallClient { sdp: String, session_config: RealtimeSessionConfig, extra_headers: HeaderMap, - ) -> Result { - self.create_with_session_architecture_and_headers( - sdp, - session_config, - RealtimeConversationArchitecture::RealtimeApi, - extra_headers, - ) - .await - } - - pub async fn create_with_session_architecture_and_headers( - &self, - sdp: String, - session_config: RealtimeSessionConfig, - architecture: RealtimeConversationArchitecture, - extra_headers: HeaderMap, ) -> Result { trace!(target: "codex_api::realtime_websocket::wire", "realtime call request SDP: {sdp}"); // WebRTC can begin inference as soon as the peer connection comes up, so the initial // session payload is sent with call creation. The sideband WebSocket still sends its normal // session.update after it joins. + validate_avas_session_config(&session_config)?; let mut session = realtime_session_json(session_config)?; if let Some(session) = session.as_object_mut() { session.remove("id"); @@ -159,7 +144,7 @@ impl RealtimeCallClient { Self::path(), extra_headers, Some(body), - |req| configure_realtime_call_request(req, architecture), + configure_realtime_call_request, ) .await?; let sdp = decode_sdp_response(resp.body.as_ref())?; @@ -191,7 +176,7 @@ impl RealtimeCallClient { extra_headers, /*body*/ None, |req| { - configure_realtime_call_request(req, architecture); + configure_realtime_call_request(req); req.headers.insert( CONTENT_TYPE, HeaderValue::from_static(MULTIPART_CONTENT_TYPE), @@ -208,17 +193,18 @@ impl RealtimeCallClient { } } -fn configure_realtime_call_request( - request: &mut Request, - architecture: RealtimeConversationArchitecture, -) { - match architecture { - RealtimeConversationArchitecture::RealtimeApi => {} - RealtimeConversationArchitecture::Avas => { - append_query_pair(&mut request.url, "intent", "quicksilver"); - append_query_pair(&mut request.url, "architecture", "avas"); - } +fn configure_realtime_call_request(request: &mut Request) { + append_query_pair(&mut request.url, "intent", "quicksilver"); + append_query_pair(&mut request.url, "architecture", "avas"); +} + +fn validate_avas_session_config(session_config: &RealtimeSessionConfig) -> Result<(), ApiError> { + if session_config.event_parser != RealtimeEventParser::V1 { + return Err(ApiError::InvalidRequest { + message: "AVAS realtime calls require realtime v1".to_string(), + }); } + Ok(()) } fn append_query_pair(url: &mut String, key: &str, value: &str) { @@ -377,10 +363,18 @@ mod tests { instructions: "hi".to_string(), model: Some("gpt-realtime".to_string()), session_id: Some(session_id.to_string()), - event_parser: RealtimeEventParser::RealtimeV2, + event_parser: RealtimeEventParser::V1, session_mode: RealtimeSessionMode::Conversational, output_modality: RealtimeOutputModality::Audio, + voice: RealtimeVoice::Cove, + } + } + + fn realtime_v2_session_config(session_id: &str) -> RealtimeSessionConfig { + RealtimeSessionConfig { + event_parser: RealtimeEventParser::RealtimeV2, voice: RealtimeVoice::Marin, + ..realtime_session_config(session_id) } } @@ -488,7 +482,10 @@ mod tests { let request = transport.last_request.lock().unwrap().clone().unwrap(); assert_eq!(request.method, Method::POST); - assert_eq!(request.url, "https://api.openai.com/v1/realtime/calls"); + assert_eq!( + request.url, + "https://api.openai.com/v1/realtime/calls?intent=quicksilver&architecture=avas" + ); assert_eq!( request.headers.get(CONTENT_TYPE).unwrap(), HeaderValue::from_static(MULTIPART_CONTENT_TYPE) @@ -524,7 +521,7 @@ mod tests { } #[tokio::test] - async fn sends_avas_session_call_query_params() { + async fn sends_session_call_with_avas_query_params() { let transport = CapturingTransport::new(); let client = RealtimeCallClient::new( transport.clone(), @@ -533,10 +530,9 @@ mod tests { ); let response = client - .create_with_session_architecture_and_headers( + .create_with_session_and_headers( "v=offer\r\n".to_string(), realtime_session_config("sess-api"), - RealtimeConversationArchitecture::Avas, HeaderMap::new(), ) .await @@ -558,6 +554,30 @@ mod tests { ); } + #[tokio::test] + async fn rejects_v2_session_call_before_sending_request() { + let transport = CapturingTransport::new(); + let client = RealtimeCallClient::new( + transport.clone(), + provider("https://api.openai.com/v1"), + Arc::new(DummyAuth), + ); + + let err = client + .create_with_session( + "v=offer\r\n".to_string(), + realtime_v2_session_config("sess-api"), + ) + .await + .expect_err("v2 session config should be rejected"); + + assert_eq!( + err.to_string(), + "invalid request: AVAS realtime calls require realtime v1" + ); + assert!(transport.last_request.lock().unwrap().is_none()); + } + #[tokio::test] async fn sends_backend_session_call_as_json_body() { let transport = CapturingTransport::new(); @@ -587,7 +607,7 @@ mod tests { assert_eq!(request.method, Method::POST); assert_eq!( request.url, - "https://chatgpt.com/backend-api/codex/realtime/calls" + "https://chatgpt.com/backend-api/codex/realtime/calls?intent=quicksilver&architecture=avas" ); let mut expected_session = realtime_session_json(realtime_session_config("sess-backend")) .expect("session should encode"); diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 7270c3f329..f575aa27d8 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -587,14 +587,12 @@ pub enum RealtimeTransport { Websocket, } -pub use codex_protocol::protocol::RealtimeConversationArchitecture as RealtimeArchitecture; pub use codex_protocol::protocol::RealtimeConversationVersion as RealtimeWsVersion; pub use codex_protocol::protocol::RealtimeVoice; #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct RealtimeConfig { - pub architecture: RealtimeArchitecture, pub version: RealtimeWsVersion, #[serde(rename = "type")] pub session_type: RealtimeWsMode, @@ -605,7 +603,6 @@ pub struct RealtimeConfig { #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct RealtimeToml { - pub architecture: Option, pub version: Option, #[serde(rename = "type")] pub session_type: Option, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 21c7975261..f80ec3b563 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2524,13 +2524,6 @@ }, "type": "object" }, - "RealtimeConversationArchitecture": { - "enum": [ - "realtimeapi", - "avas" - ], - "type": "string" - }, "RealtimeConversationVersion": { "enum": [ "v1", @@ -2541,9 +2534,6 @@ "RealtimeToml": { "additionalProperties": false, "properties": { - "architecture": { - "$ref": "#/definitions/RealtimeConversationArchitecture" - }, "transport": { "$ref": "#/definitions/RealtimeTransport" }, diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 4ea8c0ff1f..773a15deba 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -77,7 +77,6 @@ use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::InternalSessionSource; -use codex_protocol::protocol::RealtimeConversationArchitecture; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::W3cTraceContext; use codex_rollout_trace::CompactionTraceContext; @@ -583,7 +582,6 @@ impl ModelClient { &self, sdp: String, session_config: ApiRealtimeSessionConfig, - architecture: RealtimeConversationArchitecture, mut extra_headers: ApiHeaderMap, api_provider_override: Option, ) -> Result { @@ -600,12 +598,7 @@ impl ModelClient { let transport = ReqwestTransport::new(build_reqwest_client()); let api_provider = api_provider_override.unwrap_or(client_setup.api_provider); let response = ApiRealtimeCallClient::new(transport, api_provider, client_setup.api_auth) - .create_with_session_architecture_and_headers( - sdp, - session_config, - architecture, - extra_headers, - ) + .create_with_session_and_headers(sdp, session_config, extra_headers) .await .map_err(map_api_error)?; Ok(RealtimeWebrtcCallStart { diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index b13c9575f1..53a31433a0 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -14,7 +14,6 @@ use codex_config::config_toml::AutoReviewToml; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ExperimentalRequestUserInput; use codex_config::config_toml::ProjectConfig; -use codex_config::config_toml::RealtimeArchitecture; use codex_config::config_toml::RealtimeConfig; use codex_config::config_toml::RealtimeToml; use codex_config::config_toml::RealtimeTransport; @@ -10896,7 +10895,6 @@ async fn realtime_loads_from_config_toml() -> std::io::Result<()> { let cfg: ConfigToml = toml::from_str( r#" [realtime] -architecture = "avas" version = "v2" type = "transcription" transport = "webrtc" @@ -10908,7 +10906,6 @@ voice = "cedar" assert_eq!( cfg.realtime, Some(RealtimeToml { - architecture: Some(RealtimeArchitecture::Avas), version: Some(RealtimeWsVersion::V2), session_type: Some(RealtimeWsMode::Transcription), transport: Some(RealtimeTransport::WebRtc), @@ -10927,7 +10924,6 @@ voice = "cedar" assert_eq!( config.realtime, RealtimeConfig { - architecture: RealtimeArchitecture::Avas, version: RealtimeWsVersion::V2, session_type: RealtimeWsMode::Transcription, transport: RealtimeTransport::WebRtc, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index a1854067f5..1089ba648f 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -3792,7 +3792,6 @@ impl Config { .map_or_else(RealtimeConfig::default, |realtime| { let defaults = RealtimeConfig::default(); RealtimeConfig { - architecture: realtime.architecture.unwrap_or(defaults.architecture), version: realtime.version.unwrap_or(defaults.version), session_type: realtime.session_type.unwrap_or(defaults.session_type), transport: realtime.transport.unwrap_or(defaults.transport), diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index b684228466..667939f105 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -41,7 +41,6 @@ use codex_protocol::protocol::ConversationTextRole; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::RealtimeConversationArchitecture; use codex_protocol::protocol::RealtimeConversationClosedEvent; use codex_protocol::protocol::RealtimeConversationRealtimeEvent; use codex_protocol::protocol::RealtimeConversationSdpEvent; @@ -246,7 +245,6 @@ struct ConversationState { struct RealtimeStart { api_provider: ApiProvider, - architecture: RealtimeConversationArchitecture, extra_headers: Option, client_managed_handoffs: bool, codex_responses_as_items: bool, @@ -304,7 +302,6 @@ impl RealtimeConversationManager { async fn start_inner(&self, start: RealtimeStart) -> CodexResult { let RealtimeStart { api_provider, - architecture, extra_headers, client_managed_handoffs, codex_responses_as_items, @@ -351,7 +348,6 @@ impl RealtimeConversationManager { .create_realtime_call_with_headers( sdp, session_config.clone(), - architecture, extra_headers.unwrap_or_default(), realtime_call_api_provider, ) @@ -717,7 +713,6 @@ pub(crate) async fn handle_start( struct PreparedRealtimeConversationStart { api_provider: ApiProvider, - architecture: RealtimeConversationArchitecture, extra_headers: Option, client_managed_handoffs: bool, codex_responses_as_items: bool, @@ -730,6 +725,12 @@ struct PreparedRealtimeConversationStart { transport: ConversationStartTransport, } +#[derive(Clone, Copy)] +pub(crate) enum ConfiguredRealtimeVoice { + Use, + Ignore, +} + async fn prepare_realtime_start( sess: &Arc, params: ConversationStartParams, @@ -758,16 +759,21 @@ async fn prepare_realtime_start( } else { None }; - let version = params.version.unwrap_or(config.realtime.version); - // TODO(pbakkum): Remove the realtimeapi/AVAS branch once WebRTC realtime sessions always use AVAS. - let architecture = params.architecture.unwrap_or(config.realtime.architecture); - validate_realtime_architecture( - architecture, - version, - &transport, - config.realtime.session_type, - )?; - let session_config = build_realtime_session_config(sess, ¶ms, version).await?; + let version = params.version.unwrap_or(match &transport { + ConversationStartTransport::Websocket => config.realtime.version, + ConversationStartTransport::Webrtc { .. } => RealtimeWsVersion::V1, + }); + if matches!(transport, ConversationStartTransport::Webrtc { .. }) { + validate_avas_webrtc_start(version, config.realtime.session_type)?; + } + let configured_voice = match (&transport, params.version) { + (ConversationStartTransport::Webrtc { .. }, None) => ConfiguredRealtimeVoice::Ignore, + (ConversationStartTransport::Webrtc { .. } | ConversationStartTransport::Websocket, _) => { + ConfiguredRealtimeVoice::Use + } + }; + let session_config = + build_realtime_session_config(sess, ¶ms, version, configured_voice).await?; let requested_realtime_session_id = session_config.session_id.clone(); let extra_headers = match transport { ConversationStartTransport::Websocket => { @@ -788,7 +794,6 @@ async fn prepare_realtime_start( }; Ok(PreparedRealtimeConversationStart { api_provider, - architecture, extra_headers, client_managed_handoffs: params.client_managed_handoffs, codex_responses_as_items: params.codex_responses_as_items, @@ -802,28 +807,18 @@ async fn prepare_realtime_start( }) } -fn validate_realtime_architecture( - architecture: RealtimeConversationArchitecture, +fn validate_avas_webrtc_start( version: RealtimeWsVersion, - transport: &ConversationStartTransport, session_type: RealtimeWsMode, ) -> CodexResult<()> { - if architecture != RealtimeConversationArchitecture::Avas { - return Ok(()); - } if version != RealtimeWsVersion::V1 { return Err(CodexErr::InvalidRequest( - "AVAS realtime architecture requires realtime v1".to_string(), - )); - } - if !matches!(transport, ConversationStartTransport::Webrtc { .. }) { - return Err(CodexErr::InvalidRequest( - "AVAS realtime architecture requires WebRTC transport".to_string(), + "AVAS realtime calls require realtime v1".to_string(), )); } if session_type != RealtimeWsMode::Conversational { return Err(CodexErr::InvalidRequest( - "AVAS realtime architecture requires conversational realtime".to_string(), + "AVAS realtime calls require conversational realtime".to_string(), )); } Ok(()) @@ -833,6 +828,7 @@ pub(crate) async fn build_realtime_session_config( sess: &Arc, params: &ConversationStartParams, version: RealtimeWsVersion, + configured_voice: ConfiguredRealtimeVoice, ) -> CodexResult { let config = sess.get_config().await; let prompt = prepare_realtime_backend_prompt( @@ -879,9 +875,13 @@ pub(crate) async fn build_realtime_session_config( RealtimeWsMode::Conversational => RealtimeSessionMode::Conversational, RealtimeWsMode::Transcription => RealtimeSessionMode::Transcription, }; + let config_voice = match configured_voice { + ConfiguredRealtimeVoice::Use => config.realtime.voice, + ConfiguredRealtimeVoice::Ignore => None, + }; let voice = params .voice - .or(config.realtime.voice) + .or(config_voice) .unwrap_or_else(|| default_realtime_voice(version)); validate_realtime_voice(version, voice)?; Ok(RealtimeSessionConfig { @@ -960,7 +960,6 @@ async fn handle_start_inner( ) -> CodexResult<()> { let PreparedRealtimeConversationStart { api_provider, - architecture, extra_headers, client_managed_handoffs, codex_responses_as_items, @@ -979,7 +978,6 @@ async fn handle_start_inner( }; let start = RealtimeStart { api_provider, - architecture, extra_headers, client_managed_handoffs, codex_responses_as_items, diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 40c01fa1bc..77561093c6 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -203,7 +203,6 @@ async fn start_remote_realtime_server() -> responses::WebSocketTestServer { async fn start_realtime_conversation(codex: &codex_core::CodexThread) -> Result<()> { codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 0788709952..dd97e5a5d3 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -18,7 +18,6 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::Op; use codex_protocol::protocol::RealtimeAudioFrame; -use codex_protocol::protocol::RealtimeConversationArchitecture; use codex_protocol::protocol::RealtimeConversationRealtimeEvent; use codex_protocol::protocol::RealtimeConversationVersion; use codex_protocol::protocol::RealtimeEvent; @@ -285,7 +284,6 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -432,7 +430,6 @@ async fn conversation_start_defaults_to_v2_and_gpt_realtime_1_5() -> Result<()> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -528,7 +525,6 @@ async fn conversation_webrtc_start_posts_generated_session() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -585,7 +581,10 @@ async fn conversation_webrtc_start_posts_generated_session() -> Result<()> { // begin inference before the sideband WebSocket is ready. let request = capture.single_request(); assert_eq!(request.url.path(), "/v1/realtime/calls"); - assert_eq!(request.url.query(), None); + assert_eq!( + request.url.query(), + Some("intent=quicksilver&architecture=avas") + ); assert_eq!( request .headers @@ -674,7 +673,7 @@ async fn conversation_webrtc_start_posts_generated_session() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn conversation_webrtc_start_uses_avas_architecture_query() -> Result<()> { +async fn conversation_webrtc_start_uses_avas_query() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -713,7 +712,6 @@ async fn conversation_webrtc_start_uses_avas_architecture_query() -> Result<()> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: Some(RealtimeConversationArchitecture::Avas), client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -774,6 +772,132 @@ async fn conversation_webrtc_start_uses_avas_architecture_query() -> Result<()> Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn conversation_webrtc_default_v1_ignores_configured_v2_voice() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let capture = RealtimeCallRequestCapture::new(); + Mock::given(method("POST")) + .and(path_regex(".*/realtime/calls$")) + .and(capture.clone()) + .respond_with( + ResponseTemplate::new(200) + .insert_header("Location", "/v1/realtime/calls/calls/rtc_voice_default") + .set_body_string("v=answer\r\n"), + ) + .mount(&server) + .await; + let realtime_server = start_websocket_server_with_headers(vec![WebSocketConnectionConfig { + requests: vec![vec![json!({ + "type": "session.updated", + "session": { "id": "sess_webrtc_voice", "instructions": "backend prompt" } + })]], + response_headers: Vec::new(), + accept_delay: None, + close_after_requests: false, + }]) + .await; + + let realtime_ws_base_url = realtime_server.uri().to_string(); + let mut builder = test_codex().with_config(move |config| { + config.experimental_realtime_ws_backend_prompt = Some("backend prompt".to_string()); + config.experimental_realtime_ws_base_url = Some(realtime_ws_base_url); + config.realtime.version = RealtimeWsVersion::V2; + config.realtime.voice = Some(RealtimeVoice::Cedar); + }); + let test = builder.build(&server).await?; + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + client_managed_handoffs: false, + codex_responses_as_items: false, + codex_response_item_prefix: None, + codex_response_handoff_prefix: None, + model: None, + output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, + prompt: Some(Some("backend prompt".to_string())), + realtime_session_id: None, + transport: Some(ConversationStartTransport::Webrtc { + sdp: "v=offer\r\n".to_string(), + }), + version: None, + voice: None, + })) + .await?; + + let created = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationSdp(created) => Some(Ok(created.clone())), + EventMsg::Error(err) => Some(Err(err.clone())), + _ => None, + }) + .await + .expect("conversation call create failed"); + assert_eq!(created.sdp, "v=answer\r\n"); + + let request = capture.single_request(); + let body = String::from_utf8(request.body).context("multipart body should be utf-8")?; + assert!(body.contains(r#""type":"quicksilver""#)); + assert!(body.contains(r#""voice":"cove""#)); + assert!(!body.contains(r#""voice":"cedar""#)); + + let session_update = wait_for_websocket_request( + &realtime_server, + /*connection_index*/ 0, + /*request_index*/ 0, + ) + .await?; + assert_eq!( + session_update.body_json()["session"]["audio"]["output"]["voice"], + "cove" + ); + + test.codex.submit(Op::RealtimeConversationClose).await?; + realtime_server.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn conversation_webrtc_default_v1_rejects_explicit_v2_voice() -> Result<()> { + skip_if_no_network!(Ok(())); + + let api_server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config.realtime.version = RealtimeWsVersion::V2; + }); + let test = builder.build(&api_server).await?; + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + client_managed_handoffs: false, + codex_responses_as_items: false, + codex_response_item_prefix: None, + codex_response_handoff_prefix: None, + model: None, + output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, + prompt: Some(Some("backend prompt".to_string())), + realtime_session_id: None, + transport: Some(ConversationStartTransport::Webrtc { + sdp: "v=offer\r\n".to_string(), + }), + version: None, + voice: Some(RealtimeVoice::Cedar), + })) + .await?; + + let error = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::Error(message), + }) => Some(message.clone()), + _ => None, + }) + .await; + assert!(error.contains("realtime voice `cedar` is not supported for v1")); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn conversation_webrtc_start_uses_configured_call_base_url_for_avas() -> Result<()> { skip_if_no_network!(Ok(())); @@ -816,7 +940,6 @@ async fn conversation_webrtc_start_uses_configured_call_base_url_for_avas() -> R test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: Some(RealtimeConversationArchitecture::Avas), client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -911,7 +1034,6 @@ async fn conversation_webrtc_close_while_sideband_connecting_drops_pending_join( test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -1003,7 +1125,6 @@ async fn conversation_webrtc_sideband_connect_failure_closes_with_error() -> Res test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -1097,7 +1218,6 @@ async fn conversation_start_uses_openai_env_key_fallback_with_chatgpt_auth() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -1171,7 +1291,6 @@ async fn conversation_transport_close_emits_closed_event() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -1269,7 +1388,6 @@ async fn conversation_start_preflight_failure_emits_realtime_error_only() -> Res test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -1321,7 +1439,6 @@ async fn conversation_start_connect_failure_emits_realtime_error_only() -> Resul test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -1421,7 +1538,6 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -1452,7 +1568,6 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -1554,7 +1669,6 @@ async fn conversation_uses_experimental_realtime_ws_base_url_override() -> Resul test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -1624,7 +1738,6 @@ async fn conversation_uses_default_realtime_backend_prompt() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -1702,7 +1815,6 @@ async fn conversation_uses_empty_instructions_for_null_or_empty_prompt() -> Resu ] { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -1773,7 +1885,6 @@ async fn conversation_uses_explicit_start_voice() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -1836,7 +1947,6 @@ async fn conversation_uses_configured_realtime_voice() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -1887,7 +1997,6 @@ async fn conversation_rejects_voice_for_wrong_realtime_version() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -1939,7 +2048,6 @@ async fn conversation_uses_experimental_realtime_ws_backend_prompt_override() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -2017,7 +2125,6 @@ async fn conversation_uses_experimental_realtime_ws_startup_context_override() - test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -2089,7 +2196,6 @@ async fn conversation_disables_realtime_startup_context_with_empty_override() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -2154,7 +2260,6 @@ async fn conversation_start_injects_startup_context_from_thread_history() -> Res test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -2274,7 +2379,6 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -2387,7 +2491,6 @@ async fn conversation_startup_context_falls_back_to_workspace_map() -> Result<() test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -2452,7 +2555,6 @@ async fn conversation_startup_context_is_truncated_and_sent_once_per_start() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -2538,7 +2640,6 @@ async fn conversation_user_text_turn_is_not_sent_to_realtime() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -2640,7 +2741,6 @@ async fn realtime_v2_noop_tool_call_returns_empty_function_output_without_respon test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -2744,7 +2844,6 @@ async fn conversation_mirrors_assistant_message_text_to_realtime_handoff() -> Re test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -2884,7 +2983,6 @@ async fn conversation_handoff_persists_across_item_done_until_turn_complete() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -3041,7 +3139,6 @@ async fn inbound_handoff_request_starts_turn() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -3156,7 +3253,6 @@ async fn inbound_handoff_request_uses_active_transcript() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -3264,7 +3360,6 @@ async fn inbound_handoff_request_sends_transcript_delta_after_each_handoff() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -3370,7 +3465,6 @@ async fn inbound_conversation_item_does_not_start_turn_and_still_forwards_audio( test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -3498,7 +3592,6 @@ async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_au test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -3656,7 +3749,6 @@ async fn inbound_handoff_request_does_not_block_realtime_event_forwarding() -> R test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -3803,7 +3895,6 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, @@ -3961,7 +4052,6 @@ async fn inbound_handoff_request_starts_turn_and_does_not_block_realtime_audio() test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { - architecture: None, client_managed_handoffs: false, codex_responses_as_items: false, codex_response_item_prefix: None, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 285114d8b1..99aa0a0b69 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -180,8 +180,6 @@ pub struct McpServerRefreshConfig { #[derive(Debug, Clone, PartialEq)] pub struct ConversationStartParams { - /// Overrides the configured realtime architecture for this session only. - pub architecture: Option, /// Whether Codex response handoffs are managed through explicit client append calls. pub client_managed_handoffs: bool, /// Sends automatic Codex responses as realtime conversation items instead of handoff appends. @@ -1549,15 +1547,6 @@ pub enum RealtimeConversationVersion { V2, } -#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -pub enum RealtimeConversationArchitecture { - #[default] - #[serde(rename = "realtimeapi")] - RealtimeApi, - Avas, -} - #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct RealtimeConversationStartedEvent { pub realtime_session_id: Option, From f00f93d8c0134c49e379af5a946f713f20649c3e Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 18 Jun 2026 17:30:55 -0700 Subject: [PATCH 061/112] [codex] Assign response item IDs when recording history (#28814) ## Why Client-created response items enter history without IDs, so their identity is lost across rollout persistence and resume. IDs should be assigned once at the history-recording boundary, while IDs returned by the server must remain unchanged. The Responses API validates item IDs using type-specific prefixes. Locally generated IDs therefore use the matching prefix plus a hyphenated UUIDv7, keeping them valid while distinguishable from server-generated IDs. Because this changes persisted history and provider request shapes, the behavior is opt-in behind the under-development `item_ids` feature. Compaction triggers remain request controls whose API shape does not accept an ID. ## What changed - Register the disabled-by-default `item_ids` feature and expose it in `config.schema.json`. - Make supported optional `ResponseItem` IDs serializable and expose them in the generated app-server schemas. - When `item_ids` is enabled, assign an ID during conversation-history preparation if an item has no ID. - Generate type-prefixed, hyphenated UUIDv7 IDs using the Responses API item conventions. - Preserve existing server IDs without rewriting them. - Persist assigned IDs in rollouts and include them in subsequent Responses requests. - Remove the unsupported ID field from `CompactionTrigger` and document why it has no ID. - Add integration coverage for enabled ID persistence, preservation of server IDs, and omission of generated IDs while the feature is disabled. `prepare_conversation_items_for_history` is the single response-item ID allocation boundary. ## Test plan - `just test -p codex-features` - `just test -p codex-core response_item_ids_persist_across_resume_and_preserve_server_ids` - `just test -p codex-core non_openai_responses_requests_omit_item_turn_metadata` - `just test -p codex-core resize_all_images_prepares_failures_before_history_insertion` - `just test -p codex-protocol` - `just test -p codex-app-server-protocol` - `just test -p codex-api azure_default_store_attaches_ids_and_headers` --- .../schema/json/ClientRequest.json | 80 +++++++++++- .../codex_app_server_protocol.schemas.json | 80 +++++++++++- .../codex_app_server_protocol.v2.schemas.json | 80 +++++++++++- .../RawResponseItemCompletedNotification.json | 80 +++++++++++- .../schema/json/v2/ThreadResumeParams.json | 80 +++++++++++- .../schema/typescript/ResponseItem.ts | 12 +- codex-rs/codex-api/src/endpoint/compact.rs | 3 +- codex-rs/codex-api/src/endpoint/responses.rs | 13 +- codex-rs/codex-api/src/endpoint/search.rs | 3 +- codex-rs/codex-api/src/requests/mod.rs | 1 - codex-rs/codex-api/src/requests/responses.rs | 31 ----- codex-rs/codex-api/tests/clients.rs | 11 +- codex-rs/core/config.schema.json | 6 + codex-rs/core/src/client.rs | 25 +++- codex-rs/core/src/client_tests.rs | 2 + codex-rs/core/src/compact_remote_v2.rs | 5 +- codex-rs/core/src/session/mod.rs | 40 +++++- codex-rs/core/src/session/session.rs | 1 + codex-rs/core/src/session/tests.rs | 43 ++++++- codex-rs/core/tests/responses_headers.rs | 3 + codex-rs/core/tests/suite/client.rs | 83 ++++++++++++ .../core/tests/suite/client_websockets.rs | 5 +- codex-rs/core/tests/suite/model_switching.rs | 8 +- codex-rs/ext/web-search/src/history.rs | 16 ++- codex-rs/features/src/lib.rs | 8 ++ codex-rs/memories/write/src/runtime.rs | 1 + codex-rs/protocol/src/models.rs | 120 +++++++----------- codex-rs/rollout-trace/src/inference.rs | 1 + 28 files changed, 677 insertions(+), 164 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 275d0fc13e..dc83fc5994 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2287,6 +2287,12 @@ }, "type": "array" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -2337,6 +2343,12 @@ }, "type": "array" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -2385,6 +2397,12 @@ "null" ] }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -2428,6 +2446,13 @@ "null" ] }, + "id": { + "description": "Legacy id field retained for compatibility with older payloads.", + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -2465,6 +2490,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -2513,6 +2544,12 @@ "execution": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -2550,6 +2587,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -2584,6 +2627,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "input": { "type": "string" }, @@ -2628,6 +2677,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -2674,6 +2729,12 @@ "execution": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -2720,6 +2781,12 @@ } ] }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -2753,7 +2820,6 @@ { "properties": { "id": { - "description": "Existing provider ID retained on serialized history for compatibility.", "type": [ "string", "null" @@ -2802,6 +2868,12 @@ "encrypted_content": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -2861,6 +2933,12 @@ "null" ] }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index cb9c7a6225..d88c2c9d4f 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -14833,6 +14833,12 @@ }, "type": "array" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -14883,6 +14889,12 @@ }, "type": "array" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -14931,6 +14943,12 @@ "null" ] }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -14974,6 +14992,13 @@ "null" ] }, + "id": { + "description": "Legacy id field retained for compatibility with older payloads.", + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -15011,6 +15036,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -15059,6 +15090,12 @@ "execution": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -15096,6 +15133,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -15130,6 +15173,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "input": { "type": "string" }, @@ -15174,6 +15223,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -15220,6 +15275,12 @@ "execution": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -15266,6 +15327,12 @@ } ] }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -15299,7 +15366,6 @@ { "properties": { "id": { - "description": "Existing provider ID retained on serialized history for compatibility.", "type": [ "string", "null" @@ -15348,6 +15414,12 @@ "encrypted_content": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -15407,6 +15479,12 @@ "null" ] }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index df16d65884..3c6a774424 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -11257,6 +11257,12 @@ }, "type": "array" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -11307,6 +11313,12 @@ }, "type": "array" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -11355,6 +11367,12 @@ "null" ] }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -11398,6 +11416,13 @@ "null" ] }, + "id": { + "description": "Legacy id field retained for compatibility with older payloads.", + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -11435,6 +11460,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -11483,6 +11514,12 @@ "execution": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -11520,6 +11557,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -11554,6 +11597,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "input": { "type": "string" }, @@ -11598,6 +11647,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -11644,6 +11699,12 @@ "execution": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -11690,6 +11751,12 @@ } ] }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -11723,7 +11790,6 @@ { "properties": { "id": { - "description": "Existing provider ID retained on serialized history for compatibility.", "type": [ "string", "null" @@ -11772,6 +11838,12 @@ "encrypted_content": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -11831,6 +11903,12 @@ "null" ] }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index f77a68dc1d..22385f816f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -377,6 +377,12 @@ }, "type": "array" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -427,6 +433,12 @@ }, "type": "array" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -475,6 +487,12 @@ "null" ] }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -518,6 +536,13 @@ "null" ] }, + "id": { + "description": "Legacy id field retained for compatibility with older payloads.", + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -555,6 +580,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -603,6 +634,12 @@ "execution": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -640,6 +677,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -674,6 +717,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "input": { "type": "string" }, @@ -718,6 +767,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -764,6 +819,12 @@ "execution": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -810,6 +871,12 @@ } ] }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -843,7 +910,6 @@ { "properties": { "id": { - "description": "Existing provider ID retained on serialized history for compatibility.", "type": [ "string", "null" @@ -892,6 +958,12 @@ "encrypted_content": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -951,6 +1023,12 @@ "null" ] }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index e09dd2c6fc..528076c6ae 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -448,6 +448,12 @@ }, "type": "array" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -498,6 +504,12 @@ }, "type": "array" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -546,6 +558,12 @@ "null" ] }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -589,6 +607,13 @@ "null" ] }, + "id": { + "description": "Legacy id field retained for compatibility with older payloads.", + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -626,6 +651,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -674,6 +705,12 @@ "execution": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -711,6 +748,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -745,6 +788,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "input": { "type": "string" }, @@ -789,6 +838,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -835,6 +890,12 @@ "execution": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -881,6 +942,12 @@ } ] }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -914,7 +981,6 @@ { "properties": { "id": { - "description": "Existing provider ID retained on serialized history for compatibility.", "type": [ "string", "null" @@ -963,6 +1029,12 @@ "encrypted_content": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -1022,6 +1094,12 @@ "null" ] }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts index f0cd1c8ee6..fc399b925f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts @@ -12,12 +12,12 @@ import type { ReasoningItemReasoningSummary } from "./ReasoningItemReasoningSumm import type { ResponseItemMetadata } from "./ResponseItemMetadata"; import type { WebSearchAction } from "./WebSearchAction"; -export type ResponseItem = { "type": "message", role: string, content: Array, phase?: MessagePhase, metadata?: ResponseItemMetadata, } | { "type": "agent_message", author: string, recipient: string, content: Array, metadata?: ResponseItemMetadata, } | { "type": "reasoning", summary: Array, content?: Array, encrypted_content: string | null, metadata?: ResponseItemMetadata, } | { "type": "local_shell_call", +export type ResponseItem = { "type": "message", id?: string, role: string, content: Array, phase?: MessagePhase, metadata?: ResponseItemMetadata, } | { "type": "agent_message", id?: string, author: string, recipient: string, content: Array, metadata?: ResponseItemMetadata, } | { "type": "reasoning", id?: string, summary: Array, content?: Array, encrypted_content: string | null, metadata?: ResponseItemMetadata, } | { "type": "local_shell_call", +/** + * Legacy id field retained for compatibility with older payloads. + */ +id?: string, /** * Set when using the Responses API. */ -call_id: string | null, status: LocalShellStatus, action: LocalShellAction, metadata?: ResponseItemMetadata, } | { "type": "function_call", name: string, namespace?: string, arguments: string, call_id: string, metadata?: ResponseItemMetadata, } | { "type": "tool_search_call", call_id: string | null, status?: string, execution: string, arguments: unknown, metadata?: ResponseItemMetadata, } | { "type": "function_call_output", call_id: string, output: FunctionCallOutputBody, metadata?: ResponseItemMetadata, } | { "type": "custom_tool_call", status?: string, call_id: string, name: string, input: string, metadata?: ResponseItemMetadata, } | { "type": "custom_tool_call_output", call_id: string, name?: string, output: FunctionCallOutputBody, metadata?: ResponseItemMetadata, } | { "type": "tool_search_output", call_id: string | null, status: string, execution: string, tools: unknown[], metadata?: ResponseItemMetadata, } | { "type": "web_search_call", status?: string, action?: WebSearchAction, metadata?: ResponseItemMetadata, } | { "type": "image_generation_call", -/** - * Existing provider ID retained on serialized history for compatibility. - */ -id?: string, status: string, revised_prompt?: string, result: string, metadata?: ResponseItemMetadata, } | { "type": "compaction", encrypted_content: string, metadata?: ResponseItemMetadata, } | { "type": "compaction_trigger", metadata?: ResponseItemMetadata, } | { "type": "context_compaction", encrypted_content?: string, metadata?: ResponseItemMetadata, } | { "type": "other" }; +call_id: string | null, status: LocalShellStatus, action: LocalShellAction, metadata?: ResponseItemMetadata, } | { "type": "function_call", id?: string, name: string, namespace?: string, arguments: string, call_id: string, metadata?: ResponseItemMetadata, } | { "type": "tool_search_call", id?: string, call_id: string | null, status?: string, execution: string, arguments: unknown, metadata?: ResponseItemMetadata, } | { "type": "function_call_output", id?: string, call_id: string, output: FunctionCallOutputBody, metadata?: ResponseItemMetadata, } | { "type": "custom_tool_call", id?: string, status?: string, call_id: string, name: string, input: string, metadata?: ResponseItemMetadata, } | { "type": "custom_tool_call_output", id?: string, call_id: string, name?: string, output: FunctionCallOutputBody, metadata?: ResponseItemMetadata, } | { "type": "tool_search_output", id?: string, call_id: string | null, status: string, execution: string, tools: unknown[], metadata?: ResponseItemMetadata, } | { "type": "web_search_call", id?: string, status?: string, action?: WebSearchAction, metadata?: ResponseItemMetadata, } | { "type": "image_generation_call", id?: string, status: string, revised_prompt?: string, result: string, metadata?: ResponseItemMetadata, } | { "type": "compaction", id?: string, encrypted_content: string, metadata?: ResponseItemMetadata, } | { "type": "compaction_trigger", metadata?: ResponseItemMetadata, } | { "type": "context_compaction", id?: string, encrypted_content?: string, metadata?: ResponseItemMetadata, } | { "type": "other" }; diff --git a/codex-rs/codex-api/src/endpoint/compact.rs b/codex-rs/codex-api/src/endpoint/compact.rs index c8730235fd..fd3fd17dbc 100644 --- a/codex-rs/codex-api/src/endpoint/compact.rs +++ b/codex-rs/codex-api/src/endpoint/compact.rs @@ -9,7 +9,6 @@ use codex_protocol::models::ResponseItem; use http::HeaderMap; use http::Method; use serde::Deserialize; -use serde_json::to_value; use std::sync::Arc; use std::sync::OnceLock; use std::time::Duration; @@ -76,7 +75,7 @@ impl CompactClient { request_timeout: Duration, turn_state: Option<&OnceLock>, ) -> Result, ApiError> { - let body = to_value(input) + let body = serde_json::to_value(input) .map_err(|e| ApiError::Stream(format!("failed to encode compaction input: {e}")))?; self.compact(body, extra_headers, request_timeout, turn_state) .await diff --git a/codex-rs/codex-api/src/endpoint/responses.rs b/codex-rs/codex-api/src/endpoint/responses.rs index ad79adf33d..804f0027ff 100644 --- a/codex-rs/codex-api/src/endpoint/responses.rs +++ b/codex-rs/codex-api/src/endpoint/responses.rs @@ -5,7 +5,6 @@ use crate::endpoint::session::EndpointSession; use crate::error::ApiError; use crate::provider::Provider; use crate::requests::Compression; -use crate::requests::attach_item_ids; use crate::requests::headers::build_session_headers; use crate::requests::headers::insert_header; use crate::requests::headers::subagent_header; @@ -82,16 +81,8 @@ impl ResponsesClient { turn_state, } = options; - let body = if request.store && self.session.provider().is_azure_responses_endpoint() { - let mut body = serde_json::to_value(&request).map_err(|e| { - ApiError::Stream(format!("failed to encode responses request: {e}")) - })?; - attach_item_ids(&mut body, &request.input); - EncodedJsonBody::encode(&body) - } else { - EncodedJsonBody::encode(&request) - } - .map_err(|e| ApiError::Stream(format!("failed to encode responses request: {e}")))?; + let body = EncodedJsonBody::encode(&request) + .map_err(|e| ApiError::Stream(format!("failed to encode responses request: {e}")))?; let mut headers = extra_headers; if let Some(ref thread_id) = thread_id { diff --git a/codex-rs/codex-api/src/endpoint/search.rs b/codex-rs/codex-api/src/endpoint/search.rs index 1c231d6bef..864f7ec9b0 100644 --- a/codex-rs/codex-api/src/endpoint/search.rs +++ b/codex-rs/codex-api/src/endpoint/search.rs @@ -149,7 +149,7 @@ mod tests { model: "gpt-test".to_string(), reasoning: None, input: Some(SearchInput::Items(vec![ResponseItem::Message { - id: None, + id: Some("msg_search".to_string()), role: "user".to_string(), content: vec![ ContentItem::InputText { @@ -228,6 +228,7 @@ mod tests { "model": "gpt-test", "input": [{ "type": "message", + "id": "msg_search", "role": "user", "content": [ {"type": "input_text", "text": "find this"}, diff --git a/codex-rs/codex-api/src/requests/mod.rs b/codex-rs/codex-api/src/requests/mod.rs index 1c357b2a61..abe57a886d 100644 --- a/codex-rs/codex-api/src/requests/mod.rs +++ b/codex-rs/codex-api/src/requests/mod.rs @@ -2,4 +2,3 @@ pub(crate) mod headers; pub(crate) mod responses; pub use responses::Compression; -pub(crate) use responses::attach_item_ids; diff --git a/codex-rs/codex-api/src/requests/responses.rs b/codex-rs/codex-api/src/requests/responses.rs index 836f525c49..9a16ceb1c4 100644 --- a/codex-rs/codex-api/src/requests/responses.rs +++ b/codex-rs/codex-api/src/requests/responses.rs @@ -1,37 +1,6 @@ -use codex_protocol::models::ResponseItem; -use serde_json::Value; - #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum Compression { #[default] None, Zstd, } - -pub(crate) fn attach_item_ids(payload_json: &mut Value, original_items: &[ResponseItem]) { - let Some(input_value) = payload_json.get_mut("input") else { - return; - }; - let Value::Array(items) = input_value else { - return; - }; - - for (value, item) in items.iter_mut().zip(original_items.iter()) { - if let ResponseItem::Reasoning { id: Some(id), .. } - | ResponseItem::Message { id: Some(id), .. } - | ResponseItem::WebSearchCall { id: Some(id), .. } - | ResponseItem::FunctionCall { id: Some(id), .. } - | ResponseItem::ToolSearchCall { id: Some(id), .. } - | ResponseItem::LocalShellCall { id: Some(id), .. } - | ResponseItem::CustomToolCall { id: Some(id), .. } = item - { - if id.is_empty() { - continue; - } - - if let Some(obj) = value.as_object_mut() { - obj.insert("id".to_string(), Value::String(id.clone())); - } - } - } -} diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index d8489ed748..488f6dc674 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -301,7 +301,7 @@ async fn responses_client_uses_responses_path() -> Result<()> { } #[tokio::test] -async fn responses_client_stream_request_preserves_exact_json_body() -> Result<()> { +async fn responses_client_stream_request_preserves_item_ids() -> Result<()> { let state = RecordingState::default(); let transport = RecordingTransport::new(state.clone()); let client = ResponsesClient::new(transport, provider("openai"), Arc::new(NoAuth)); @@ -327,7 +327,7 @@ async fn responses_client_stream_request_preserves_exact_json_body() -> Result<( text: None, client_metadata: None, }; - let expected = serde_json::to_vec(&request)?; + let expected = serde_json::to_value(&request)?; let _stream = client .stream_request(request, ResponsesOptions::default()) @@ -338,7 +338,10 @@ async fn responses_client_stream_request_preserves_exact_json_body() -> Result<( let prepared = requests[0] .prepare_body_for_send() .expect("body should prepare"); - assert_eq!(prepared.body.as_deref(), Some(expected.as_slice())); + let body: serde_json::Value = + serde_json::from_slice(prepared.body.as_deref().expect("body should be JSON"))?; + assert_eq!(body, expected); + assert_eq!(body["input"][0]["id"], "msg_1"); assert_eq!( prepared.headers.get(http::header::CONTENT_TYPE), Some(&HeaderValue::from_static("application/json")) @@ -502,7 +505,7 @@ async fn streaming_client_does_not_retry_auth_build_error() -> Result<()> { } #[tokio::test] -async fn azure_default_store_attaches_ids_and_headers() -> Result<()> { +async fn azure_store_sends_ids_and_headers() -> Result<()> { let state = RecordingState::default(); let transport = RecordingTransport::new(state.clone()); let client = ResponsesClient::new(transport, provider("azure"), Arc::new(NoAuth)); diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index f80ec3b563..0568f20e5f 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -530,6 +530,9 @@ "in_app_browser": { "type": "boolean" }, + "item_ids": { + "type": "boolean" + }, "js_repl": { "type": "boolean" }, @@ -4760,6 +4763,9 @@ "in_app_browser": { "type": "boolean" }, + "item_ids": { + "type": "boolean" + }, "js_repl": { "type": "boolean" }, diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 773a15deba..73d7398a3d 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -177,6 +177,7 @@ struct ModelClientState { enable_request_compression: bool, include_timing_metrics: bool, beta_features_header: Option, + item_ids_enabled: bool, include_attestation: bool, attestation_provider: Option>, disable_websockets: AtomicBool, @@ -377,6 +378,7 @@ impl ModelClient { enable_request_compression: bool, include_timing_metrics: bool, beta_features_header: Option, + item_ids_enabled: bool, attestation_provider: Option>, ) -> Self { let model_provider = create_model_provider(provider_info, auth_manager); @@ -397,6 +399,7 @@ impl ModelClient { enable_request_compression, include_timing_metrics, beta_features_header, + item_ids_enabled, include_attestation, attestation_provider, disable_websockets: AtomicBool::new(false), @@ -519,7 +522,7 @@ impl ModelClient { let ResponsesApiRequest { model, instructions, - input, + mut input, tools, parallel_tool_calls, reasoning, @@ -528,6 +531,7 @@ impl ModelClient { text, .. } = request; + self.prepare_response_items_for_request(&mut input, /*store*/ false); let payload = ApiCompactionInput { model: &model, input: &input, @@ -823,6 +827,16 @@ impl ModelClient { Ok(request) } + fn prepare_response_items_for_request(&self, input: &mut [ResponseItem], store: bool) { + if self.state.item_ids_enabled || store { + return; + } + + for item in input { + item.set_id(/*new_id*/ None); + } + } + /// Returns whether the Responses-over-WebSocket transport is active for this session. /// /// WebSocket use is controlled by provider capability and session-scoped fallback state. @@ -1292,7 +1306,7 @@ impl ModelClientSession { ) .await; - let request = self.client.build_responses_request( + let mut request = self.client.build_responses_request( &client_setup.api_provider, prompt, model_info, @@ -1301,6 +1315,9 @@ impl ModelClientSession { service_tier.clone(), responses_metadata, )?; + let store = request.store; + self.client + .prepare_response_items_for_request(&mut request.input, store); let inference_trace_attempt = inference_trace.start_attempt(); inference_trace_attempt.add_request_headers(&mut options.extra_headers); inference_trace_attempt.record_started(&request); @@ -1468,6 +1485,10 @@ impl ModelClientSession { inference_trace.start_attempt() }; stamp_ws_stream_request_start_ms(&mut ws_request); + let ResponsesWsRequest::ResponseCreate(ws_payload) = &mut ws_request; + let store = ws_payload.store; + self.client + .prepare_response_items_for_request(&mut ws_payload.input, store); if previous_response_id_from_untraced_warmup { // The transport can reuse an untraced warmup response id and omit the // already-sent input, but rollout replay needs the logical model-visible diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index 06074a5d20..1d0c4c48c2 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -76,6 +76,7 @@ fn test_model_client(session_source: SessionSource) -> ModelClient { /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*item_ids_enabled*/ false, /*attestation_provider*/ None, ) } @@ -566,6 +567,7 @@ fn model_client_with_counting_attestation( /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*item_ids_enabled*/ false, Some(Arc::new(CountingAttestationProvider { calls: attestation_calls.clone(), })), diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 099b9c30b8..8a6026bb8a 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -231,10 +231,7 @@ async fn run_remote_compact_task_inner_impl( ) .await?; let mut input = prompt_input.clone(); - input.push(ResponseItem::CompactionTrigger { - id: None, - metadata: None, - }); + input.push(ResponseItem::CompactionTrigger { metadata: None }); let prompt = Prompt { input, tools: tool_router.model_visible_specs(), diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index d721a162dc..7fd3b9540b 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2664,17 +2664,47 @@ impl Session { turn_context: &TurnContext, items: &'a [ResponseItem], ) -> Cow<'a, [ResponseItem]> { - if !turn_context + let mut items = Cow::Borrowed(items); + if turn_context .config .features .enabled(Feature::ResizeAllImages) { - return Cow::Borrowed(items); + prepare_response_items(items.to_mut()); } + if turn_context.config.features.enabled(Feature::ItemIds) { + Self::assign_missing_response_item_ids(&mut items); + } + items + } - let mut prepared_items = items.to_vec(); - prepare_response_items(&mut prepared_items); - Cow::Owned(prepared_items) + fn assign_missing_response_item_ids(items: &mut Cow<'_, [ResponseItem]>) { + if items.iter().all(|item| item.id().is_some()) { + return; + } + for item in items.to_mut() { + if item.id().is_some() { + continue; + } + let prefix = match item { + ResponseItem::Message { .. } => "msg", + ResponseItem::Reasoning { .. } => "rs", + ResponseItem::LocalShellCall { .. } => "lsh", + ResponseItem::FunctionCall { .. } => "fc", + ResponseItem::ToolSearchCall { .. } => "tsc", + ResponseItem::FunctionCallOutput { .. } => "fco", + ResponseItem::CustomToolCall { .. } => "ctc", + ResponseItem::CustomToolCallOutput { .. } => "ctco", + ResponseItem::ToolSearchOutput { .. } => "tso", + ResponseItem::WebSearchCall { .. } => "ws", + ResponseItem::ImageGenerationCall { .. } => "ig", + ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => "cmp", + ResponseItem::AgentMessage { .. } + | ResponseItem::CompactionTrigger { .. } + | ResponseItem::Other => continue, + }; + item.set_id(Some(format!("{prefix}_{}", Uuid::now_v7()))); + } } pub(crate) fn response_item_from_user_input( diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index b7ceb165bb..583882cae1 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1039,6 +1039,7 @@ impl Session { config.features.enabled(Feature::EnableRequestCompression), config.features.enabled(Feature::RuntimeMetrics), Self::build_model_client_beta_features_header(config.as_ref()), + /*item_ids_enabled*/ config.features.enabled(Feature::ItemIds), attestation_provider, ) .with_prompt_cache_key_override( diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 67dd6becfc..8fec6f97dc 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -42,6 +42,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::TrustLevel; use codex_protocol::exec_output::ExecToolCallOutput; use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::AgentMessageInputContent; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::FunctionCallOutputBody; @@ -198,6 +199,27 @@ fn user_message(text: &str) -> ResponseItem { } } +#[test] +fn assign_missing_response_item_ids_skips_agent_messages() { + let mut items = Cow::Owned(vec![ + ResponseItem::AgentMessage { + id: None, + author: "worker".to_string(), + recipient: "root".to_string(), + content: vec![AgentMessageInputContent::InputText { + text: "done".to_string(), + }], + metadata: None, + }, + user_message("hello"), + ]); + + Session::assign_missing_response_item_ids(&mut items); + + assert_eq!(items[0].id(), None); + assert!(items[1].id().is_some_and(|id| id.starts_with("msg_"))); +} + fn assistant_message(text: &str) -> ResponseItem { ResponseItem::Message { id: None, @@ -438,6 +460,7 @@ fn test_model_client_session() -> crate::client::ModelClientSession { /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*item_ids_enabled*/ false, /*attestation_provider*/ None, ) .new_session() @@ -1670,6 +1693,7 @@ async fn resize_all_images_prepares_failures_before_history_insertion() { Vec::new(), |config| { let _ = config.features.enable(Feature::ResizeAllImages); + let _ = config.features.enable(Feature::ItemIds); }, ) .await; @@ -1699,8 +1723,18 @@ async fn resize_all_images_prepares_failures_before_history_insertion() { .record_conversation_items(turn_context.as_ref(), std::slice::from_ref(&item)) .await; + let history = session.state.lock().await.clone_history(); + let id = history.raw_items()[0] + .id() + .expect("history item should have an ID") + .to_string(); + let uuid = id + .strip_prefix("fco_") + .expect("function call output ID should have the Responses API prefix"); + let parsed_id = Uuid::parse_str(uuid).expect("history item should have a UUID ID"); + assert_eq!(parsed_id.get_version(), Some(uuid::Version::SortRand)); let expected = vec![ResponseItem::FunctionCallOutput { - id: None, + id: Some(id), call_id: "call-1".to_string(), output: FunctionCallOutputPayload { body: FunctionCallOutputBody::ContentItems(vec![ @@ -1719,10 +1753,7 @@ async fn resize_all_images_prepares_failures_before_history_insertion() { }, metadata: None, }]; - assert_eq!( - session.state.lock().await.clone_history().raw_items(), - expected.as_slice() - ); + assert_eq!(history.raw_items(), expected.as_slice()); } #[tokio::test] @@ -5059,6 +5090,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { config.features.enabled(Feature::EnableRequestCompression), config.features.enabled(Feature::RuntimeMetrics), Session::build_model_client_beta_features_header(config.as_ref()), + /*item_ids_enabled*/ config.features.enabled(Feature::ItemIds), /*attestation_provider*/ None, ), code_mode_service: crate::tools::code_mode::CodeModeService::new(), @@ -7113,6 +7145,7 @@ where config.features.enabled(Feature::EnableRequestCompression), config.features.enabled(Feature::RuntimeMetrics), Session::build_model_client_beta_features_header(config.as_ref()), + /*item_ids_enabled*/ config.features.enabled(Feature::ItemIds), /*attestation_provider*/ None, ), code_mode_service: crate::tools::code_mode::CodeModeService::new(), diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index b5bc2b0d44..cf9b9a939f 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -127,6 +127,7 @@ async fn responses_stream_includes_subagent_header_on_review() { /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*item_ids_enabled*/ false, /*attestation_provider*/ None, ); let responses_metadata = test_turn_responses_metadata(&client, thread_id, &session_source); @@ -258,6 +259,7 @@ async fn responses_stream_includes_subagent_header_on_other() { /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*item_ids_enabled*/ false, /*attestation_provider*/ None, ); let responses_metadata = test_turn_responses_metadata(&client, thread_id, &session_source); @@ -375,6 +377,7 @@ async fn responses_respects_model_info_overrides_from_config() { /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*item_ids_enabled*/ false, /*attestation_provider*/ None, ); let responses_metadata = test_turn_responses_metadata(&client, thread_id, &session_source); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index e329618828..dff440a305 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -134,6 +134,22 @@ fn message_input_text_contains(request: &ResponsesRequest, role: &str, needle: & .any(|text| text.contains(needle)) } +fn response_message_item_id(request: &ResponsesRequest, role: &str, text: &str) -> String { + request + .inputs_of_type("message") + .into_iter() + .find(|item| { + item.get("role").and_then(serde_json::Value::as_str) == Some(role) + && message_input_texts(item).contains(&text) + }) + .and_then(|item| { + item.get("id") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) + .unwrap_or_else(|| panic!("missing item ID for {role} message {text:?}")) +} + fn assert_codex_client_metadata( request_body: &serde_json::Value, installation_id: &str, @@ -216,9 +232,74 @@ async fn non_openai_responses_requests_omit_item_turn_metadata() { item.get("metadata").is_none(), "input item should omit metadata: {item}" ); + assert!( + item.get("id").is_none(), + "input item should omit generated IDs: {item}" + ); } } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn response_item_ids_persist_across_resume_and_preserve_server_ids() -> anyhow::Result<()> { + let server = MockServer::start().await; + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg_server", "first reply"), + ev_completed("resp-1"), + ]), + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ], + ) + .await; + let mut builder = test_codex().with_config(|config| { + let _ = config.features.enable(Feature::ItemIds); + }); + let initial = builder.build(&server).await?; + let home = Arc::clone(&initial.home); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); + + initial.submit_turn("before resume").await?; + initial.codex.submit(Op::Shutdown).await?; + wait_for_event(&initial.codex, |event| { + matches!(event, EventMsg::ShutdownComplete) + }) + .await; + + builder = builder.with_config(|config| { + let _ = config.features.enable(Feature::ItemIds); + }); + let resumed = builder.resume(&server, home, rollout_path).await?; + resumed.submit_turn("after resume").await?; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); + let user_id = response_message_item_id(&requests[0], "user", "before resume"); + let user_uuid = user_id + .strip_prefix("msg_") + .expect("message ID should have the Responses API prefix"); + assert_eq!( + Uuid::parse_str(user_uuid)?.get_version(), + Some(uuid::Version::SortRand) + ); + assert_eq!( + response_message_item_id(&requests[1], "user", "before resume"), + user_id + ); + assert_eq!( + response_message_item_id(&requests[1], "assistant", "first reply"), + "msg_server" + ); + + Ok(()) +} + /// Writes an `auth.json` into the provided `codex_home` with the specified parameters. /// Returns the fake JWT string written to `tokens.id_token`. #[expect(clippy::unwrap_used)] @@ -1022,6 +1103,7 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*item_ids_enabled*/ config.features.enabled(Feature::ItemIds), /*attestation_provider*/ None, ); let responses_metadata = test_turn_responses_metadata(&client, thread_id); @@ -2515,6 +2597,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*item_ids_enabled*/ false, /*attestation_provider*/ None, ); let responses_metadata = test_turn_responses_metadata(&client, thread_id); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index cee54e24b3..1ccbd302d8 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -157,7 +157,8 @@ async fn responses_websocket_streams_request() { let harness = websocket_harness(&server).await; let mut client_session = harness.client.new_session(); - let prompt = prompt_with_input(vec![message_item("hello")]); + let mut prompt = prompt_with_input(vec![message_item("hello")]); + prompt.input[0].set_id(Some("msg_existing".to_string())); stream_until_complete(&mut client_session, &harness, &prompt).await; @@ -169,6 +170,7 @@ async fn responses_websocket_streams_request() { assert_eq!(body["model"].as_str(), Some(MODEL)); assert_eq!(body["stream"], serde_json::Value::Bool(true)); assert_eq!(body["input"].as_array().map(Vec::len), Some(1)); + assert_eq!(body["input"][0].get("id"), None); let handshake = server.single_handshake(); assert_eq!( handshake.header(OPENAI_BETA_HEADER), @@ -2189,6 +2191,7 @@ async fn websocket_harness_with_provider_options( /*enable_request_compression*/ false, runtime_metrics_enabled, /*beta_features_header*/ None, + /*item_ids_enabled*/ config.features.enabled(Feature::ItemIds), /*attestation_provider*/ None, ); diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 22363eabea..ef7a305bd7 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -649,8 +649,8 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { ); assert_eq!( image_generation_calls[0]["id"].as_str(), - Some("ig_123"), - "expected the original image generation call id to be preserved" + None, + "expected the image generation call id to be omitted" ); assert_eq!( image_generation_calls[0]["result"].as_str(), @@ -766,8 +766,8 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima ); assert_eq!( image_generation_calls[0]["id"].as_str(), - Some("ig_123"), - "second request should preserve the original generated image call id" + None, + "second request should omit the generated image call id" ); assert_eq!( image_generation_calls[0]["result"].as_str(), diff --git a/codex-rs/ext/web-search/src/history.rs b/codex-rs/ext/web-search/src/history.rs index 3065ed178b..df3faf123c 100644 --- a/codex-rs/ext/web-search/src/history.rs +++ b/codex-rs/ext/web-search/src/history.rs @@ -29,7 +29,9 @@ pub(crate) fn recent_input(items: &[ResponseItem]) -> Option { fn push_visible_message(messages: &mut Vec, item: &ResponseItem) { match item { ResponseItem::Message { role, .. } if role == ASSISTANT_ROLE => { - messages.push(item.clone()); + let mut message = item.clone(); + message.set_id(/*new_id*/ None); + messages.push(message); } ResponseItem::AgentMessage { author, @@ -50,7 +52,7 @@ fn push_visible_message(messages: &mut Vec, item: &ResponseItem) { } } ResponseItem::Message { - id, + id: _, role, content, phase, @@ -65,7 +67,7 @@ fn push_visible_message(messages: &mut Vec, item: &ResponseItem) { .collect::>(); if !content.is_empty() { messages.push(ResponseItem::Message { - id: id.clone(), + id: None, role: role.clone(), content, phase: phase.clone(), @@ -108,11 +110,15 @@ mod tests { #[test] fn keeps_current_user_and_previous_visible_turn() { + let mut previous_user = message(USER_ROLE, "previous user"); + previous_user.set_id(Some("msg_previous_user".to_string())); + let mut previous_assistant = message(ASSISTANT_ROLE, "previous assistant"); + previous_assistant.set_id(Some("msg_previous_assistant".to_string())); let items = vec![ message("system", "system"), message(USER_ROLE, "old user"), message(ASSISTANT_ROLE, "old assistant"), - message(USER_ROLE, "previous user"), + previous_user, ResponseItem::FunctionCall { id: None, name: "tool".to_string(), @@ -121,7 +127,7 @@ mod tests { call_id: "call-1".to_string(), metadata: None, }, - message(ASSISTANT_ROLE, "previous assistant"), + previous_assistant, message("developer", "developer"), message(USER_ROLE, "current user"), message(ASSISTANT_ROLE, "current commentary"), diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index aa208d1ba0..8808cb37c3 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -190,6 +190,8 @@ pub enum Feature { ImageGenExt, /// Resize all inline data-URL images before recording them in history. ResizeAllImages, + /// Generate Responses API item IDs for client-created history items. + ItemIds, /// Allow prompting and installing missing MCP dependencies. SkillMcpDependencyInstall, /// Removed compatibility flag for deleted skill env var dependency prompting. @@ -1134,6 +1136,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::ItemIds, + key: "item_ids", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::SkillMcpDependencyInstall, key: "skill_mcp_dependency_install", diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index 1d8fcb567b..6cc856ddb4 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -235,6 +235,7 @@ impl MemoryStartupContext { config.features.enabled(Feature::EnableRequestCompression), config.features.enabled(Feature::RuntimeMetrics), /*beta_features_header*/ None, + config.features.enabled(Feature::ItemIds), /*attestation_provider*/ None, ); diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 3c0753cb43..b0d373cfb0 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -918,9 +918,8 @@ pub struct ResponseItemMetadata { #[serde(tag = "type", rename_all = "snake_case")] pub enum ResponseItem { Message { - #[serde(default, skip_serializing)] - #[ts(skip)] - #[schemars(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, role: String, content: Vec, @@ -935,9 +934,8 @@ pub enum ResponseItem { metadata: Option, }, AgentMessage { - #[serde(default, skip_serializing)] - #[ts(skip)] - #[schemars(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, author: String, recipient: String, @@ -947,9 +945,8 @@ pub enum ResponseItem { metadata: Option, }, Reasoning { - #[serde(default, skip_serializing)] - #[ts(skip)] - #[schemars(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, summary: Vec, #[serde(default, skip_serializing_if = "should_serialize_reasoning_content")] @@ -962,9 +959,8 @@ pub enum ResponseItem { }, LocalShellCall { /// Legacy id field retained for compatibility with older payloads. - #[serde(default, skip_serializing)] - #[ts(skip)] - #[schemars(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, /// Set when using the Responses API. call_id: Option, @@ -975,9 +971,8 @@ pub enum ResponseItem { metadata: Option, }, FunctionCall { - #[serde(default, skip_serializing)] - #[ts(skip)] - #[schemars(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, name: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -993,9 +988,8 @@ pub enum ResponseItem { metadata: Option, }, ToolSearchCall { - #[serde(default, skip_serializing)] - #[ts(skip)] - #[schemars(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, call_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1014,9 +1008,8 @@ pub enum ResponseItem { // - an array of structured content items (`content_items`) // We keep this behavior centralized in `FunctionCallOutputPayload`. FunctionCallOutput { - #[serde(default, skip_serializing)] - #[ts(skip)] - #[schemars(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, call_id: String, #[ts(as = "FunctionCallOutputBody")] @@ -1027,9 +1020,8 @@ pub enum ResponseItem { metadata: Option, }, CustomToolCall { - #[serde(default, skip_serializing)] - #[ts(skip)] - #[schemars(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1046,9 +1038,8 @@ pub enum ResponseItem { // `function_call_output.output` so freeform tools can return either plain // text or structured content items. CustomToolCallOutput { - #[serde(default, skip_serializing)] - #[ts(skip)] - #[schemars(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, call_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1062,9 +1053,8 @@ pub enum ResponseItem { metadata: Option, }, ToolSearchOutput { - #[serde(default, skip_serializing)] - #[ts(skip)] - #[schemars(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, call_id: Option, status: String, @@ -1084,9 +1074,8 @@ pub enum ResponseItem { // "action": {"type":"search","query":"weather: San Francisco, CA"} // } WebSearchCall { - #[serde(default, skip_serializing)] - #[ts(skip)] - #[schemars(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1108,7 +1097,6 @@ pub enum ResponseItem { // "result":"..." // } ImageGenerationCall { - /// Existing provider ID retained on serialized history for compatibility. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] id: Option, @@ -1123,28 +1111,24 @@ pub enum ResponseItem { }, #[serde(alias = "compaction_summary")] Compaction { - #[serde(default, skip_serializing)] - #[ts(skip)] - #[schemars(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, encrypted_content: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] metadata: Option, }, + // Compaction triggers are request controls, and the Responses API does not + // accept an `id` field for them. CompactionTrigger { - #[serde(default, skip_serializing)] - #[ts(skip)] - #[schemars(skip)] - id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] metadata: Option, }, ContextCompaction { - #[serde(default, skip_serializing)] - #[ts(skip)] - #[schemars(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1179,14 +1163,13 @@ impl ResponseItem { | Self::Reasoning { id, .. } | Self::ImageGenerationCall { id, .. } | Self::Compaction { id, .. } - | Self::CompactionTrigger { id, .. } | Self::ContextCompaction { id, .. } => id.as_deref().filter(|id| !id.is_empty()), - Self::Other => None, + Self::CompactionTrigger { .. } | Self::Other => None, } } - /// Sets the Responses API item ID for variants that carry one. - pub fn set_id(&mut self, new_id: String) { + /// Sets or clears the Responses API item ID for variants that carry one. + pub fn set_id(&mut self, new_id: Option) { match self { Self::Message { id, .. } | Self::AgentMessage { id, .. } @@ -1201,9 +1184,8 @@ impl ResponseItem { | Self::Reasoning { id, .. } | Self::ImageGenerationCall { id, .. } | Self::Compaction { id, .. } - | Self::CompactionTrigger { id, .. } - | Self::ContextCompaction { id, .. } => *id = Some(new_id), - Self::Other => {} + | Self::ContextCompaction { id, .. } => *id = new_id, + Self::CompactionTrigger { .. } | Self::Other => {} } } @@ -2150,9 +2132,13 @@ mod tests { let mut item = response_item_with_metadata(/*metadata*/ None); assert_eq!(item.id(), None); - item.set_id("msg_test".to_string()); + item.set_id(Some("msg_test".to_string())); assert_eq!(item.id(), Some("msg_test")); + + item.set_id(/*new_id*/ None); + + assert_eq!(item.id(), None); } fn response_item_with_metadata(metadata: Option) -> ResponseItem { @@ -3022,10 +3008,7 @@ mod tests { #[test] fn serializes_compaction_trigger_without_payload() -> Result<()> { - let item = ResponseItem::CompactionTrigger { - id: None, - metadata: None, - }; + let item = ResponseItem::CompactionTrigger { metadata: None }; assert_eq!( serde_json::to_value(item)?, @@ -3038,10 +3021,7 @@ mod tests { #[test] fn serializes_stamped_compaction_trigger_metadata() -> Result<()> { - let mut item = ResponseItem::CompactionTrigger { - id: None, - metadata: None, - }; + let mut item = ResponseItem::CompactionTrigger { metadata: None }; item.stamp_turn_id_if_missing("turn-1"); assert_eq!( @@ -3062,13 +3042,7 @@ mod tests { let item: ResponseItem = serde_json::from_str(json)?; - assert_eq!( - item, - ResponseItem::CompactionTrigger { - id: None, - metadata: None, - } - ); + assert_eq!(item, ResponseItem::CompactionTrigger { metadata: None }); Ok(()) } @@ -3109,7 +3083,6 @@ mod tests { queries: Some(vec!["weather seattle".into(), "seattle weather now".into()]), }), Some("completed".into()), - true, ), ( r#"{ @@ -3125,7 +3098,6 @@ mod tests { url: Some("https://example.com".into()), }), Some("open".into()), - true, ), ( r#"{ @@ -3143,7 +3115,6 @@ mod tests { pattern: Some("installation".into()), }), Some("in_progress".into()), - true, ), ( r#"{ @@ -3154,12 +3125,10 @@ mod tests { Some("ws_partial".into()), None, Some("in_progress".into()), - false, ), ]; - for (json_literal, expected_id, expected_action, expected_status, expect_roundtrip) in cases - { + for (json_literal, expected_id, expected_action, expected_status) in cases { let parsed: ResponseItem = serde_json::from_str(json_literal)?; let expected = ResponseItem::WebSearchCall { id: expected_id.clone(), @@ -3170,10 +3139,7 @@ mod tests { assert_eq!(parsed, expected); let serialized = serde_json::to_value(&parsed)?; - let mut expected_serialized: serde_json::Value = serde_json::from_str(json_literal)?; - if !expect_roundtrip && let Some(obj) = expected_serialized.as_object_mut() { - obj.remove("id"); - } + let expected_serialized: serde_json::Value = serde_json::from_str(json_literal)?; assert_eq!(serialized, expected_serialized); } diff --git a/codex-rs/rollout-trace/src/inference.rs b/codex-rs/rollout-trace/src/inference.rs index 7329fd3cf2..7afdcf5847 100644 --- a/codex-rs/rollout-trace/src/inference.rs +++ b/codex-rs/rollout-trace/src/inference.rs @@ -515,6 +515,7 @@ mod tests { traced, json!({ "type": "reasoning", + "id": "rs-1", "summary": [{"type": "summary_text", "text": "summary"}], "content": [{"type": "text", "text": "raw reasoning"}], "encrypted_content": "encoded", From 38c96866f0213173f98cf102a8efbb5aad5f6543 Mon Sep 17 00:00:00 2001 From: xl-openai Date: Thu, 18 Jun 2026 18:02:14 -0700 Subject: [PATCH 062/112] [codex] Skip curated repo sync for remote plugins (#29005) ## Summary - skip the legacy `openai-curated` startup repository sync when remote plugins are enabled and the current auth uses the Codex backend - keep the curated sync for API-key, Bedrock, and unauthenticated sessions that fall back to the local marketplace - preserve configured marketplace upgrades and all remote plugin startup warmups ## Why The remote catalog owns plugin discovery and materialization only when it is usable for the current auth mode. Starting the legacy curated repository sync in that case performs an unnecessary Git/HTTP/archive download and cache refresh. API-key and Bedrock sessions still require the local curated marketplace, so they must continue syncing it. ## User impact Codex startup no longer downloads or refreshes the local `openai-curated` snapshot when the remote catalog is active. Behavior is unchanged for auth modes that use the local curated marketplace. ## Validation - `just fmt` - `git diff --check` Rust tests were not run per the repository's local verification policy for this narrow conditional change. --- codex-rs/core-plugins/src/manager.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 5e2224bb7c..b502106ea9 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -1779,7 +1779,11 @@ impl PluginsManager { on_effective_plugins_changed: Option>, ) { if config.plugins_enabled { - self.start_curated_repo_sync(); + let use_remote_global_catalog = + config.remote_plugin_enabled && auth_manager.current_auth_uses_codex_backend(); + if !use_remote_global_catalog { + self.start_curated_repo_sync(); + } let should_spawn_marketplace_auto_upgrade = { let mut state = match self.configured_marketplace_upgrade_state.write() { Ok(state) => state, From 73251b2f00b3d9bda142f622fc11bde5af793bdd Mon Sep 17 00:00:00 2001 From: rka-oai Date: Thu, 18 Jun 2026 18:46:57 -0700 Subject: [PATCH 063/112] [codex] add clock current-time tool (#29011) ## Summary - expose `clock.curr_time` when current-time reminders are enabled - query the session's configured time provider with the calling thread id - return the existing UTC reminder text for direct model calls - return `{ "current_time": "YYYY-MM-DD HH:MM:SS UTC" }` in Code Mode Clock lookup failures remain fatal, matching pre-inference reminder behavior. ## Testing - `just test -p codex-core current_time_tool_returns_the_latest_time` - `just test -p codex-core code_mode_current_time_returns_structured_result` - `just fix -p codex-core` --- .../core/src/context/current_time_reminder.rs | 11 +- .../core/src/tools/handlers/current_time.rs | 108 ++++++++++++++++++ codex-rs/core/src/tools/handlers/mod.rs | 2 + codex-rs/core/src/tools/spec_plan.rs | 5 + codex-rs/core/tests/suite/code_mode.rs | 45 ++++++++ .../core/tests/suite/current_time_reminder.rs | 43 +++++++ 6 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 codex-rs/core/src/tools/handlers/current_time.rs diff --git a/codex-rs/core/src/context/current_time_reminder.rs b/codex-rs/core/src/context/current_time_reminder.rs index 0e3fe0b1e8..0baf56b281 100644 --- a/codex-rs/core/src/context/current_time_reminder.rs +++ b/codex-rs/core/src/context/current_time_reminder.rs @@ -11,6 +11,12 @@ impl CurrentTimeReminder { pub(crate) fn new(current_time: DateTime) -> Self { Self { current_time } } + + pub(crate) fn formatted_time(&self) -> String { + self.current_time + .format("%Y-%m-%d %H:%M:%S UTC") + .to_string() + } } impl ContextualUserFragment for CurrentTimeReminder { @@ -27,9 +33,6 @@ impl ContextualUserFragment for CurrentTimeReminder { } fn body(&self) -> String { - format!( - "It is {}.", - self.current_time.format("%Y-%m-%d %H:%M:%S UTC") - ) + format!("It is {}.", self.formatted_time()) } } diff --git a/codex-rs/core/src/tools/handlers/current_time.rs b/codex-rs/core/src/tools/handlers/current_time.rs new file mode 100644 index 0000000000..73873ca741 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/current_time.rs @@ -0,0 +1,108 @@ +use crate::context::ContextualUserFragment; +use crate::context::CurrentTimeReminder; +use crate::function_tool::FunctionCallError; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolOutput; +use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; +use crate::tools::registry::CoreToolRuntime; +use crate::tools::registry::ToolExecutor; +use codex_protocol::models::ResponseInputItem; +use codex_tools::JsonSchema; +use codex_tools::ResponsesApiNamespace; +use codex_tools::ResponsesApiNamespaceTool; +use codex_tools::ResponsesApiTool; +use codex_tools::ToolName; +use codex_tools::ToolSpec; +use serde_json::Value as JsonValue; +use serde_json::json; +use std::collections::BTreeMap; + +const NAMESPACE: &str = "clock"; +const TOOL_NAME: &str = "curr_time"; + +struct CurrentTimeOutput(CurrentTimeReminder); + +impl ToolOutput for CurrentTimeOutput { + fn log_preview(&self) -> String { + self.0.render() + } + + fn success_for_logging(&self) -> bool { + true + } + + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { + FunctionToolOutput::from_text(self.0.render(), Some(true)) + .to_response_item(call_id, payload) + } + + fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { + json!({ + "current_time": self.0.formatted_time(), + }) + } +} + +pub struct CurrentTimeHandler; + +impl ToolExecutor for CurrentTimeHandler { + fn tool_name(&self) -> ToolName { + ToolName::namespaced(NAMESPACE, TOOL_NAME) + } + + fn spec(&self) -> ToolSpec { + ToolSpec::Namespace(ResponsesApiNamespace { + name: NAMESPACE.to_string(), + description: "Tools for reading the current time.".to_string(), + tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool { + name: TOOL_NAME.to_string(), + description: "Return the current time in UTC.".to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::object( + BTreeMap::new(), + /*required*/ None, + /*additional_properties*/ Some(false.into()), + ), + output_schema: Some(json!({ + "type": "object", + "properties": { + "current_time": { + "type": "string", + "description": "Current UTC time formatted as YYYY-MM-DD HH:MM:SS UTC." + } + }, + "required": ["current_time"], + "additionalProperties": false + })), + })], + }) + } + + fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> { + Box::pin(async move { + if !matches!(invocation.payload, ToolPayload::Function { .. }) { + return Err(FunctionCallError::RespondToModel(format!( + "{TOOL_NAME} handler received unsupported payload" + ))); + } + + let current_time = invocation + .session + .services + .time_provider + .current_time(invocation.session.thread_id) + .await + .map_err(|err| { + FunctionCallError::Fatal(format!("failed to read current time: {err:#}")) + })?; + Ok(boxed_tool_output(CurrentTimeOutput( + CurrentTimeReminder::new(current_time), + ))) + }) + } +} + +impl CoreToolRuntime for CurrentTimeHandler {} diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index d9c338e82d..d1e931aa95 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod agent_jobs; pub(crate) mod agent_jobs_spec; pub(crate) mod apply_patch; pub(crate) mod apply_patch_spec; +mod current_time; mod dynamic; pub(crate) mod extension_tools; mod get_context_remaining; @@ -55,6 +56,7 @@ pub(crate) use crate::tools::code_mode::CodeModeWaitHandler; pub use apply_patch::ApplyPatchHandler; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::protocol::AskForApproval; +pub use current_time::CurrentTimeHandler; pub use dynamic::DynamicToolHandler; pub use get_context_remaining::GetContextRemainingHandler; pub use list_available_plugins_to_install::ListAvailablePluginsToInstallHandler; diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 660f3f4187..af7acf17a0 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -7,6 +7,7 @@ use crate::tools::effective_tool_mode; use crate::tools::handlers::ApplyPatchHandler; use crate::tools::handlers::CodeModeExecuteHandler; use crate::tools::handlers::CodeModeWaitHandler; +use crate::tools::handlers::CurrentTimeHandler; use crate::tools::handlers::DynamicToolHandler; use crate::tools::handlers::ExecCommandHandler; use crate::tools::handlers::ExecCommandHandlerOptions; @@ -711,6 +712,10 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut planned_tools.add(GetContextRemainingHandler); } + if features.enabled(Feature::CurrentTimeReminder) { + planned_tools.add(CurrentTimeHandler); + } + if features.enabled(Feature::SleepTool) { planned_tools.add(SleepHandler); } diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 60b476d614..0ce33cc691 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -6,7 +6,9 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_config::types::McpServerConfig; use codex_config::types::McpServerTransportConfig; use codex_core::config::Config; +use codex_core::config::CurrentTimeReminderConfig; use codex_extension_api::ExtensionRegistryBuilder; +use codex_features::CurrentTimeSource; use codex_features::Feature; use codex_login::CodexAuth; use codex_models_manager::bundled_models_response; @@ -848,6 +850,49 @@ text(JSON.stringify(result)); Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_current_time_returns_structured_result() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let (_test, second_mock) = run_code_mode_turn_with_config( + &server, + "use exec to get the current time", + r#" +const result = await tools.clock__curr_time({}); +text(JSON.stringify(result)); +"#, + |config| { + config + .features + .enable(Feature::CurrentTimeReminder) + .expect("test config should allow current-time reminders"); + config.current_time_reminder = Some(CurrentTimeReminderConfig { + reminder_interval_model_requests: 50, + clock_source: CurrentTimeSource::System, + }); + }, + ) + .await?; + + let req = second_mock.single_request(); + let (output, success) = custom_tool_output_body_and_success(&req, "call-1"); + assert_ne!( + success, + Some(false), + "exec clock.curr_time call failed unexpectedly: {output}" + ); + + let parsed: Value = serde_json::from_str(&output)?; + let current_time = parsed + .get("current_time") + .and_then(Value::as_str) + .expect("clock.curr_time should return current_time"); + assert_regex_match(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$", current_time); + + Ok(()) +} + #[cfg_attr(windows, ignore = "flaky on windows")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn code_mode_nested_tool_calls_can_run_in_parallel() -> Result<()> { diff --git a/codex-rs/core/tests/suite/current_time_reminder.rs b/codex-rs/core/tests/suite/current_time_reminder.rs index 5b5970a0ee..fda9b85b03 100644 --- a/codex-rs/core/tests/suite/current_time_reminder.rs +++ b/codex-rs/core/tests/suite/current_time_reminder.rs @@ -23,6 +23,7 @@ use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; use core_test_support::responses::mount_sse_sequence; @@ -272,3 +273,45 @@ async fn time_provider_failure_stops_before_inference() -> Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn current_time_tool_returns_the_latest_time() -> Result<()> { + skip_if_no_network!(Ok(())); + + const CALL_ID: &str = "current-time"; + + let server = start_mock_server().await; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call_with_namespace(CALL_ID, "clock", "curr_time", "{}"), + ev_completed("resp-1"), + ]), + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ], + ) + .await; + let test = test_codex() + .with_config(|config| { + enable_current_time_reminder(config, /*interval*/ 50, CurrentTimeSource::External) + }) + .with_external_time_provider(Arc::new(TestTimeProvider::default())) + .build(&server) + .await?; + + test.submit_turn("check the current time").await?; + + let requests = responses.requests(); + assert!( + requests[0].tool_by_name("clock", "curr_time").is_some(), + "clock.curr_time should be exposed when current-time reminders are enabled" + ); + assert_eq!( + requests[1].function_call_output_text(CALL_ID), + Some(SECOND_REMINDER.to_string()) + ); + + Ok(()) +} From 70a6aa2634adedaabf66a66f11c16e869c1c7d1e Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 18 Jun 2026 18:49:57 -0700 Subject: [PATCH 064/112] core: assign item IDs to compacted replacement history (#29012) ## Why Remote v2 compaction can return replacement-history items without IDs. Because replacement history is installed directly, those items bypass normal history preparation and remain ID-less in later Responses requests even when the `item_ids` feature is enabled. ## What changed - Pass the active `TurnContext` into `replace_compacted_history`. - When `item_ids` is enabled, assign missing IDs before installing and persisting replacement history. - Rebuild `CompactedItem` from the prepared history so live and persisted replacement histories match. - Add integration coverage requiring IDs on every ID-capable input item in the initial, remote v2 compaction, and post-compaction requests. ## Test plan - `just test -p codex-core response_item_ids` - `just test -p codex-core websocket_v2_test_codex_shell_chain` - `just test -p codex-core remote_compaction_parity_pre_turn_auto` - `just test -p codex-app-server thread_inject_items_adds_raw_response_items_to_thread_history` --- codex-rs/core/src/compact.rs | 9 +++- codex-rs/core/src/compact_remote.rs | 9 +++- codex-rs/core/src/compact_remote_v2.rs | 9 +++- codex-rs/core/src/session/mod.rs | 21 ++++++++-- codex-rs/core/src/session/tests.rs | 4 +- codex-rs/core/tests/suite/client.rs | 57 ++++++++++++++++++++++++++ 6 files changed, 97 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index d7d2f57bf7..7b93195de4 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -322,8 +322,13 @@ async fn run_compact_task_inner_impl( window_number: Some(window_number), window_id: Some(window_id), }; - sess.replace_compacted_history(new_history, reference_context_item, compacted_item) - .await; + sess.replace_compacted_history( + turn_context.as_ref(), + new_history, + reference_context_item, + compacted_item, + ) + .await; sess.recompute_token_usage(&turn_context).await; sess.emit_turn_item_completed(&turn_context, compaction_item) diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 1eebc23172..26469c201a 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -284,8 +284,13 @@ async fn run_remote_compact_task_inner_impl( input_history: &trace_input_history, replacement_history: &new_history, }); - sess.replace_compacted_history(new_history, reference_context_item, compacted_item) - .await; + sess.replace_compacted_history( + turn_context.as_ref(), + new_history, + reference_context_item, + compacted_item, + ) + .await; sess.recompute_token_usage(turn_context).await; sess.emit_turn_item_completed(turn_context, compaction_item) diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 8a6026bb8a..d941388b07 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -312,8 +312,13 @@ async fn run_remote_compact_task_inner_impl( input_history: &trace_input_history, replacement_history: &new_history, }); - sess.replace_compacted_history(new_history, reference_context_item, compacted_item) - .await; + sess.replace_compacted_history( + turn_context.as_ref(), + new_history, + reference_context_item, + compacted_item, + ) + .await; sess.recompute_token_usage(turn_context).await; sess.emit_turn_item_completed(turn_context, compaction_item) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 7fd3b9540b..fbd5a61bc3 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2673,15 +2673,17 @@ impl Session { prepare_response_items(items.to_mut()); } if turn_context.config.features.enabled(Feature::ItemIds) { - Self::assign_missing_response_item_ids(&mut items); + Self::assign_missing_response_item_ids(items) + } else { + items } - items } - fn assign_missing_response_item_ids(items: &mut Cow<'_, [ResponseItem]>) { + fn assign_missing_response_item_ids(items: Cow<'_, [ResponseItem]>) -> Cow<'_, [ResponseItem]> { if items.iter().all(|item| item.id().is_some()) { - return; + return items; } + let mut items = items; for item in items.to_mut() { if item.id().is_some() { continue; @@ -2705,6 +2707,7 @@ impl Session { }; item.set_id(Some(format!("{prefix}_{}", Uuid::now_v7()))); } + items } pub(crate) fn response_item_from_user_input( @@ -2840,10 +2843,20 @@ impl Session { pub(crate) async fn replace_compacted_history( &self, + turn_context: &TurnContext, items: Vec, reference_context_item: Option, compacted_item: CompactedItem, ) { + let items = if turn_context.config.features.enabled(Feature::ItemIds) { + Self::assign_missing_response_item_ids(Cow::Owned(items)).into_owned() + } else { + items + }; + let compacted_item = CompactedItem { + replacement_history: Some(items.clone()), + ..compacted_item + }; { let mut state = self.state.lock().await; state.replace_history(items, reference_context_item.clone()); diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 8fec6f97dc..436b85907d 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -201,7 +201,7 @@ fn user_message(text: &str) -> ResponseItem { #[test] fn assign_missing_response_item_ids_skips_agent_messages() { - let mut items = Cow::Owned(vec![ + let items = Cow::Owned(vec![ ResponseItem::AgentMessage { id: None, author: "worker".to_string(), @@ -214,7 +214,7 @@ fn assign_missing_response_item_ids_skips_agent_messages() { user_message("hello"), ]); - Session::assign_missing_response_item_ids(&mut items); + let items = Session::assign_missing_response_item_ids(items); assert_eq!(items[0].id(), None); assert!(items[1].id().is_some_and(|id| id.starts_with("msg_"))); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index dff440a305..b88960f95e 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -300,6 +300,63 @@ async fn response_item_ids_persist_across_resume_and_preserve_server_ids() -> an Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn response_item_ids_are_sent_for_all_remote_v2_compaction_requests() -> anyhow::Result<()> { + let server = MockServer::start().await; + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + sse(vec![ + json!({ + "type": "response.output_item.done", + "item": { + "type": "compaction", + "encrypted_content": "ENCRYPTED_CONTEXT_COMPACTION_SUMMARY", + } + }), + ev_completed("resp-compact"), + ]), + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ], + ) + .await; + let test = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + let _ = config.features.enable(Feature::ItemIds); + let _ = config.features.enable(Feature::RemoteCompactionV2); + }) + .build(&server) + .await?; + + test.submit_turn("before compaction").await?; + test.codex.submit(Op::Compact).await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + test.submit_turn("after compaction").await?; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 3); + for (request_index, request) in requests.iter().enumerate() { + let input = request.input(); + assert!(!input.is_empty(), "request {request_index} input is empty"); + for item in input { + if item.get("type").and_then(serde_json::Value::as_str) == Some("compaction_trigger") { + continue; + } + assert!( + item.get("id").and_then(serde_json::Value::as_str).is_some(), + "request {request_index} item should have an ID: {item:#?}" + ); + } + } + + Ok(()) +} + /// Writes an `auth.json` into the provided `codex_home` with the specified parameters. /// Returns the fake JWT string written to `tokens.id_token`. #[expect(clippy::unwrap_used)] From 4e6bc4226658b7bc2ba4e207506b51f8d6d3626f Mon Sep 17 00:00:00 2001 From: xl-openai Date: Thu, 18 Jun 2026 21:17:20 -0700 Subject: [PATCH 065/112] [codex] Support protected resource OAuth discovery (#29022) ## Why Plugin-install preflight and the actual OAuth login flow used different discovery implementations. Preflight had a Codex-specific implementation that only queried authorization-server metadata on the MCP host, while login already used the upstream `rmcp` Rust MCP SDK. As a result, servers that advertise a separate authorization server through RFC 9728 Protected Resource Metadata were classified as OAuth-unsupported during plugin installation, so login was skipped. ## What changed - delegate plugin-install OAuth discovery to `rmcp::transport::AuthorizationManager`, the same implementation used by the login flow - let `rmcp` follow Protected Resource Metadata first and perform direct RFC 8414 authorization-server discovery when protected-resource discovery does not yield usable metadata - retain Codex's existing HTTP headers, timeout, `no_proxy` behavior, and scope normalization around that discovery - add unit coverage and a pure-MCP plugin-install integration test that proves the protected-resource path reaches OAuth client registration This only changes shared MCP OAuth discovery. App declarations and `appsNeedingAuth` behavior are unchanged. ## Verification - `just test -p codex-rmcp-client auth_status` - `just test -p codex-app-server plugin_install_starts_mcp_oauth` - real plugin-install smoke test with an isolated `CODEX_HOME`: both DigitalOcean MCP servers started OAuth callback listeners, while Linear continued to start its existing direct-discovery OAuth flow --- .../tests/suite/v2/plugin_install.rs | 91 ++++++++++ codex-rs/rmcp-client/src/auth_status.rs | 158 ++++++++---------- 2 files changed, 163 insertions(+), 86 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 3fc40d2dce..aebc30dfea 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -1330,6 +1330,97 @@ async fn plugin_install_starts_mcp_oauth_with_formerly_disallowed_plugin_app() - Ok(()) } +#[tokio::test] +async fn plugin_install_starts_mcp_oauth_through_protected_resource_metadata() -> Result<()> { + let resource_server = MockServer::start().await; + let authorization_server = MockServer::start().await; + let resource_metadata_url = format!("{}/oauth-resource", resource_server.uri()); + let challenge = format!("Bearer resource_metadata=\"{resource_metadata_url}\""); + Mock::given(method("GET")) + .and(path("/mcp")) + .respond_with( + ResponseTemplate::new(401).insert_header("WWW-Authenticate", challenge.as_str()), + ) + .mount(&resource_server) + .await; + Mock::given(method("GET")) + .and(path("/oauth-resource")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "resource": resource_server.uri(), + "authorization_servers": [authorization_server.uri()], + }))) + .mount(&resource_server) + .await; + Mock::given(method("GET")) + .and(path("/.well-known/oauth-authorization-server")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "authorization_endpoint": format!("{}/oauth/authorize", authorization_server.uri()), + "token_endpoint": format!("{}/oauth/token", authorization_server.uri()), + "registration_endpoint": format!("{}/oauth/register", authorization_server.uri()), + "response_types_supported": ["code"], + "code_challenge_methods_supported": ["S256"], + }))) + .mount(&authorization_server) + .await; + Mock::given(method("POST")) + .and(path("/oauth/register")) + .respond_with(ResponseTemplate::new(400)) + .mount(&authorization_server) + .await; + + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + "[features]\nplugins = true\n", + )?; + let repo_root = TempDir::new()?; + write_plugin_marketplace( + repo_root.path(), + "debug", + "sample-plugin", + "./sample-plugin", + /*install_policy*/ None, + /*auth_policy*/ None, + )?; + write_plugin_source(repo_root.path(), "sample-plugin", &[])?; + write_plugin_mcp_config(repo_root.path(), "sample-plugin", &resource_server.uri())?; + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, + plugin_name: "sample-plugin".to_string(), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let _: PluginInstallResponse = to_response(response)?; + wait_for_remote_plugin_request_count( + &authorization_server, + "POST", + "/oauth/register", + /*expected_count*/ 1, + ) + .await?; + + let resource_metadata_requested = resource_server + .received_requests() + .await + .unwrap_or_default() + .iter() + .any(|request| request.url.path() == "/oauth-resource"); + assert!(resource_metadata_requested); + Ok(()) +} + #[tokio::test] async fn plugin_install_starts_mcp_oauth_for_api_key_dual_surface_plugin() -> Result<()> { let oauth_server = MockServer::start().await; diff --git a/codex-rs/rmcp-client/src/auth_status.rs b/codex-rs/rmcp-client/src/auth_status.rs index cdb0a69bba..b2bd834d59 100644 --- a/codex-rs/rmcp-client/src/auth_status.rs +++ b/codex-rs/rmcp-client/src/auth_status.rs @@ -1,15 +1,14 @@ use std::collections::HashMap; use std::time::Duration; -use anyhow::Error; use anyhow::Result; use codex_protocol::protocol::McpAuthStatus; +use futures::FutureExt; use reqwest::Client; -use reqwest::StatusCode; -use reqwest::Url; use reqwest::header::AUTHORIZATION; use reqwest::header::HeaderMap; -use serde::Deserialize; +use rmcp::transport::AuthorizationManager; +use rmcp::transport::auth::AuthError; use tracing::debug; use crate::oauth::StoredOAuthTokenStatus; @@ -20,8 +19,6 @@ use codex_config::types::AuthKeyringBackendKind; use codex_config::types::OAuthCredentialsStoreMode; const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5); -const OAUTH_DISCOVERY_HEADER: &str = "MCP-Protocol-Version"; -const OAUTH_DISCOVERY_VERSION: &str = "2024-11-05"; #[derive(Debug, Clone, PartialEq, Eq)] pub struct StreamableHttpOAuthDiscovery { @@ -89,65 +86,19 @@ async fn discover_streamable_http_oauth_with_headers( url: &str, default_headers: &HeaderMap, ) -> Result> { - let base_url = Url::parse(url)?; - // Use no_proxy to avoid a bug in the system-configuration crate that // can result in a panic. See #8912. let builder = Client::builder().timeout(DISCOVERY_TIMEOUT).no_proxy(); let client = apply_default_headers(builder, default_headers).build()?; - - let mut last_error: Option = None; - for candidate_path in discovery_paths(base_url.path()) { - let mut discovery_url = base_url.clone(); - discovery_url.set_path(&candidate_path); - - let response = match client - .get(discovery_url.clone()) - .header(OAUTH_DISCOVERY_HEADER, OAUTH_DISCOVERY_VERSION) - .send() - .await - { - Ok(response) => response, - Err(err) => { - last_error = Some(err.into()); - continue; - } - }; - - if response.status() != StatusCode::OK { - continue; - } - - let metadata = match response.json::().await { - Ok(metadata) => metadata, - Err(err) => { - last_error = Some(err.into()); - continue; - } - }; - - if metadata.authorization_endpoint.is_some() && metadata.token_endpoint.is_some() { - return Ok(Some(StreamableHttpOAuthDiscovery { - scopes_supported: normalize_scopes(metadata.scopes_supported), - })); - } + let mut authorization_manager = AuthorizationManager::new(url).await?; + authorization_manager.with_client(client)?; + match authorization_manager.discover_metadata().boxed().await { + Ok(metadata) => Ok(Some(StreamableHttpOAuthDiscovery { + scopes_supported: normalize_scopes(metadata.scopes_supported), + })), + Err(AuthError::NoAuthorizationSupport) => Ok(None), + Err(err) => Err(err.into()), } - - if let Some(err) = last_error { - debug!("OAuth discovery requests failed for {url}: {err:?}"); - } - - Ok(None) -} - -#[derive(Debug, Deserialize)] -struct OAuthDiscoveryMetadata { - #[serde(default)] - authorization_endpoint: Option, - #[serde(default)] - token_endpoint: Option, - #[serde(default)] - scopes_supported: Option>, } fn normalize_scopes(scopes_supported: Option>) -> Option> { @@ -172,37 +123,13 @@ fn normalize_scopes(scopes_supported: Option>) -> Option } } -/// Implements RFC 8414 section 3.1 for discovering well-known oauth endpoints. -/// This is a requirement for MCP servers to support OAuth. -/// https://datatracker.ietf.org/doc/html/rfc8414#section-3.1 -/// https://github.com/modelcontextprotocol/rust-sdk/blob/main/crates/rmcp/src/transport/auth.rs#L182 -fn discovery_paths(base_path: &str) -> Vec { - let trimmed = base_path.trim_start_matches('/').trim_end_matches('/'); - let canonical = "/.well-known/oauth-authorization-server".to_string(); - - if trimmed.is_empty() { - return vec![canonical]; - } - - let mut candidates = Vec::new(); - let mut push_unique = |candidate: String| { - if !candidates.contains(&candidate) { - candidates.push(candidate); - } - }; - - push_unique(format!("{canonical}/{trimmed}")); - push_unique(format!("/{trimmed}/.well-known/oauth-authorization-server")); - push_unique(canonical); - - candidates -} - #[cfg(test)] mod tests { use super::*; use axum::Json; use axum::Router; + use axum::http::StatusCode; + use axum::http::header::WWW_AUTHENTICATE; use axum::routing::get; use pretty_assertions::assert_eq; use serial_test::serial; @@ -344,6 +271,65 @@ mod tests { ); } + #[tokio::test] + async fn discover_streamable_http_oauth_follows_protected_resource_metadata() { + let authorization_server = spawn_oauth_discovery_server(serde_json::json!({ + "authorization_endpoint": "https://example.com/authorize", + "token_endpoint": "https://example.com/token", + "scopes_supported": ["read", " write ", "read"], + })) + .await; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener should have address"); + let resource_metadata_url = format!("http://{address}/oauth-resource"); + let challenge = format!("Bearer resource_metadata=\"{resource_metadata_url}\""); + let authorization_server_url = authorization_server.url.clone(); + let app = Router::new() + .route( + "/mcp", + get(move || { + let challenge = challenge.clone(); + async move { (StatusCode::UNAUTHORIZED, [(WWW_AUTHENTICATE, challenge)]) } + }), + ) + .route( + "/oauth-resource", + get(move || { + let authorization_server_url = authorization_server_url.clone(); + async move { + Json(serde_json::json!({ + "resource": format!("http://{address}/mcp"), + "authorization_servers": [authorization_server_url], + })) + } + }), + ); + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.expect("server should run"); + }); + let resource_server = TestServer { + url: format!("http://{address}/mcp"), + handle, + }; + + let discovery = discover_streamable_http_oauth( + &resource_server.url, + /*http_headers*/ None, + /*env_http_headers*/ None, + ) + .await + .expect("discovery should succeed") + .expect("oauth support should be detected"); + + assert_eq!( + discovery.scopes_supported, + Some(vec!["read".to_string(), "write".to_string()]) + ); + } + #[tokio::test] async fn discover_streamable_http_oauth_ignores_empty_scopes() { let server = spawn_oauth_discovery_server(serde_json::json!({ From 41988e6a2442730639e71cf2a8b6e768737d1bb0 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Thu, 18 Jun 2026 21:50:15 -0700 Subject: [PATCH 066/112] [1/3] core: add remote environment connection lifecycle (#28674) ## Why Remote environments can be registered before their exec-server is first used. Starting the connection at registration time uses that startup window, while sharing one startup result prevents background work and capability calls from opening competing connections. Keep initial startup simple: each environment makes one connection attempt using its configured transport timeout. A failed initial attempt is final for that environment, while an environment that disconnects after connecting can still recover on a later operation. ## What changed - Start URL and Noise environments in the background when they are added to `EnvironmentManager`. Provider snapshots are fully validated before connection work begins. - Share one initial connection attempt and its saved result across metadata, process, filesystem, and HTTP callers. - Keep configured stdio environments lazy until first use so registration does not launch a process. - Tie background startup work to the environment lifetime so replacing or dropping an environment cancels unfinished work. - After an established client disconnects, share one fresh connection attempt across concurrent callers. A failed attempt fails the current operation without permanently preventing a later attempt. - Store the shared lazy client directly on `Environment` and expose small methods for starting, observing, and awaiting startup. ## Test plan - `just test -p codex-exec-server` - `just test -p codex-app-server turn_start_resolves_sticky_thread_local_environment_and_turn_overrides` --- codex-rs/exec-server/src/client.rs | 323 ++++++++++++++++++++++-- codex-rs/exec-server/src/environment.rs | 228 +++++++++++++---- codex-rs/exec-server/tests/relay.rs | 65 ----- 3 files changed, 469 insertions(+), 147 deletions(-) diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index 325fb74420..88c0c80342 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -14,9 +14,10 @@ use futures::FutureExt; use futures::future::BoxFuture; use serde_json::Value; use tokio::sync::Mutex; -use tokio::sync::Semaphore; +use tokio::sync::OnceCell; use tokio::sync::mpsc; use tokio::sync::watch; +use tokio_util::task::AbortOnDropHandle; use tokio::time::timeout; use tracing::debug; @@ -227,54 +228,132 @@ impl Drop for ActiveProcessStart { } } +type ConnectionResult = Result>; +type ConnectionAttempt = OnceCell; + #[derive(Clone)] pub(crate) struct LazyRemoteExecServerClient { transport_params: ExecServerTransportParams, - client: Arc>>, - connect_lock: Arc, + // Saves the first startup result so callers share it and failures remain final. + startup: Arc, + // The latest successful client, replaced whenever reconnecting succeeds. + current_client: Arc>>, + reconnect: Arc>>>, } impl LazyRemoteExecServerClient { pub(crate) fn new(transport_params: ExecServerTransportParams) -> Self { Self { transport_params, - client: Arc::new(StdMutex::new(None)), - connect_lock: Arc::new(Semaphore::new(/*permits*/ 1)), + startup: Arc::new(ConnectionAttempt::new()), + current_client: Arc::new(StdMutex::new(None)), + reconnect: Arc::new(StdMutex::new(None)), } } + pub(crate) fn start_connecting(&self) -> Option> { + // Stdio starts a process, so keep it lazy until the environment is used. + if matches!( + self.transport_params, + ExecServerTransportParams::StdioCommand { .. } + ) { + return None; + } + let client = self.clone(); + Some(AbortOnDropHandle::new(tokio::spawn(async move { + if let Err(error) = client.wait_until_ready().await { + debug!(%error, "exec-server environment startup failed"); + } + }))) + } + + pub(crate) fn startup_finished(&self) -> bool { + self.startup.get().is_some() + } + + pub(crate) async fn wait_until_ready(&self) -> Result<(), ExecServerError> { + self.initial_client().await.map(drop) + } + pub(crate) async fn get(&self) -> Result { if let Some(client) = self.connected_client() { return Ok(client); } - let _connect_permit = self.connect_lock.acquire().await.map_err(|_| { - ExecServerError::Protocol("exec-server connect lock closed".to_string()) - })?; - if let Some(client) = self.connected_client() { - return Ok(client); - } - - let next_client = match self.cached_client() { - Some(_client) - if matches!( - &self.transport_params, - ExecServerTransportParams::WebSocketUrl { .. } - | ExecServerTransportParams::NoiseRendezvous { .. } - ) => - { - ExecServerClient::connect_for_transport(self.transport_params.clone()).await? + let Some(cached_client) = self.cached_client() else { + let client = self.initial_client().await?; + if !client.is_disconnected() || !self.can_reconnect() { + return Ok(client); } - Some(client) => return Ok(client), - None => ExecServerClient::connect_for_transport(self.transport_params.clone()).await?, + return self.reconnect().await; }; - let mut cached_client = self - .client + if !self.can_reconnect() { + return Ok(cached_client); + } + + self.reconnect().await + } + + async fn initial_client(&self) -> Result { + // The first caller starts the work; every other caller waits for that same result. + let result = self + .startup + .get_or_init(|| connect_once(self.transport_params.clone())) + .await; + match result { + Ok(client) => { + let mut current_client = self + .current_client + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if current_client.is_none() { + *current_client = Some(client.clone()); + } + Ok(client.clone()) + } + Err(error) => Err(ExecServerError::ConnectionAttempt(Arc::clone(error))), + } + } + + async fn reconnect(&self) -> Result { + // Callers handling the same outage share one reconnect attempt. + let attempt = { + let mut reconnect = self + .reconnect + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(client) = self.connected_client() { + return Ok(client); + } + reconnect + .get_or_insert_with(|| Arc::new(ConnectionAttempt::new())) + .clone() + }; + let result = attempt + .get_or_init(|| async { + let result = connect_once(self.transport_params.clone()).await; + if let Ok(client) = &result { + *self + .current_client + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(client.clone()); + } + result + }) + .await; + let mut reconnect = self + .reconnect .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - *cached_client = Some(next_client.clone()); - Ok(next_client) + // Forget only this completed attempt so a later operation can retry after failure. + if reconnect + .as_ref() + .is_some_and(|current| Arc::ptr_eq(current, &attempt)) + { + *reconnect = None; + } + result.clone().map_err(ExecServerError::ConnectionAttempt) } fn connected_client(&self) -> Option { @@ -283,11 +362,25 @@ impl LazyRemoteExecServerClient { } fn cached_client(&self) -> Option { - self.client + self.current_client .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone() } + + fn can_reconnect(&self) -> bool { + matches!( + self.transport_params, + ExecServerTransportParams::WebSocketUrl { .. } + | ExecServerTransportParams::NoiseRendezvous { .. } + ) + } +} + +async fn connect_once(transport_params: ExecServerTransportParams) -> ConnectionResult { + ExecServerClient::connect_for_transport(transport_params) + .await + .map_err(Arc::new) } impl HttpClient for LazyRemoteExecServerClient { @@ -353,6 +446,8 @@ pub enum ExecServerError { EnvironmentRegistryAuth(String), #[error("environment registry request failed: {0}")] EnvironmentRegistryRequest(#[from] reqwest::Error), + #[error("exec-server connection attempt failed: {0}")] + ConnectionAttempt(#[source] Arc), } impl ExecServerClient { @@ -1112,6 +1207,7 @@ mod tests { use tokio::net::TcpStream; use tokio::sync::mpsc; use tokio::sync::oneshot; + use tokio::sync::watch; use tokio::time::Duration; #[cfg(unix)] use tokio::time::sleep; @@ -1919,6 +2015,177 @@ mod tests { server.await.expect("server task should finish"); } + #[tokio::test] + async fn initial_connection_is_shared_by_all_waiters() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let websocket_url = format!( + "ws://{}", + listener.local_addr().expect("listener should have address") + ); + let server = tokio::spawn(async move { + let mut connection = accept_websocket(&listener).await; + complete_websocket_initialize( + &mut connection, + "startup-session", + /*expected_resume_session_id*/ None, + ) + .await; + timeout(Duration::from_secs(1), connection.next()) + .await + .expect("client should close after the test"); + }); + let client = LazyRemoteExecServerClient::new(ExecServerTransportParams::WebSocketUrl { + websocket_url, + connect_timeout: Duration::from_secs(1), + initialize_timeout: Duration::from_secs(1), + }); + + assert!(!client.startup_finished()); + let _startup_task = client.start_connecting(); + let (ready, first, second) = + tokio::join!(client.wait_until_ready(), client.get(), client.get()); + ready.expect("background startup should finish"); + let first = first.expect("first waiter should receive the client"); + let second = second.expect("second waiter should receive the same client"); + + assert!(client.startup_finished()); + assert_eq!(first.session_id().as_deref(), Some("startup-session")); + assert!(Arc::ptr_eq(&first.inner, &second.inner)); + + drop(first); + drop(second); + drop(client); + server.await.expect("server task should finish"); + } + + #[tokio::test] + async fn terminal_stdio_startup_failure_is_remembered() { + let client = LazyRemoteExecServerClient::new(ExecServerTransportParams::StdioCommand { + command: StdioExecServerCommand { + program: "codex-missing-exec-server-for-test".to_string(), + args: Vec::new(), + env: HashMap::new(), + cwd: None, + }, + initialize_timeout: Duration::from_secs(1), + }); + + assert!(client.start_connecting().is_none()); + assert!(!client.startup_finished()); + let first = match client.get().await { + Ok(_) => panic!("missing executable should fail"), + Err(error) => error, + }; + assert!(client.startup_finished()); + let second = match client.get().await { + Ok(_) => panic!("burned environment should stay failed"), + Err(error) => error, + }; + + let ( + super::ExecServerError::ConnectionAttempt(first), + super::ExecServerError::ConnectionAttempt(second), + ) = (first, second) + else { + panic!("expected saved connection failures"); + }; + assert!(Arc::ptr_eq(&first, &second)); + } + + #[tokio::test] + async fn failed_reconnect_does_not_burn_environment() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let websocket_url = format!( + "ws://{}", + listener.local_addr().expect("listener should have address") + ); + let (replacement_initialized_tx, replacement_initialized_rx) = oneshot::channel(); + let (allow_replacement_tx, allow_replacement_rx) = watch::channel(false); + let server = tokio::spawn(async move { + let mut first = accept_websocket(&listener).await; + complete_websocket_initialize( + &mut first, + "startup-session", + /*expected_resume_session_id*/ None, + ) + .await; + first + .close(None) + .await + .expect("startup websocket should close"); + + let successful_reconnect = loop { + let (stream, _) = listener.accept().await.expect("reconnect should arrive"); + if *allow_replacement_rx.borrow() { + break stream; + } + let mut failed_reconnect = stream; + failed_reconnect + .write_all(b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n") + .await + .expect("failed handshake response should write"); + }; + let mut successful_reconnect = accept_async(successful_reconnect) + .await + .expect("replacement websocket handshake should succeed"); + complete_websocket_initialize( + &mut successful_reconnect, + "replacement-session", + /*expected_resume_session_id*/ None, + ) + .await; + replacement_initialized_tx + .send(()) + .expect("replacement initialization should be observed"); + timeout(Duration::from_secs(1), successful_reconnect.next()) + .await + .expect("client should close after the test"); + }); + let client = LazyRemoteExecServerClient::new(ExecServerTransportParams::WebSocketUrl { + websocket_url, + connect_timeout: Duration::from_secs(1), + initialize_timeout: Duration::from_secs(1), + }); + + let initial = client.get().await.expect("startup should connect"); + timeout(Duration::from_secs(1), async { + while !initial.is_disconnected() { + tokio::task::yield_now().await; + } + }) + .await + .expect("client should observe disconnect"); + let failed_reconnect = match client.get().await { + Ok(_) => panic!("first lazy reconnect should fail"), + Err(error) => error, + }; + assert!(matches!( + failed_reconnect, + super::ExecServerError::ConnectionAttempt(_) + )); + allow_replacement_tx + .send(true) + .expect("server should allow a fresh client"); + let replacement = client.get().await.expect("later reconnect should succeed"); + + assert_eq!( + replacement.session_id().as_deref(), + Some("replacement-session") + ); + replacement_initialized_rx + .await + .expect("server should observe replacement initialization"); + + drop(initial); + drop(replacement); + drop(client); + server.await.expect("server task should finish"); + } + #[tokio::test] async fn wake_notifications_do_not_block_other_sessions() { let (client_stdin, server_reader) = duplex(1 << 20); diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 6159fb2dd2..03d0d996c7 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -1,10 +1,8 @@ use std::collections::HashMap; use std::sync::Arc; +use std::sync::Mutex; use std::sync::RwLock; -use futures::FutureExt; -use futures::future::BoxFuture; - use crate::ExecServerError; use crate::ExecServerRuntimePaths; use crate::ExecutorFileSystem; @@ -29,6 +27,7 @@ use crate::remote::NoiseRendezvousEnvironmentConfig; use crate::remote_file_system::RemoteFileSystem; use crate::remote_process::RemoteProcess; use codex_shell_command::shell_detect::DetectedShell; +use tokio_util::task::AbortOnDropHandle; pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL"; pub const CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR: &str = @@ -50,9 +49,9 @@ pub const CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID_ENV_VAR: &str = /// use `default_environment().is_some()` as the signal for model-facing /// shell/filesystem tool availability. /// -/// Remote environments create remote filesystem and execution backends that -/// lazy-connect to the configured exec-server on first use. The remote -/// transport is not opened when the manager or environment is constructed. +/// Remote environments begin connecting when added to the manager. Their +/// filesystem and execution backends share that startup result and reconnect +/// after later disconnects as needed. #[derive(Debug)] pub struct EnvironmentManager { default_environment: Option, @@ -224,6 +223,10 @@ impl EnvironmentManager { Some(environment_id) } }; + // The snapshot is valid; start connecting its remote environments in the background. + for environment in environment_map.values() { + environment.start_connecting(); + } Ok(Self { default_environment, environments: RwLock::new(environment_map), @@ -307,12 +310,15 @@ impl EnvironmentManager { "remote environment requires an exec-server url".to_string(), )); }; - let environment = - Environment::remote_inner(exec_server_url, self.local_runtime_paths.clone()); + let environment = Arc::new(Environment::remote_inner( + exec_server_url, + self.local_runtime_paths.clone(), + )); + environment.start_connecting(); self.environments .write() .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert(environment_id, Arc::new(environment)); + .insert(environment_id, environment); Ok(()) } @@ -336,14 +342,15 @@ impl EnvironmentManager { "failed to generate Noise harness identity: {error}" )) })?; - let environment = Environment::remote_with_transport( + let environment = Arc::new(Environment::remote_with_transport( ExecServerTransportParams::NoiseRendezvous { provider, identity }, self.local_runtime_paths.clone(), - ); + )); + environment.start_connecting(); self.environments .write() .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert(environment_id, Arc::new(environment)); + .insert(environment_id, environment); Ok(()) } } @@ -402,50 +409,22 @@ fn optional_environment_value(name: &str) -> Option { #[derive(Clone)] pub struct Environment { exec_server_url: Option, - remote_transport: Option, - info_provider: Arc, + remote_client: Option, + // Dropping the environment stops unfinished background startup work. + startup_task: Arc>>>, exec_backend: Arc, filesystem: Arc, http_client: Arc, local_runtime_paths: Option, } -/// Provides environment metadata from either a local environment or a remote exec-server. -trait EnvironmentInfoProvider: Send + Sync { - fn info(&self) -> BoxFuture<'_, Result>; -} - -struct LocalEnvironmentInfoProvider; - -impl EnvironmentInfoProvider for LocalEnvironmentInfoProvider { - fn info(&self) -> BoxFuture<'_, Result> { - std::future::ready(Ok(EnvironmentInfo::local())).boxed() - } -} - -struct RemoteEnvironmentInfoProvider { - client: LazyRemoteExecServerClient, -} - -impl RemoteEnvironmentInfoProvider { - fn new(client: LazyRemoteExecServerClient) -> Self { - Self { client } - } -} - -impl EnvironmentInfoProvider for RemoteEnvironmentInfoProvider { - fn info(&self) -> BoxFuture<'_, Result> { - async move { self.client.environment_info().await }.boxed() - } -} - impl Environment { /// Builds a test-only local environment without configured sandbox helper paths. pub fn default_for_tests() -> Self { Self { exec_server_url: None, - remote_transport: None, - info_provider: Arc::new(LocalEnvironmentInfoProvider), + remote_client: None, + startup_task: Arc::new(Mutex::new(None)), exec_backend: Arc::new(LocalProcess::default()), filesystem: Arc::new(LocalFileSystem::unsandboxed()), http_client: Arc::new(ReqwestHttpClient), @@ -501,8 +480,8 @@ impl Environment { pub(crate) fn local(local_runtime_paths: ExecServerRuntimePaths) -> Self { Self { exec_server_url: None, - remote_transport: None, - info_provider: Arc::new(LocalEnvironmentInfoProvider), + remote_client: None, + startup_task: Arc::new(Mutex::new(None)), exec_backend: Arc::new(LocalProcess::default()), filesystem: Arc::new(LocalFileSystem::with_runtime_paths( local_runtime_paths.clone(), @@ -534,15 +513,15 @@ impl Environment { ExecServerTransportParams::NoiseRendezvous { .. } => None, ExecServerTransportParams::StdioCommand { .. } => None, }; - let client = LazyRemoteExecServerClient::new(remote_transport.clone()); + let client = LazyRemoteExecServerClient::new(remote_transport); let exec_backend: Arc = Arc::new(RemoteProcess::new(client.clone())); let filesystem: Arc = Arc::new(RemoteFileSystem::new(client.clone())); Self { exec_server_url, - remote_transport: Some(remote_transport), - info_provider: Arc::new(RemoteEnvironmentInfoProvider::new(client.clone())), + remote_client: Some(client.clone()), + startup_task: Arc::new(Mutex::new(None)), exec_backend, filesystem, http_client: Arc::new(client), @@ -551,7 +530,7 @@ impl Environment { } pub fn is_remote(&self) -> bool { - self.remote_transport.is_some() + self.remote_client.is_some() } /// Returns the remote exec-server URL when this environment is remote. @@ -565,7 +544,40 @@ impl Environment { /// Returns environment information from the selected execution/filesystem environment. pub async fn info(&self) -> Result { - self.info_provider.info().await + match &self.remote_client { + Some(client) => client.environment_info().await, + None => Ok(EnvironmentInfo::local()), + } + } + + /// Starts connecting a remote environment without waiting for it. + /// Requires an active Tokio runtime when background startup is supported. + pub fn start_connecting(&self) { + let Some(client) = &self.remote_client else { + return; + }; + let mut startup_task = self + .startup_task + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if startup_task.is_none() { + *startup_task = client.start_connecting(); + } + } + + /// Returns whether initial startup has either succeeded or permanently failed. + pub fn startup_finished(&self) -> bool { + self.remote_client + .as_ref() + .is_none_or(LazyRemoteExecServerClient::startup_finished) + } + + /// Waits for initial startup. A failed startup is never attempted again. + pub async fn wait_until_ready(&self) -> Result<(), ExecServerError> { + match &self.remote_client { + Some(client) => client.wait_until_ready().await, + None => Ok(()), + } } pub fn get_exec_backend(&self) -> Arc { @@ -600,7 +612,9 @@ impl From for ShellInfo { #[cfg(test)] mod tests { + use std::collections::HashMap; use std::sync::Arc; + use std::time::Duration; use super::Environment; use super::EnvironmentManager; @@ -609,10 +623,14 @@ mod tests { use super::noise_environment_config_from_values; use crate::ExecServerRuntimePaths; use crate::ProcessId; + use crate::client_api::ExecServerTransportParams; + use crate::client_api::StdioExecServerCommand; use crate::environment_provider::EnvironmentDefault; use crate::environment_provider::EnvironmentProviderSnapshot; use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; + use tokio::net::TcpListener; + use tokio::time::timeout; fn test_runtime_paths() -> ExecServerRuntimePaths { ExecServerRuntimePaths::new( @@ -626,8 +644,8 @@ mod tests { assert!(manager.try_local_environment().is_none()); } - #[test] - fn noise_environment_config_selects_remote_as_default() { + #[tokio::test] + async fn noise_environment_config_selects_remote_as_default() { let config = noise_environment_config_from_values( Some("http://registry.example/api".to_string()), Some("environment-requested".to_string()), @@ -982,6 +1000,108 @@ mod tests { assert!(!Arc::ptr_eq(&first, &second)); } + #[tokio::test] + async fn environment_manager_starts_remote_environment_when_upserted() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind websocket listener"); + let manager = EnvironmentManager::without_environments(); + + manager + .upsert_environment( + "executor-a".to_string(), + format!("ws://{}", listener.local_addr().expect("listener address")), + ) + .expect("remote environment"); + + timeout(Duration::from_secs(5), listener.accept()) + .await + .expect("environment should start connecting when registered") + .expect("accept connection"); + } + + #[tokio::test] + async fn environment_manager_leaves_stdio_environment_lazy() { + let environment = Environment::remote_with_transport( + ExecServerTransportParams::StdioCommand { + command: StdioExecServerCommand { + program: "codex-missing-exec-server-for-test".to_string(), + args: Vec::new(), + env: HashMap::new(), + cwd: None, + }, + initialize_timeout: Duration::from_secs(1), + }, + /*local_runtime_paths*/ None, + ); + let manager = EnvironmentManager::from_snapshot( + EnvironmentProviderSnapshot { + environments: vec![("stdio".to_string(), environment)], + default: EnvironmentDefault::Disabled, + include_local: false, + }, + /*local_runtime_paths*/ None, + ) + .expect("environment manager"); + let environment = manager.get_environment("stdio").expect("stdio environment"); + + assert!(!environment.startup_finished()); + assert!(environment.wait_until_ready().await.is_err()); + assert!(environment.startup_finished()); + } + + #[tokio::test] + async fn replacing_environment_stops_its_startup_task() { + let first_listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind first websocket listener"); + let second_listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind second websocket listener"); + let manager = EnvironmentManager::without_environments(); + manager + .upsert_environment( + "executor-a".to_string(), + format!( + "ws://{}", + first_listener.local_addr().expect("first listener address") + ), + ) + .expect("first remote environment"); + let environment = manager + .get_environment("executor-a") + .expect("first remote environment"); + let startup_abort = environment + .startup_task + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .expect("startup task") + .abort_handle(); + assert!(!startup_abort.is_finished()); + drop(environment); + + manager + .upsert_environment( + "executor-a".to_string(), + format!( + "ws://{}", + second_listener + .local_addr() + .expect("second listener address") + ), + ) + .expect("replacement remote environment"); + + timeout(Duration::from_secs(1), async { + while !startup_abort.is_finished() { + tokio::task::yield_now().await; + } + }) + .await + .expect("replacing the environment should cancel its startup task"); + } + #[tokio::test] async fn environment_manager_rejects_empty_remote_environment_url() { let manager = EnvironmentManager::without_environments(); diff --git a/codex-rs/exec-server/tests/relay.rs b/codex-rs/exec-server/tests/relay.rs index 114fe122a2..5e58618410 100644 --- a/codex-rs/exec-server/tests/relay.rs +++ b/codex-rs/exec-server/tests/relay.rs @@ -6,8 +6,6 @@ mod relay_proto; use std::collections::HashMap; use std::sync::Arc; use std::sync::Mutex; -use std::sync::atomic::AtomicUsize; -use std::sync::atomic::Ordering; use std::time::Duration; use anyhow::Context; @@ -15,25 +13,20 @@ use anyhow::Result; use base64::Engine as _; use base64::engine::general_purpose::STANDARD; use codex_api::AuthProvider; -use codex_exec_server::EnvironmentManager; use codex_exec_server::ExecParams; use codex_exec_server::ExecResponse; use codex_exec_server::ExecServerClient; -use codex_exec_server::ExecServerError; use codex_exec_server::ExecServerRuntimePaths; use codex_exec_server::FsReadFileParams; use codex_exec_server::NoiseChannelIdentity; use codex_exec_server::NoiseChannelPublicKey; use codex_exec_server::NoiseRendezvousConnectArgs; use codex_exec_server::NoiseRendezvousConnectBundle; -use codex_exec_server::NoiseRendezvousConnectProvider; use codex_exec_server::ProcessId; use codex_exec_server::RemoteEnvironmentConfig; use codex_utils_path_uri::PathUri; -use futures::FutureExt; use futures::SinkExt; use futures::StreamExt; -use futures::future::BoxFuture; use http::HeaderMap; use http::HeaderValue; use pretty_assertions::assert_eq; @@ -72,68 +65,10 @@ impl AuthProvider for StaticRegistryAuthProvider { } } -struct FailingNoiseConnectProvider { - attempts: Arc, -} - -impl NoiseRendezvousConnectProvider for FailingNoiseConnectProvider { - fn connect_bundle( - &self, - _: NoiseChannelPublicKey, - ) -> BoxFuture<'_, Result> { - self.attempts.fetch_add(1, Ordering::SeqCst); - async { - Err(ExecServerError::Protocol( - "test registry connect failure".to_string(), - )) - } - .boxed() - } -} - fn static_registry_auth_provider() -> codex_api::SharedAuthProvider { Arc::new(StaticRegistryAuthProvider) } -#[tokio::test] -async fn noise_environment_refreshes_bundle_for_each_connection_attempt() -> Result<()> { - let attempts = Arc::new(AtomicUsize::new(0)); - let manager = EnvironmentManager::without_environments(); - manager.upsert_noise_environment( - ENVIRONMENT_ID.to_string(), - Arc::new(FailingNoiseConnectProvider { - attempts: Arc::clone(&attempts), - }), - )?; - let backend = manager - .get_environment(ENVIRONMENT_ID) - .context("Noise environment should be materialized")? - .get_exec_backend(); - - for attempt in 1..=2 { - let result = backend - .start(ExecParams { - process_id: ProcessId::new(format!("proc-{attempt}")), - argv: vec!["true".to_string()], - cwd: PathUri::from_path(std::env::current_dir()?)?, - env_policy: None, - env: HashMap::new(), - tty: false, - pipe_stdin: false, - arg0: None, - }) - .await; - assert!(matches!( - result, - Err(ExecServerError::Protocol(ref message)) - if message == "test registry connect failure" - )); - } - - assert_eq!(attempts.load(Ordering::SeqCst), 2); - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn remote_environment_routes_encrypted_exec_server_rpc() -> Result<()> { let listener = TcpListener::bind("127.0.0.1:0").await?; From 45a133bae0ec14584aba62919bc86307e9f10088 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Thu, 18 Jun 2026 22:06:34 -0700 Subject: [PATCH 067/112] [2/3] core: track starting environments in snapshots (#28683) ## Why Remote environments may still be resolving when Codex creates a session or turn. Waiting for the existing all-or-nothing environment snapshot can hold startup until the selected environment is usable. Behind the default-off `deferred_executor` feature, let callers take a useful snapshot immediately: completed environments remain available normally, while unfinished environments are reported without blocking startup. With the feature disabled, snapshots preserve the existing blocking behavior. Depends on #28674. ## What changed - Store one ordered list of selected environments in `ThreadEnvironments`. Each selection owns one shared resolution that produces its complete `TurnEnvironment`. - Start new resolutions in the background with `remote_handle()`, allowing snapshots and the future wait tool to share the same result while cancellation follows the retained handles. - Make `snapshot()` a read-only operation: nonblocking snapshots collect completed resolutions and retain handles for unfinished ones, while blocking snapshots await every resolution. - Replace completed failed resolutions from the current manager entry and log when failed environments are omitted. - Return attached and starting environments as a point-in-time view, and count starting environments when deciding whether a snapshot is local-only. - Keep existing consumers attached-only. `to_selections()` derives from attached environments, so child threads do not inherit an environment that is still starting. ## Test plan - `just test -p codex-core environment_selection` - `just test -p codex-core deferred_executor_reaches_model_before_remote_environment_is_ready` ## Landing note Keep `deferred_executor` disabled for slow-starting executors until configurable `environment/add` connection timeouts and caller support land. When enabled, an environment that attaches after session startup may remain absent from environment-derived model context, tools, instructions, skills, and related state until follow-up refresh work lands. --- codex-rs/core/config.schema.json | 6 + codex-rs/core/src/agents_md_tests.rs | 1 + codex-rs/core/src/environment_selection.rs | 593 +++++++++++++++------ codex-rs/core/src/session/session.rs | 1 + codex-rs/core/src/session/tests.rs | 3 + codex-rs/core/tests/suite/remote_env.rs | 34 ++ codex-rs/features/src/lib.rs | 8 + 7 files changed, 490 insertions(+), 156 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 0568f20e5f..6eff342082 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -479,6 +479,9 @@ "default_mode_request_user_input": { "type": "boolean" }, + "deferred_executor": { + "type": "boolean" + }, "elevated_windows_sandbox": { "type": "boolean" }, @@ -4712,6 +4715,9 @@ "default_mode_request_user_input": { "type": "boolean" }, + "deferred_executor": { + "type": "boolean" + }, "elevated_windows_sandbox": { "type": "boolean" }, diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index 0e1a670fc2..0122910c3b 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -277,6 +277,7 @@ fn resolved_local_environments( ) }) .collect(), + starting: Vec::new(), } } diff --git a/codex-rs/core/src/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index 16b2c47b5d..0d82a147be 100644 --- a/codex-rs/core/src/environment_selection.rs +++ b/codex-rs/core/src/environment_selection.rs @@ -1,11 +1,12 @@ use std::collections::HashSet; +use std::fmt; use std::sync::Arc; use arc_swap::ArcSwap; +use codex_exec_server::Environment; use codex_exec_server::EnvironmentManager; +use codex_exec_server::ExecServerError; use codex_exec_server::ExecutorFileSystem; -use codex_protocol::error::CodexErr; -use codex_protocol::error::Result as CodexResult; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; @@ -31,13 +32,37 @@ pub(crate) fn default_thread_environment_selections( .collect() } -type SnapshotTask = Shared>; +type TurnEnvironmentResult = Result>; +type TurnEnvironmentResolution = Shared>; + +#[derive(Clone)] +struct SelectedTurnEnvironment { + selection: TurnEnvironmentSelection, + resolution: TurnEnvironmentResolution, +} + +#[derive(Clone)] +pub(crate) struct StartingTurnEnvironment { + pub(crate) selection: TurnEnvironmentSelection, + resolution: TurnEnvironmentResolution, +} + +impl fmt::Debug for StartingTurnEnvironment { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("StartingTurnEnvironment") + .field("selection", &self.selection) + .field("resolved", &self.resolution.peek().is_some()) + .finish_non_exhaustive() + } +} pub(crate) struct ThreadEnvironments { environment_manager: Arc, local_shell: Shell, shell_snapshot: ShellSnapshot, - snapshot_task: ArcSwap, + non_blocking_snapshots: bool, + environments: ArcSwap>, } impl ThreadEnvironments { @@ -46,132 +71,142 @@ impl ThreadEnvironments { local_shell: Shell, shell_snapshot: ShellSnapshot, current: TurnEnvironmentSnapshot, + non_blocking_snapshots: bool, ) -> Self { + // Reuse only attached environments from the supplied snapshot; drop starting entries. + let environments = current + .turn_environments + .into_iter() + .map(|environment| { + let selection = environment.selection(); + let resolution: TurnEnvironmentResolution = + futures::future::ready(Ok(environment)).boxed().shared(); + SelectedTurnEnvironment { + selection, + resolution, + } + }) + .collect(); Self { environment_manager, local_shell, shell_snapshot, - snapshot_task: ArcSwap::from_pointee(futures::future::ready(current).boxed().shared()), + non_blocking_snapshots, + environments: ArcSwap::from_pointee(environments), } } pub(crate) fn update_selections(&self, environments: &[TurnEnvironmentSelection]) { - let previous = self - .snapshot_task - .load() - .peek() - .cloned() - .unwrap_or_default(); - let environment_manager = Arc::clone(&self.environment_manager); - let local_shell = self.local_shell.clone(); - let shell_snapshot = self.shell_snapshot.clone(); - let environments = environments.to_vec(); - let (snapshot_task, snapshot) = async move { - Self::resolve_snapshot( - environment_manager, - local_shell, - shell_snapshot, - previous, - environments, - ) - .await - } - .remote_handle(); - self.snapshot_task - .store(Arc::new(snapshot.boxed().shared())); - drop(tokio::spawn(snapshot_task)); - } - - async fn resolve_snapshot( - environment_manager: Arc, - local_shell: Shell, - shell_snapshot: ShellSnapshot, - current: TurnEnvironmentSnapshot, - environments: Vec, - ) -> TurnEnvironmentSnapshot { + let previous = self.environments.load(); let mut seen_environment_ids = HashSet::with_capacity(environments.len()); - let mut turn_environments = Vec::with_capacity(environments.len()); - for selected_environment in &environments { + let mut next = Vec::with_capacity(environments.len()); + for selected_environment in environments { if !seen_environment_ids.insert(selected_environment.environment_id.as_str()) { continue; } - let turn_environment = match current.turn_environments.iter().find(|environment| { - environment.environment_id == selected_environment.environment_id - && environment.cwd() == &selected_environment.cwd - }) { - Some(environment) => environment.clone(), - None => match Self::resolve_selection( - &environment_manager, - &local_shell, - &shell_snapshot, - selected_environment, - ) - .await - { - Ok(environment) => environment, - Err(err) => { - tracing::warn!( - "skipping unresolved turn environment `{}`: {err}", - selected_environment.environment_id - ); - continue; - } - }, + if let Some(environment) = previous + .iter() + .find(|environment| environment.selection == *selected_environment) + && !matches!(environment.resolution.clone().now_or_never(), Some(Err(_))) + { + next.push(environment.clone()); + continue; + } + + let environment_id = &selected_environment.environment_id; + let Some(environment) = self.environment_manager.get_environment(environment_id) else { + tracing::warn!("skipping unknown turn environment `{environment_id}`"); + continue; }; - turn_environments.push(turn_environment); + let (resolution_task, resolution) = Self::resolve_environment( + selected_environment.clone(), + environment, + self.local_shell.clone(), + self.shell_snapshot.clone(), + ) + .remote_handle(); + drop(tokio::spawn(resolution_task)); + let resolution = resolution.boxed().shared(); + next.push(SelectedTurnEnvironment { + selection: selected_environment.clone(), + resolution, + }); } - TurnEnvironmentSnapshot { turn_environments } + self.environments.store(Arc::new(next)); } - async fn resolve_selection( - environment_manager: &EnvironmentManager, - local_shell: &Shell, - shell_snapshot: &ShellSnapshot, - selected_environment: &TurnEnvironmentSelection, - ) -> CodexResult { - let environment_id = selected_environment.environment_id.clone(); - let environment = environment_manager - .get_environment(&environment_id) - .ok_or_else(|| { - CodexErr::InvalidRequest(format!("unknown turn environment id `{environment_id}`")) - })?; - let shell = if environment.is_remote() { - match environment.info().await { - Ok(info) => match Shell::from_environment_shell_info(info.shell) { - Ok(shell) => Some(shell), + fn resolve_environment( + selection: TurnEnvironmentSelection, + environment: Arc, + local_shell: Shell, + shell_snapshot: ShellSnapshot, + ) -> BoxFuture<'static, TurnEnvironmentResult> { + async move { + let environment_id = &selection.environment_id; + if let Err(err) = environment.wait_until_ready().await { + tracing::warn!("turn environment `{environment_id}` failed to start: {err}"); + return Err(Arc::new(err)); + } + let shell = if environment.is_remote() { + match environment.info().await { + Ok(info) => match Shell::from_environment_shell_info(info.shell) { + Ok(shell) => Some(shell), + Err(err) => { + tracing::warn!( + "failed to resolve shell for environment `{environment_id}`: {err}" + ); + None + } + }, Err(err) => { tracing::warn!( - "failed to resolve shell for environment `{environment_id}`: {err}" + "failed to get info for environment `{environment_id}`: {err}" ); None } - }, - Err(err) => { - tracing::warn!("failed to get info for environment `{environment_id}`: {err}"); - None } - } - } else { - Some(local_shell.clone()) - }; - let mut turn_environment = TurnEnvironment::new( - environment_id, - environment, - selected_environment.cwd.clone(), - shell, - ); - let task = shell_snapshot - .clone() - .build(turn_environment.clone()) - .boxed() - .shared(); - drop(tokio::spawn(task.clone())); - turn_environment.shell_snapshot = task; - Ok(turn_environment) + } else { + Some(local_shell) + }; + let mut turn_environment = + TurnEnvironment::new(selection.environment_id, environment, selection.cwd, shell); + let task = shell_snapshot + .build(turn_environment.clone()) + .boxed() + .shared(); + drop(tokio::spawn(task.clone())); + turn_environment.shell_snapshot = task; + Ok(turn_environment) + } + .boxed() } pub(crate) async fn snapshot(&self) -> TurnEnvironmentSnapshot { - self.snapshot_task.load_full().as_ref().clone().await + let current = self.environments.load_full(); + let mut turn_environments = Vec::with_capacity(current.len()); + let mut starting = Vec::new(); + for environment in current.iter() { + let resolved = if self.non_blocking_snapshots { + environment.resolution.clone().now_or_never() + } else { + Some(environment.resolution.clone().await) + }; + match resolved { + Some(Ok(turn_environment)) => turn_environments.push(turn_environment), + Some(Err(err)) => tracing::debug!( + environment_id = %environment.selection.environment_id, + "skipping failed turn environment: {err}" + ), + None => starting.push(StartingTurnEnvironment { + selection: environment.selection.clone(), + resolution: environment.resolution.clone(), + }), + } + } + TurnEnvironmentSnapshot { + turn_environments, + starting, + } } pub(crate) fn environment_manager(&self) -> Arc { @@ -182,6 +217,7 @@ impl ThreadEnvironments { #[derive(Clone, Debug, Default)] pub(crate) struct TurnEnvironmentSnapshot { pub(crate) turn_environments: Vec, + pub(crate) starting: Vec, } impl TurnEnvironmentSnapshot { @@ -214,6 +250,9 @@ impl TurnEnvironmentSnapshot { } pub(crate) fn single_local_environment(&self) -> Option<&TurnEnvironment> { + if !self.starting.is_empty() { + return None; + } let [environment] = self.turn_environments.as_slice() else { return None; }; @@ -230,6 +269,8 @@ impl TurnEnvironmentSnapshot { #[cfg(test)] mod tests { + use std::time::Duration; + use codex_exec_server::Environment; use codex_exec_server::ExecServerRuntimePaths; use codex_exec_server::LOCAL_ENVIRONMENT_ID; @@ -237,7 +278,16 @@ mod tests { use codex_protocol::protocol::TurnEnvironmentSelection; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; + use futures::SinkExt; + use futures::StreamExt; use pretty_assertions::assert_eq; + use serde_json::Value; + use tokio::net::TcpListener; + use tokio::net::TcpStream; + use tokio::time::timeout; + use tokio_tungstenite::WebSocketStream; + use tokio_tungstenite::accept_async; + use tokio_tungstenite::tungstenite::Message; use super::*; @@ -250,6 +300,7 @@ mod tests { crate::shell::default_user_shell(), ShellSnapshot::disabled(), TurnEnvironmentSnapshot::default(), + /*non_blocking_snapshots*/ false, )); turn_environments.update_selections(selections); turn_environments.snapshot().await; @@ -264,6 +315,61 @@ mod tests { .expect("runtime paths") } + async fn read_websocket_json(websocket: &mut WebSocketStream) -> Value { + loop { + match timeout(std::time::Duration::from_secs(5), websocket.next()) + .await + .expect("websocket read should not time out") + .expect("websocket should stay open") + .expect("websocket frame should read") + { + Message::Text(text) => { + return serde_json::from_str(text.as_ref()).expect("valid JSON-RPC message"); + } + Message::Binary(bytes) => { + return serde_json::from_slice(bytes.as_ref()).expect("valid JSON-RPC message"); + } + Message::Ping(_) | Message::Pong(_) => {} + other => panic!("expected JSON-RPC message, got {other:?}"), + } + } + } + + async fn serve_environment_info(listener: TcpListener) { + let (stream, _) = listener.accept().await.expect("connection"); + let mut websocket = accept_async(stream).await.expect("websocket handshake"); + + let initialize = read_websocket_json(&mut websocket).await; + assert_eq!(initialize["method"], "initialize"); + websocket + .send(Message::Text( + serde_json::json!({ + "id": initialize["id"], + "result": { "sessionId": "test-session" } + }) + .to_string() + .into(), + )) + .await + .expect("initialize response"); + let initialized = read_websocket_json(&mut websocket).await; + assert_eq!(initialized["method"], "initialized"); + + let info = read_websocket_json(&mut websocket).await; + assert_eq!(info["method"], "environment/info"); + websocket + .send(Message::Text( + serde_json::json!({ + "id": info["id"], + "result": { "shell": { "name": "zsh", "path": "/bin/zsh" } } + }) + .to_string() + .into(), + )) + .await + .expect("environment info response"); + } + #[tokio::test] async fn default_thread_environment_selections_use_manager_default_id() { let cwd = AbsolutePathBuf::current_dir().expect("cwd"); @@ -340,6 +446,7 @@ url = "ws://127.0.0.1:8765" local_shell.clone(), ShellSnapshot::disabled(), TurnEnvironmentSnapshot::default(), + /*non_blocking_snapshots*/ false, ); turn_environments.update_selections(&[TurnEnvironmentSelection { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), @@ -448,8 +555,53 @@ url = "ws://127.0.0.1:8765" } #[tokio::test] - async fn latest_environment_update_wins_while_previous_resolution_is_pending() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + async fn blocking_snapshot_waits_for_starting_environment() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind websocket listener"); + let manager = Arc::new( + EnvironmentManager::create_for_tests( + Some(format!( + "ws://{}", + listener.local_addr().expect("listener address") + )), + Some(test_runtime_paths()), + ) + .await, + ); + let selection = TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: PathUri::from_abs_path(&AbsolutePathBuf::current_dir().expect("cwd")), + }; + let environments = Arc::new(ThreadEnvironments::new( + manager, + crate::shell::default_user_shell(), + ShellSnapshot::disabled(), + TurnEnvironmentSnapshot::default(), + /*non_blocking_snapshots*/ false, + )); + environments.update_selections(std::slice::from_ref(&selection)); + let snapshot_task = tokio::spawn({ + let environments = Arc::clone(&environments); + async move { environments.snapshot().await } + }); + tokio::task::yield_now().await; + assert!(!snapshot_task.is_finished()); + + let server = tokio::spawn(serve_environment_info(listener)); + let snapshot = timeout(Duration::from_secs(5), snapshot_task) + .await + .expect("snapshot should finish after the environment starts") + .expect("snapshot task"); + + assert!(snapshot.starting.is_empty()); + assert_eq!(snapshot.to_selections(), vec![selection]); + server.await.expect("server task"); + } + + #[tokio::test] + async fn snapshot_keeps_starting_environment_until_it_can_be_attached() { + let listener = TcpListener::bind("127.0.0.1:0") .await .expect("bind websocket listener"); let manager = Arc::new( @@ -463,43 +615,115 @@ url = "ws://127.0.0.1:8765" .await, ); let cwd = AbsolutePathBuf::current_dir().expect("cwd"); - let turn_environments = Arc::new(ThreadEnvironments::new( + let cwd = PathUri::from_abs_path(&cwd); + let remote = TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: cwd.clone(), + }; + let local = TurnEnvironmentSelection { + environment_id: LOCAL_ENVIRONMENT_ID.to_string(), + cwd, + }; + let turn_environments = ThreadEnvironments::new( manager, crate::shell::default_user_shell(), ShellSnapshot::disabled(), TurnEnvironmentSnapshot::default(), - )); - turn_environments.update_selections(&[TurnEnvironmentSelection { - environment_id: REMOTE_ENVIRONMENT_ID.to_string(), - cwd: PathUri::from_abs_path(&cwd), - }]); - let (_connection, _) = - tokio::time::timeout(std::time::Duration::from_secs(5), listener.accept()) - .await - .expect("remote resolution should connect") - .expect("accept remote resolution connection"); - let local = TurnEnvironmentSelection { - environment_id: LOCAL_ENVIRONMENT_ID.to_string(), - cwd: PathUri::from_abs_path(&cwd), - }; + /*non_blocking_snapshots*/ true, + ); + turn_environments.update_selections(&[remote.clone(), local.clone()]); - turn_environments.update_selections(std::slice::from_ref(&local)); - let snapshot = tokio::time::timeout( + let starting = turn_environments.snapshot().await; + assert!(starting.turn_environments.is_empty()); + assert_eq!( + starting + .starting + .iter() + .map(|environment| environment.selection.clone()) + .collect::>(), + vec![remote.clone(), local.clone()] + ); + assert!(starting.to_selections().is_empty()); + assert!(starting.single_local_environment().is_none()); + + let server = tokio::spawn(serve_environment_info(listener)); + timeout( std::time::Duration::from_secs(5), - turn_environments.snapshot(), + starting.starting[0].resolution.clone(), ) .await - .expect("latest environment resolution should complete"); + .expect("environment resolution should finish") + .expect("environment resolution should succeed"); + let attached = turn_environments.snapshot().await; - assert_eq!(snapshot.to_selections(), vec![local]); + assert!(attached.starting.is_empty()); + assert_eq!( + attached + .turn_environments + .iter() + .map(TurnEnvironment::selection) + .collect::>(), + vec![remote.clone(), local.clone()] + ); + assert_eq!(attached.to_selections(), vec![remote, local]); + server.await.expect("server task"); } #[tokio::test] - async fn matching_environment_id_and_cwd_reuse_resolved_environment() { - let cwd = AbsolutePathBuf::current_dir().expect("cwd"); + async fn failed_resolution_is_replaced_from_the_environment_manager() { let manager = Arc::new( EnvironmentManager::create_for_tests( - Some("ws://127.0.0.1:8765".to_string()), + Some("http://example.com".to_string()), + Some(test_runtime_paths()), + ) + .await, + ); + let selection = TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: PathUri::from_abs_path(&AbsolutePathBuf::current_dir().expect("cwd")), + }; + let environments = ThreadEnvironments::new( + Arc::clone(&manager), + crate::shell::default_user_shell(), + ShellSnapshot::disabled(), + TurnEnvironmentSnapshot::default(), + /*non_blocking_snapshots*/ true, + ); + environments.update_selections(std::slice::from_ref(&selection)); + let failed_resolution = environments.environments.load()[0].resolution.clone(); + assert!(failed_resolution.clone().await.is_err()); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind replacement listener"); + manager + .upsert_environment( + REMOTE_ENVIRONMENT_ID.to_string(), + format!("ws://{}", listener.local_addr().expect("listener address")), + ) + .expect("replacement environment"); + environments.update_selections(std::slice::from_ref(&selection)); + + let replacement = environments.snapshot().await; + let [replacement] = replacement.starting.as_slice() else { + panic!("expected the replacement environment to be starting"); + }; + assert_eq!(replacement.selection, selection); + assert!(!failed_resolution.ptr_eq(&replacement.resolution)); + } + + #[tokio::test] + async fn matching_environment_id_and_cwd_reuse_resolution() { + let cwd = AbsolutePathBuf::current_dir().expect("cwd"); + let first_listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind first listener"); + let manager = Arc::new( + EnvironmentManager::create_for_tests( + Some(format!( + "ws://{}", + first_listener.local_addr().expect("first listener address") + )), Some(test_runtime_paths()), ) .await, @@ -508,43 +732,98 @@ url = "ws://127.0.0.1:8765" environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&cwd), }; - let initial = - resolve_turn_environments(Arc::clone(&manager), std::slice::from_ref(&selection)).await; + let environments = ThreadEnvironments::new( + Arc::clone(&manager), + crate::shell::default_user_shell(), + ShellSnapshot::disabled(), + TurnEnvironmentSnapshot::default(), + /*non_blocking_snapshots*/ true, + ); + environments.update_selections(std::slice::from_ref(&selection)); + let initial_snapshot = environments.snapshot().await; + let second_listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind second listener"); + manager + .upsert_environment( + REMOTE_ENVIRONMENT_ID.to_string(), + format!( + "ws://{}", + second_listener + .local_addr() + .expect("second listener address") + ), + ) + .expect("replace environment"); + + environments.update_selections(std::slice::from_ref(&selection)); + let reused_snapshot = environments.snapshot().await; + environments.update_selections(&[TurnEnvironmentSelection { + cwd: PathUri::from_abs_path(&cwd.join("changed")), + ..selection + }]); + let changed_snapshot = environments.snapshot().await; + + let initial = initial_snapshot + .starting + .first() + .expect("initial environment"); + let reused = reused_snapshot + .starting + .first() + .expect("reused environment"); + let changed = changed_snapshot + .starting + .first() + .expect("changed environment"); + assert!(initial.resolution.ptr_eq(&reused.resolution)); + assert!(!reused.resolution.ptr_eq(&changed.resolution)); + } + + #[tokio::test] + async fn inherited_environment_reuses_parent_handle() { + let cwd = AbsolutePathBuf::current_dir().expect("cwd"); + let selection = TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: PathUri::from_abs_path(&cwd), + }; + let inherited_environment = Arc::new( + Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string())) + .expect("inherited environment"), + ); + let inherited = TurnEnvironment::new( + selection.environment_id.clone(), + Arc::clone(&inherited_environment), + selection.cwd.clone(), + /*shell*/ None, + ); + let manager = Arc::new(EnvironmentManager::without_environments()); manager .upsert_environment( REMOTE_ENVIRONMENT_ID.to_string(), "ws://127.0.0.1:9876".to_string(), ) - .expect("replace environment"); + .expect("replacement environment"); + let environments = ThreadEnvironments::new( + manager, + crate::shell::default_user_shell(), + ShellSnapshot::disabled(), + TurnEnvironmentSnapshot { + turn_environments: vec![inherited], + starting: Vec::new(), + }, + /*non_blocking_snapshots*/ false, + ); - let initial_snapshot = initial.snapshot().await; - initial.update_selections(std::slice::from_ref(&selection)); - let reused_snapshot = initial.snapshot().await; - initial.update_selections(&[TurnEnvironmentSelection { - cwd: PathUri::from_abs_path(&cwd.join("changed")), - ..selection - }]); - let changed_snapshot = initial.snapshot().await; + environments.update_selections(std::slice::from_ref(&selection)); + let snapshot = environments.snapshot().await; assert!(Arc::ptr_eq( - &initial_snapshot + &snapshot .primary() - .expect("initial environment") - .environment, - &reused_snapshot - .primary() - .expect("reused environment") - .environment, - )); - assert!(!Arc::ptr_eq( - &reused_snapshot - .primary() - .expect("reused environment") - .environment, - &changed_snapshot - .primary() - .expect("changed environment") + .expect("inherited environment") .environment, + &inherited_environment, )); } @@ -573,6 +852,7 @@ url = "ws://127.0.0.1:8765" cwd_uri.clone(), /*shell*/ None, )], + starting: Vec::new(), }; let multiple = TurnEnvironmentSnapshot { turn_environments: vec![ @@ -584,6 +864,7 @@ url = "ws://127.0.0.1:8765" /*shell*/ None, ), ], + starting: Vec::new(), }; assert_eq!(local.single_local_environment_cwd(), Some(cwd)); diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 583882cae1..1e8c250bc9 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -826,6 +826,7 @@ impl Session { default_shell.clone(), shell_snapshot, inherited_environments.unwrap_or_default(), + config.features.enabled(Feature::DeferredExecutor), )); turn_environments.update_selections(session_configuration.environment_selections()); let resolved_environments = turn_environments.snapshot().await; diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 436b85907d..e7532a1e57 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -4101,6 +4101,7 @@ async fn resolved_environments_for_configuration( default_user_shell(), ShellSnapshot::disabled(), TurnEnvironmentSnapshot::default(), + /*non_blocking_snapshots*/ false, ); turn_environments.update_selections(session_configuration.environment_selections()); (environment_manager, turn_environments.snapshot().await) @@ -5010,6 +5011,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { default_user_shell(), ShellSnapshot::disabled(), resolved_environments, + /*non_blocking_snapshots*/ false, )); let environment = Arc::clone( &resolved_turn_environments @@ -7065,6 +7067,7 @@ where default_user_shell(), ShellSnapshot::disabled(), resolved_turn_environments.clone(), + /*non_blocking_snapshots*/ false, )); let environment = Arc::clone( &resolved_turn_environments diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 63bb5deeda..67e5668f45 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -55,6 +55,7 @@ use serde_json::json; use std::fs; use std::path::PathBuf; use std::process::Command; +use std::time::Duration; use std::time::SystemTime; use std::time::UNIX_EPOCH; use tempfile::TempDir; @@ -299,6 +300,39 @@ async fn explicit_remote_shell_runs_in_remote_cwd() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn deferred_executor_reaches_model_before_remote_environment_is_ready() -> Result<()> { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let server = start_mock_server().await; + let response_mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + ) + .await; + let mut builder = test_codex() + .with_exec_server_url(format!("ws://{}", listener.local_addr()?)) + .with_config(|config| { + assert!(config.features.enable(Feature::DeferredExecutor).is_ok()); + }); + + let test = tokio::time::timeout(Duration::from_secs(5), builder.build(&server)) + .await + .context("thread startup should not wait for the remote environment")??; + tokio::time::timeout( + Duration::from_secs(5), + test.submit_turn("respond before the environment is ready"), + ) + .await + .context("turn should reach the model before the remote environment is ready")??; + + response_mock.single_request(); + Ok(()) +} + fn absolute_path(path: PathBuf) -> AbsolutePathBuf { AbsolutePathBuf::try_from(path).expect("path should be absolute") } diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 8808cb37c3..8483ef97bd 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -124,6 +124,8 @@ pub enum Feature { UseLegacyLandlock, /// Experimental shell snapshotting. ShellSnapshot, + /// Allow turns to start while selected executors are still starting. + DeferredExecutor, /// Enable runtime metrics snapshots via a manual reader. RuntimeMetrics, /// Enable startup memory extraction and file-backed memory consolidation. @@ -816,6 +818,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::DeferredExecutor, + key: "deferred_executor", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::JsRepl, key: "js_repl", From f886e33e5a55fcb6637d1d05c0f44cab9fcb933f Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Thu, 18 Jun 2026 22:27:45 -0700 Subject: [PATCH 068/112] [3/3] app-server: configure environment connection timeout (#29025) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Remote environments registered through `environment/add` currently use the fixed 10-second WebSocket connection timeout. Slow-starting executors need a caller-selected connection window, but this should not add retry policy or couple exec-server behavior to Core’s `deferred_executor` feature. Make the timeout an optional part of the existing experimental request. Existing clients continue using the current default, while callers that know an executor may take longer can request a larger window explicitly. Depends on #28683. ## What changed - Add optional `connectTimeoutMs` to `EnvironmentAddParams` and document it in the app-server README. - Pass the optional timeout through `EnvironmentRequestProcessor` into one `EnvironmentManager::upsert_environment()` path; the manager applies the existing default when it is omitted. - Preserve the existing single-attempt lifecycle. The configured value controls WebSocket connection and handshake time for both initial connection and later reconnects; initialization retains its separate timeout. - Add an app-server integration test that sends the real JSON-RPC request and verifies a stalled handshake observes the requested timeout. ## Test plan - `just test -p codex-app-server-protocol` - `just test -p codex-exec-server` - `just test -p codex-app-server environment_add_applies_connect_timeout` ## Rollout This is additive and does not enable `deferred_executor`. Callers should send a non-default timeout only after a compatible app-server is deployed; omitted or `null` values retain the existing 10-second default. --- .../src/protocol/common.rs | 6 ++- .../src/protocol/v2/environment.rs | 4 ++ codex-rs/app-server/README.md | 2 +- .../environment_processor.rs | 7 ++- .../tests/suite/v2/environment_add.rs | 52 +++++++++++++++++++ codex-rs/app-server/tests/suite/v2/mod.rs | 1 + codex-rs/core/src/environment_selection.rs | 3 ++ codex-rs/exec-server/src/client_api.rs | 4 +- codex-rs/exec-server/src/environment.rs | 38 +++++++++++--- .../src/remote_file_system_path_uri_tests.rs | 6 ++- 10 files changed, 110 insertions(+), 13 deletions(-) create mode 100644 codex-rs/app-server/tests/suite/v2/environment_add.rs diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 4e9a785399..643afcc58d 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2011,6 +2011,7 @@ mod tests { params: v2::EnvironmentAddParams { environment_id: "remote-a".to_string(), exec_server_url: "ws://127.0.0.1:8765".to_string(), + connect_timeout_ms: None, }, }; assert_eq!( @@ -2958,6 +2959,7 @@ mod tests { params: v2::EnvironmentAddParams { environment_id: "remote-a".to_string(), exec_server_url: "ws://127.0.0.1:8765".to_string(), + connect_timeout_ms: Some(300_000), }, }; assert_eq!( @@ -2966,7 +2968,8 @@ mod tests { "id": 9, "params": { "environmentId": "remote-a", - "execServerUrl": "ws://127.0.0.1:8765" + "execServerUrl": "ws://127.0.0.1:8765", + "connectTimeoutMs": 300000 } }), serde_json::to_value(&request)?, @@ -3392,6 +3395,7 @@ mod tests { params: v2::EnvironmentAddParams { environment_id: "remote-a".to_string(), exec_server_url: "ws://127.0.0.1:8765".to_string(), + connect_timeout_ms: None, }, }; let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&request); diff --git a/codex-rs/app-server-protocol/src/protocol/v2/environment.rs b/codex-rs/app-server-protocol/src/protocol/v2/environment.rs index 294ae736fd..ccffd5813b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/environment.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/environment.rs @@ -9,6 +9,10 @@ use ts_rs::TS; pub struct EnvironmentAddParams { pub environment_id: String, pub exec_server_url: String, + /// Optional WebSocket connection timeout. The server default applies when omitted. + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub connect_timeout_ms: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 5035ebf718..f4f3bbc3b0 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -204,7 +204,7 @@ Example with notification opt-out: - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.]` entries to be included in the current catalog view. - `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for currently supported feature keys. For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. Invalid keys will be ignored. -- `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; returns `{}` and does not change the default environment. +- `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; optional `connectTimeoutMs` overrides the WebSocket connection timeout; returns `{}` and does not change the default environment. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). - `skills/extraRoots/set` — replace the app-server process runtime extra standalone skill roots. The roots are not persisted; missing directories are accepted and simply load no skills. diff --git a/codex-rs/app-server/src/request_processors/environment_processor.rs b/codex-rs/app-server/src/request_processors/environment_processor.rs index eb9b283f7b..36533aa231 100644 --- a/codex-rs/app-server/src/request_processors/environment_processor.rs +++ b/codex-rs/app-server/src/request_processors/environment_processor.rs @@ -1,4 +1,5 @@ use super::*; +use std::time::Duration; #[derive(Clone)] pub(crate) struct EnvironmentRequestProcessor { @@ -17,7 +18,11 @@ impl EnvironmentRequestProcessor { params: EnvironmentAddParams, ) -> Result, JSONRPCErrorError> { self.environment_manager - .upsert_environment(params.environment_id, params.exec_server_url) + .upsert_environment( + params.environment_id, + params.exec_server_url, + params.connect_timeout_ms.map(Duration::from_millis), + ) .map_err(|err| invalid_request(err.to_string()))?; Ok(Some(EnvironmentAddResponse {}.into())) } diff --git a/codex-rs/app-server/tests/suite/v2/environment_add.rs b/codex-rs/app-server/tests/suite/v2/environment_add.rs new file mode 100644 index 0000000000..bf44b27607 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/environment_add.rs @@ -0,0 +1,52 @@ +use std::time::Duration; + +use anyhow::Result; +use app_test_support::TestAppServer; +use app_test_support::to_response; +use codex_app_server_protocol::EnvironmentAddResponse; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use serde_json::json; +use tempfile::TempDir; +use tokio::io::AsyncReadExt; +use tokio::net::TcpListener; +use tokio::time::timeout; + +const RPC_TIMEOUT: Duration = Duration::from_secs(10); +const CONNECTION_CLOSE_TIMEOUT: Duration = Duration::from_secs(5); + +#[tokio::test] +async fn environment_add_applies_connect_timeout() -> Result<()> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let exec_server_url = format!("ws://{}", listener.local_addr()?); + let stalled_server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await?; + let mut request = Vec::new(); + socket.read_to_end(&mut request).await?; + anyhow::ensure!(!request.is_empty(), "expected a WebSocket handshake"); + Ok::<_, anyhow::Error>(()) + }); + let codex_home = TempDir::new()?; + let mut app_server = TestAppServer::new(codex_home.path()).await?; + timeout(RPC_TIMEOUT, app_server.initialize()).await??; + + let request_id = app_server + .send_raw_request( + "environment/add", + Some(json!({ + "environmentId": "remote-a", + "execServerUrl": exec_server_url, + "connectTimeoutMs": 1_000, + })), + ) + .await?; + let response: JSONRPCResponse = timeout( + RPC_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let _: EnvironmentAddResponse = to_response(response)?; + + timeout(CONNECTION_CLOSE_TIMEOUT, stalled_server).await???; + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 2745e8ee00..f4400da468 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -13,6 +13,7 @@ mod connection_handling_websocket; mod connection_handling_websocket_unix; mod current_time; mod dynamic_tools; +mod environment_add; #[cfg(not(target_os = "windows"))] mod executor_mcp; mod executor_skills; diff --git a/codex-rs/core/src/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index 0d82a147be..1bed53fcfd 100644 --- a/codex-rs/core/src/environment_selection.rs +++ b/codex-rs/core/src/environment_selection.rs @@ -700,6 +700,7 @@ url = "ws://127.0.0.1:8765" .upsert_environment( REMOTE_ENVIRONMENT_ID.to_string(), format!("ws://{}", listener.local_addr().expect("listener address")), + /*connect_timeout*/ None, ) .expect("replacement environment"); environments.update_selections(std::slice::from_ref(&selection)); @@ -753,6 +754,7 @@ url = "ws://127.0.0.1:8765" .local_addr() .expect("second listener address") ), + /*connect_timeout*/ None, ) .expect("replace environment"); @@ -802,6 +804,7 @@ url = "ws://127.0.0.1:8765" .upsert_environment( REMOTE_ENVIRONMENT_ID.to_string(), "ws://127.0.0.1:9876".to_string(), + /*connect_timeout*/ None, ) .expect("replacement environment"); let environments = ThreadEnvironments::new( diff --git a/codex-rs/exec-server/src/client_api.rs b/codex-rs/exec-server/src/client_api.rs index 60dc638d4d..0c6a3576d0 100644 --- a/codex-rs/exec-server/src/client_api.rs +++ b/codex-rs/exec-server/src/client_api.rs @@ -136,10 +136,10 @@ impl std::fmt::Debug for ExecServerTransportParams { } impl ExecServerTransportParams { - pub(crate) fn websocket_url(websocket_url: String) -> Self { + pub(crate) fn websocket_url(websocket_url: String, connect_timeout: Duration) -> Self { Self::WebSocketUrl { websocket_url, - connect_timeout: DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT, + connect_timeout, initialize_timeout: DEFAULT_REMOTE_EXEC_SERVER_INITIALIZE_TIMEOUT, } } diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 03d0d996c7..9496bb8be6 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -11,6 +11,7 @@ use crate::NoiseChannelIdentity; use crate::NoiseRendezvousConnectProvider; use crate::client::LazyRemoteExecServerClient; use crate::client::http_client::ReqwestHttpClient; +use crate::client_api::DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT; use crate::client_api::ExecServerTransportParams; use crate::environment_provider::DefaultEnvironmentProvider; use crate::environment_provider::EnvironmentDefault; @@ -288,11 +289,13 @@ impl EnvironmentManager { } /// Adds or replaces a named remote environment without changing the - /// manager's default environment selection. + /// manager's default environment selection. Uses the default WebSocket + /// connection timeout when none is provided. pub fn upsert_environment( &self, environment_id: String, exec_server_url: String, + connect_timeout: Option, ) -> Result<(), ExecServerError> { if environment_id.is_empty() { return Err(ExecServerError::Protocol( @@ -310,8 +313,11 @@ impl EnvironmentManager { "remote environment requires an exec-server url".to_string(), )); }; - let environment = Arc::new(Environment::remote_inner( - exec_server_url, + let environment = Arc::new(Environment::remote_with_transport( + ExecServerTransportParams::websocket_url( + exec_server_url, + connect_timeout.unwrap_or(DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT), + ), self.local_runtime_paths.clone(), )); environment.start_connecting(); @@ -496,7 +502,10 @@ impl Environment { local_runtime_paths: Option, ) -> Self { Self::remote_with_transport( - ExecServerTransportParams::websocket_url(exec_server_url), + ExecServerTransportParams::websocket_url( + exec_server_url, + DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT, + ), local_runtime_paths, ) } @@ -980,7 +989,11 @@ mod tests { let manager = EnvironmentManager::without_environments(); manager - .upsert_environment("executor-a".to_string(), "ws://127.0.0.1:8765".to_string()) + .upsert_environment( + "executor-a".to_string(), + "ws://127.0.0.1:8765".to_string(), + /*connect_timeout*/ None, + ) .expect("remote environment"); let first = manager .get_environment("executor-a") @@ -990,7 +1003,11 @@ mod tests { assert_eq!(manager.default_environment_id(), None); manager - .upsert_environment("executor-a".to_string(), "ws://127.0.0.1:9876".to_string()) + .upsert_environment( + "executor-a".to_string(), + "ws://127.0.0.1:9876".to_string(), + /*connect_timeout*/ None, + ) .expect("updated remote environment"); let second = manager .get_environment("executor-a") @@ -1011,6 +1028,7 @@ mod tests { .upsert_environment( "executor-a".to_string(), format!("ws://{}", listener.local_addr().expect("listener address")), + /*connect_timeout*/ None, ) .expect("remote environment"); @@ -1066,6 +1084,7 @@ mod tests { "ws://{}", first_listener.local_addr().expect("first listener address") ), + /*connect_timeout*/ None, ) .expect("first remote environment"); let environment = manager @@ -1090,6 +1109,7 @@ mod tests { .local_addr() .expect("second listener address") ), + /*connect_timeout*/ None, ) .expect("replacement remote environment"); @@ -1107,7 +1127,11 @@ mod tests { let manager = EnvironmentManager::without_environments(); let err = manager - .upsert_environment("executor-a".to_string(), String::new()) + .upsert_environment( + "executor-a".to_string(), + String::new(), + /*connect_timeout*/ None, + ) .expect_err("empty URL should fail"); assert_eq!( diff --git a/codex-rs/exec-server/src/remote_file_system_path_uri_tests.rs b/codex-rs/exec-server/src/remote_file_system_path_uri_tests.rs index 29284e6ad9..1205c6d7e4 100644 --- a/codex-rs/exec-server/src/remote_file_system_path_uri_tests.rs +++ b/codex-rs/exec-server/src/remote_file_system_path_uri_tests.rs @@ -23,6 +23,7 @@ use tokio_tungstenite::accept_async; use tokio_tungstenite::tungstenite::Message; use super::*; +use crate::client_api::DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT; use crate::client_api::ExecServerTransportParams; use crate::protocol::FS_READ_FILE_METHOD; use crate::protocol::FsReadFileParams; @@ -36,7 +37,10 @@ async fn remote_file_system_sends_path_and_sandbox_cwd_uris_without_native_conve let (websocket_url, captured_params, server) = record_read_file_params(/*expected_requests*/ 2).await; let file_system = RemoteFileSystem::new(LazyRemoteExecServerClient::new( - ExecServerTransportParams::websocket_url(websocket_url), + ExecServerTransportParams::websocket_url( + websocket_url, + DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT, + ), )); let paths = vec![ PathUri::parse("file:///C:/Users/Alice/src/main.rs").expect("valid drive URI"), From fc8c6b73841e279f95f53b08771a7969e953bdf4 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Thu, 18 Jun 2026 22:47:51 -0700 Subject: [PATCH 069/112] Add per-turn multi-agent mode (#28685) ## Why Multi-agent v2 currently carries an explicit-request-only delegation rule in its static usage hint. That provides a safe default, but it prevents clients from selecting proactive delegation per turn without changing static guidance or rewriting prior model context. This change makes delegation mode a session selection that can be updated through `turn/start`, while deriving the effective model-visible mode separately for each turn. Eligible multi-agent v2 turns remain explicit-request-only unless proactive mode is both selected and enabled. ## What changed - Add the experimental `turn/start.multiAgentMode` parameter with `explicitRequestOnly` and `proactive` values. Omission retains the loaded session's current optional selection. - Add the default-off `features.multi_agent_mode` feature gate. Eligible multi-agent v2 turns use the selected mode when enabled; an unset selection or disabled gate resolves to `explicitRequestOnly`. - Treat mode prompting as inapplicable for multi-agent v1 and other unsupported session configurations, producing no multi-agent mode developer message rather than rejecting the turn. - Move the explicit-request-only rule out of the static v2 usage hint and into a bounded, tagged developer context fragment. - Emit the effective mode in initial context and only when that effective mode changes on later turns. - Persist the effective mode in `TurnContextItem` as the durable baseline for resume and context-update comparisons. Historical rollout items are not rewritten. Later mode developer messages establish the current rule incrementally. ## Not covered - Initial selection through `thread/start` and selected-mode reporting from thread lifecycle/settings APIs; those are isolated in the stacked #28792. - A TUI control or slash command for selecting the mode. - Persisting a preferred mode to `config.toml`; selection remains session/turn scoped. - Changes to multi-agent concurrency limits, tool availability, or model catalog capability declarations. - Rewriting historical rollout prompt items. Cold resume restores the latest persisted effective mode when available while leaving historical developer messages intact. ## Verification - `CARGO_INCREMENTAL=0 just test -p codex-core multi_agent_mode` - Focused app-server coverage verifies that `turn/start.multiAgentMode` produces proactive developer instructions for an eligible v2 turn. ## Stack Followed by #28792, which adds `thread/start` initialization and lifecycle/settings observability. --- .../schema/json/ClientRequest.json | 8 + .../codex_app_server_protocol.schemas.json | 8 + .../codex_app_server_protocol.v2.schemas.json | 8 + .../schema/json/v2/TurnStartParams.json | 8 + .../schema/typescript/MultiAgentMode.ts | 9 + .../schema/typescript/index.ts | 1 + .../src/protocol/v2/tests.rs | 24 ++ .../src/protocol/v2/turn.rs | 7 + codex-rs/app-server/README.md | 2 +- .../src/message_processor_tracing_tests.rs | 1 + .../thread_processor_tests.rs | 1 + .../src/request_processors/turn_processor.rs | 8 + .../app-server/tests/suite/v2/turn_start.rs | 80 ++++ codex-rs/core/config.schema.json | 6 + codex-rs/core/src/codex_thread.rs | 5 + codex-rs/core/src/config/config_tests.rs | 5 +- codex-rs/core/src/config/mod.rs | 4 +- .../src/context/environment_context_tests.rs | 1 + codex-rs/core/src/context/mod.rs | 2 + .../context/multi_agent_mode_instructions.rs | 41 ++ .../core/src/context_manager/history_tests.rs | 2 + codex-rs/core/src/context_manager/updates.rs | 28 ++ codex-rs/core/src/event_mapping.rs | 2 + codex-rs/core/src/session/handlers.rs | 2 + codex-rs/core/src/session/mod.rs | 17 +- codex-rs/core/src/session/multi_agents.rs | 37 ++ codex-rs/core/src/session/review.rs | 1 + .../session/rollout_reconstruction_tests.rs | 8 + codex-rs/core/src/session/session.rs | 6 + codex-rs/core/src/session/tests.rs | 9 + codex-rs/core/src/session/turn_context.rs | 10 + codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/multi_agent_mode.rs | 376 ++++++++++++++++++ codex-rs/core/tests/suite/resume_warning.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/features/src/lib.rs | 8 + codex-rs/protocol/src/config_types.rs | 14 + codex-rs/protocol/src/protocol.rs | 29 ++ codex-rs/rollout/src/recorder_tests.rs | 1 + codex-rs/state/src/extract.rs | 4 + codex-rs/tui/src/app_server_session.rs | 1 + 41 files changed, 779 insertions(+), 8 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/MultiAgentMode.ts create mode 100644 codex-rs/core/src/context/multi_agent_mode_instructions.rs create mode 100644 codex-rs/core/tests/suite/multi_agent_mode.rs diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index dc83fc5994..4ce6ac1a82 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1760,6 +1760,14 @@ "ModelProviderCapabilitiesReadParams": { "type": "object" }, + "MultiAgentMode": { + "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "enum": [ + "explicitRequestOnly", + "proactive" + ], + "type": "string" + }, "NetworkAccess": { "enum": [ "restricted", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index d88c2c9d4f..87726d30c4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -12637,6 +12637,14 @@ "title": "ModelVerificationNotification", "type": "object" }, + "MultiAgentMode": { + "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "enum": [ + "explicitRequestOnly", + "proactive" + ], + "type": "string" + }, "NetworkAccess": { "enum": [ "restricted", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 3c6a774424..0c2f2f54bf 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -9061,6 +9061,14 @@ "title": "ModelVerificationNotification", "type": "object" }, + "MultiAgentMode": { + "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "enum": [ + "explicitRequestOnly", + "proactive" + ], + "type": "string" + }, "NetworkAccess": { "enum": [ "restricted", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index b1c72bb1f2..9460f80688 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -141,6 +141,14 @@ ], "type": "string" }, + "MultiAgentMode": { + "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "enum": [ + "explicitRequestOnly", + "proactive" + ], + "type": "string" + }, "NetworkAccess": { "enum": [ "restricted", diff --git a/codex-rs/app-server-protocol/schema/typescript/MultiAgentMode.ts b/codex-rs/app-server-protocol/schema/typescript/MultiAgentMode.ts new file mode 100644 index 0000000000..93b790a592 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/MultiAgentMode.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Controls whether the model should only spawn sub-agents after an explicit + * user request or may delegate proactively when doing so would help. + */ +export type MultiAgentMode = "explicitRequestOnly" | "proactive"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index f7db65d5d8..7c94d9c601 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -49,6 +49,7 @@ export type { LocalShellStatus } from "./LocalShellStatus"; export type { McpServerInfo } from "./McpServerInfo"; export type { MessagePhase } from "./MessagePhase"; export type { ModeKind } from "./ModeKind"; +export type { MultiAgentMode } from "./MultiAgentMode"; export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction"; export type { ParsedCommand } from "./ParsedCommand"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 459b11f7f2..96cbb1ed23 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -3774,6 +3774,7 @@ fn turn_start_params_preserve_explicit_null_service_tier() { summary: None, output_schema: None, collaboration_mode: None, + multi_agent_mode: None, personality: None, }; let serialized_without_override = @@ -3781,6 +3782,29 @@ fn turn_start_params_preserve_explicit_null_service_tier() { assert_eq!(serialized_without_override.get("serviceTier"), None); } +#[test] +fn turn_start_params_round_trip_multi_agent_mode() { + let params: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + "multiAgentMode": "proactive" + })) + .expect("params should deserialize"); + + assert_eq!( + params.multi_agent_mode, + Some(codex_protocol::config_types::MultiAgentMode::Proactive) + ); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(¶ms), + Some("turn/start.multiAgentMode") + ); + assert_eq!( + serde_json::to_value(params).expect("params should serialize")["multiAgentMode"], + "proactive" + ); +} + #[test] fn thread_settings_update_params_preserve_explicit_null_service_tier() { let params: ThreadSettingsUpdateParams = serde_json::from_value(json!({ diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs index 77da0ea8ad..7ecb9f94b7 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -4,6 +4,7 @@ use super::SandboxPolicy; use super::Turn; use codex_experimental_api_macros::ExperimentalApi; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::ImageDetail; @@ -149,6 +150,12 @@ pub struct TurnStartParams { #[experimental("turn/start.collaborationMode")] #[ts(optional = nullable)] pub collaboration_mode: Option, + + /// Controls whether multi-agent v2 delegation requires an explicit user request. + /// Omitted keeps the loaded session's current mode. + #[experimental("turn/start.multiAgentMode")] + #[ts(optional = nullable)] + pub multi_agent_mode: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index f4f3bbc3b0..5914c3a858 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -168,7 +168,7 @@ Example with notification opt-out: - `thread/backgroundTerminals/list` — list running background terminals for a loaded thread (experimental; requires `capabilities.experimentalApi`); returns `data` with the running terminal ids. - `thread/backgroundTerminals/terminate` — terminate one running background terminal by app-server `processId` (experimental; requires `capabilities.experimentalApi`); returns whether a process was terminated. - `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. -- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". +- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". Experimental `multiAgentMode` accepts `explicitRequestOnly` or `proactive`; omission keeps the loaded session's current mode. The requested mode is retained for the loaded session without rejecting unsupported configurations. Eligible multi-agent v2 turns use the requested mode when `features.multi_agent_mode` is enabled and otherwise use explicit-request-only developer instructions. - `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success. - `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Review and manual compaction turns reject `turn/steer`. - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. diff --git a/codex-rs/app-server/src/message_processor_tracing_tests.rs b/codex-rs/app-server/src/message_processor_tracing_tests.rs index 771b1fd128..7c4ae8c985 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -673,6 +673,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { personality: None, output_schema: None, collaboration_mode: None, + multi_agent_mode: None, }, }, Some(remote_trace), diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 3e336cc2a5..0ac9b7fa24 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -776,6 +776,7 @@ mod thread_processor_behavior_tests { developer_instructions: None, }, }, + multi_agent_mode: Default::default(), session_source: SessionSource::Cli, forked_from_thread_id: None, parent_thread_id: None, diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index b7408a8c3b..c6a739afcb 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -1,4 +1,5 @@ use super::*; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::protocol::AdditionalContextEntry as CoreAdditionalContextEntry; use codex_protocol::protocol::AdditionalContextKind as CoreAdditionalContextKind; use codex_protocol::protocol::MultiAgentVersion; @@ -60,6 +61,7 @@ struct ThreadSettingsBuildParams { effort: Option, summary: Option, collaboration_mode: Option, + multi_agent_mode: Option, personality: Option, } @@ -454,6 +456,7 @@ impl TurnRequestProcessor { effort: params.effort, summary: params.summary, collaboration_mode: params.collaboration_mode, + multi_agent_mode: params.multi_agent_mode, personality: params.personality, }, ) @@ -560,6 +563,7 @@ impl TurnRequestProcessor { effort, summary, collaboration_mode, + multi_agent_mode, personality, } = params; @@ -593,6 +597,7 @@ impl TurnRequestProcessor { || effort.is_some() || summary.is_some() || collaboration_mode.is_some() + || multi_agent_mode.is_some() || personality.is_some(); let runtime_workspace_roots = @@ -669,6 +674,7 @@ impl TurnRequestProcessor { summary, service_tier: service_tier.clone(), collaboration_mode: collaboration_mode.clone(), + multi_agent_mode, personality, }) .await @@ -692,6 +698,7 @@ impl TurnRequestProcessor { summary, service_tier, collaboration_mode, + multi_agent_mode, personality, }) } @@ -722,6 +729,7 @@ impl TurnRequestProcessor { effort: params.effort, summary: params.summary, collaboration_mode: params.collaboration_mode, + multi_agent_mode: None, personality: params.personality, }, ) diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index f577a01fd2..058674653a 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -69,6 +69,7 @@ use codex_features::FEATURES; use codex_features::Feature; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; @@ -1749,6 +1750,83 @@ async fn turn_start_accepts_personality_override_v2() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_accepts_multi_agent_mode_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::from([ + (Feature::MultiAgentV2, true), + (Feature::MultiAgentMode, true), + ]), + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + multi_agent_mode: Some(MultiAgentMode::Proactive), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _: TurnStartResponse = to_response(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let developer_texts = response_mock + .single_request() + .message_input_texts("developer"); + assert!(developer_texts.iter().any(|text| { + text.contains("") + && text.contains("Proactive multi-agent delegation is active.") + })); + assert!(!developer_texts.iter().any(|text| { + text.contains("Do not spawn sub-agents unless the user explicitly asks for sub-agents") + })); + + Ok(()) +} + #[tokio::test] async fn turn_start_change_personality_mid_thread_v2() -> Result<()> { skip_if_no_network!(Ok(())); @@ -2367,6 +2445,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { personality: None, output_schema: None, collaboration_mode: None, + multi_agent_mode: None, }) .await?; timeout( @@ -2406,6 +2485,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { personality: None, output_schema: None, collaboration_mode: None, + multi_agent_mode: None, }) .await?; timeout( diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 6eff342082..7d21c90690 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -557,6 +557,9 @@ "multi_agent": { "type": "boolean" }, + "multi_agent_mode": { + "type": "boolean" + }, "multi_agent_v2": { "$ref": "#/definitions/FeatureToml_for_MultiAgentV2ConfigToml" }, @@ -4793,6 +4796,9 @@ "multi_agent": { "type": "boolean" }, + "multi_agent_mode": { + "type": "boolean" + }, "multi_agent_v2": { "$ref": "#/definitions/FeatureToml_for_MultiAgentV2ConfigToml" }, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index ab46d8b3ab..0f63323d48 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -8,6 +8,7 @@ use codex_otel::SessionTelemetry; use codex_protocol::ThreadId; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::WindowsSandboxLevel; @@ -71,6 +72,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_summary: Option, pub personality: Option, pub collaboration_mode: CollaborationMode, + pub multi_agent_mode: Option, pub session_source: SessionSource, pub forked_from_thread_id: Option, pub parent_thread_id: Option, @@ -151,6 +153,7 @@ pub struct CodexThreadSettingsOverrides { pub summary: Option, pub service_tier: Option>, pub collaboration_mode: Option, + pub multi_agent_mode: Option, pub personality: Option, } @@ -371,6 +374,7 @@ impl CodexThread { summary, service_tier, collaboration_mode, + multi_agent_mode, personality, } = overrides; let collaboration_mode = if let Some(collaboration_mode) = collaboration_mode { @@ -394,6 +398,7 @@ impl CodexThread { active_permission_profile, windows_sandbox_level, collaboration_mode: Some(collaboration_mode), + multi_agent_mode, reasoning_summary: summary, service_tier, personality, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 53a31433a0..a6a9ab6365 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -10055,9 +10055,8 @@ max_concurrent_threads_per_session = 17 let config = resolve_multi_agent_v2_config(&config_toml); let concurrency_guidance = "There are 17 available concurrency slots, meaning that up to 17 agents can be active at once, including you."; - let expected_suffix = format!( - "{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\n{concurrency_guidance}\n\n{DEFAULT_MULTI_AGENT_V2_NO_SPAWN_HINT_TEXT}" - ); + let expected_suffix = + format!("{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\n{concurrency_guidance}"); assert!( [ config.root_agent_usage_hint_text, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 1089ba648f..4e0cb77feb 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -247,11 +247,9 @@ All agents share the same directory. In detail: - All agents use the same current working directory. - As a result, edits made by one agent are immediately visible to all other agents. "#; -const DEFAULT_MULTI_AGENT_V2_NO_SPAWN_HINT_TEXT: &str = "Do not spawn sub-agents unless the user explicitly asks for sub-agents, delegation, or parallel agent work."; - fn default_multi_agent_v2_usage_hint_text(usage_hint_text: &str, max_concurrency: usize) -> String { format!( - "{usage_hint_text}\n{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\nThere are {max_concurrency} available concurrency slots, meaning that up to {max_concurrency} agents can be active at once, including you.\n\n{DEFAULT_MULTI_AGENT_V2_NO_SPAWN_HINT_TEXT}" + "{usage_hint_text}\n{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\nThere are {max_concurrency} available concurrency slots, meaning that up to {max_concurrency} agents can be active at once, including you." ) } diff --git a/codex-rs/core/src/context/environment_context_tests.rs b/codex-rs/core/src/context/environment_context_tests.rs index 1204a1d16b..d40ce4e671 100644 --- a/codex-rs/core/src/context/environment_context_tests.rs +++ b/codex-rs/core/src/context/environment_context_tests.rs @@ -204,6 +204,7 @@ fn turn_context_item_filesystem_uses_workspace_roots_instead_of_cwd() { personality: None, collaboration_mode: None, multi_agent_version: None, + multi_agent_mode: None, realtime_active: None, effort: None, summary: codex_protocol::config_types::ReasoningSummary::Auto, diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index 3c2f487231..3347358bb5 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -17,6 +17,7 @@ mod legacy_apply_patch_exec_command_warning; mod legacy_model_mismatch_warning; mod legacy_unified_exec_process_limit_warning; mod model_switch_instructions; +mod multi_agent_mode_instructions; mod network_rule_saved; mod permissions_instructions; mod personality_spec_instructions; @@ -59,6 +60,7 @@ pub(crate) use legacy_apply_patch_exec_command_warning::LegacyApplyPatchExecComm pub(crate) use legacy_model_mismatch_warning::LegacyModelMismatchWarning; pub(crate) use legacy_unified_exec_process_limit_warning::LegacyUnifiedExecProcessLimitWarning; pub(crate) use model_switch_instructions::ModelSwitchInstructions; +pub(crate) use multi_agent_mode_instructions::MultiAgentModeInstructions; pub(crate) use network_rule_saved::NetworkRuleSaved; pub use permissions_instructions::PermissionsInstructions; pub(crate) use personality_spec_instructions::PersonalitySpecInstructions; diff --git a/codex-rs/core/src/context/multi_agent_mode_instructions.rs b/codex-rs/core/src/context/multi_agent_mode_instructions.rs new file mode 100644 index 0000000000..a272e7c2bb --- /dev/null +++ b/codex-rs/core/src/context/multi_agent_mode_instructions.rs @@ -0,0 +1,41 @@ +use super::ContextualUserFragment; +use codex_protocol::config_types::MultiAgentMode; +use codex_protocol::protocol::MULTI_AGENT_MODE_CLOSE_TAG; +use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG; + +const EXPLICIT_REQUEST_ONLY_MULTI_AGENT_MODE_TEXT: &str = "Do not spawn sub-agents unless the user explicitly asks for sub-agents, delegation, or parallel agent work."; +const PROACTIVE_MULTI_AGENT_MODE_TEXT: &str = "Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Use sub-agents when parallel work would materially improve speed or quality. This mode remains active until a later multi-agent mode developer message changes it."; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct MultiAgentModeInstructions { + multi_agent_mode: MultiAgentMode, +} + +impl MultiAgentModeInstructions { + pub(crate) fn new(multi_agent_mode: MultiAgentMode) -> Self { + Self { multi_agent_mode } + } +} + +impl ContextualUserFragment for MultiAgentModeInstructions { + fn role(&self) -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + (MULTI_AGENT_MODE_OPEN_TAG, MULTI_AGENT_MODE_CLOSE_TAG) + } + + fn body(&self) -> String { + match self.multi_agent_mode { + MultiAgentMode::ExplicitRequestOnly => { + EXPLICIT_REQUEST_ONLY_MULTI_AGENT_MODE_TEXT.to_string() + } + MultiAgentMode::Proactive => PROACTIVE_MULTI_AGENT_MODE_TEXT.to_string(), + } + } +} diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 8b9609a25a..35a9656fe1 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -146,6 +146,7 @@ fn reference_context_item() -> TurnContextItem { personality: None, collaboration_mode: None, multi_agent_version: None, + multi_agent_mode: None, realtime_active: Some(false), effort: None, summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -935,6 +936,7 @@ fn drop_last_n_user_turns_trims_context_updates_above_rolled_back_turn() { assistant_msg("turn 1 assistant"), developer_msg("Generated images are saved to /tmp as /tmp/image-1.png by default."), developer_msg("ROLLED_BACK_DEV_INSTRUCTIONS"), + developer_msg("ROLLED_BACK_MULTI_AGENT_MODE"), user_input_text_msg( "PRETURN_CONTEXT_DIFF_CWD", ), diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 4c69aae95b..f3becabc6e 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -2,6 +2,7 @@ use crate::context::CollaborationModeInstructions; use crate::context::ContextualUserFragment; use crate::context::EnvironmentContext; use crate::context::ModelSwitchInstructions; +use crate::context::MultiAgentModeInstructions; use crate::context::PermissionsInstructions; use crate::context::PersonalitySpecInstructions; use crate::context::RealtimeEndInstructions; @@ -12,6 +13,7 @@ use crate::session::turn_context::TurnContext; use crate::shell::Shell; use codex_execpolicy::Policy; use codex_features::Feature; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::config_types::Personality; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; @@ -95,6 +97,31 @@ fn build_collaboration_mode_update_item( } } +fn build_multi_agent_mode_update_item( + previous: Option<&TurnContextItem>, + next: &TurnContext, +) -> Option { + let effective_multi_agent_mode = crate::session::multi_agents::effective_multi_agent_mode( + next.multi_agent_version, + &next.config.multi_agent_v2, + &next.session_source, + next.multi_agent_mode, + next.features.enabled(Feature::MultiAgentMode), + ); + let previous = previous?; + if previous.multi_agent_mode == effective_multi_agent_mode { + return None; + } + + match effective_multi_agent_mode { + Some(multi_agent_mode) => Some(MultiAgentModeInstructions::new(multi_agent_mode).render()), + None if previous.multi_agent_mode == Some(MultiAgentMode::Proactive) => { + Some(MultiAgentModeInstructions::new(MultiAgentMode::ExplicitRequestOnly).render()) + } + None => None, + } +} + pub(crate) fn build_realtime_update_item( previous: Option<&TurnContextItem>, previous_turn_settings: Option<&PreviousTurnSettings>, @@ -230,6 +257,7 @@ pub(crate) fn build_settings_update_items( build_model_instructions_update_item(previous_turn_settings, next), build_permissions_update_item(previous, next, exec_policy), build_collaboration_mode_update_item(previous, next), + build_multi_agent_mode_update_item(previous, next), build_realtime_update_item(previous, previous_turn_settings, next), build_personality_update_item(previous, next, personality_feature_enabled), ] diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 73193984b3..1ca097e783 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -15,6 +15,7 @@ use codex_protocol::models::is_image_open_tag_text; use codex_protocol::models::is_local_image_close_tag_text; use codex_protocol::models::is_local_image_open_tag_text; use codex_protocol::protocol::COLLABORATION_MODE_OPEN_TAG; +use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG; use codex_protocol::protocol::REALTIME_CONVERSATION_OPEN_TAG; use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG; use codex_protocol::user_input::UserInput; @@ -29,6 +30,7 @@ const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[ "", "", COLLABORATION_MODE_OPEN_TAG, + MULTI_AGENT_MODE_OPEN_TAG, REALTIME_CONVERSATION_OPEN_TAG, SKILLS_INSTRUCTIONS_OPEN_TAG, "", diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 5299747f3d..98aef25724 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -124,6 +124,7 @@ async fn thread_settings_update( summary, service_tier, collaboration_mode, + multi_agent_mode, personality, } = thread_settings; let collaboration_mode = match collaboration_mode { @@ -149,6 +150,7 @@ async fn thread_settings_update( active_permission_profile, windows_sandbox_level, collaboration_mode: Some(collaboration_mode), + multi_agent_mode, reasoning_summary: summary, service_tier, personality, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index fbd5a61bc3..8b7a15ff08 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -27,6 +27,7 @@ use crate::context::AvailablePluginsInstructions; use crate::context::AvailableSkillsInstructions; use crate::context::CollaborationModeInstructions; use crate::context::ContextualUserFragment; +use crate::context::MultiAgentModeInstructions; use crate::context::NetworkRuleSaved; use crate::context::PermissionsInstructions; use crate::context::PersonalitySpecInstructions; @@ -92,6 +93,7 @@ use codex_protocol::approvals::NetworkPolicyRuleAction; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::AutoCompactTokenLimitScope; use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::config_types::Settings; use codex_protocol::config_types::WebSearchMode; @@ -210,7 +212,7 @@ mod handlers; mod inject; mod input_queue; mod mcp; -mod multi_agents; +pub(crate) mod multi_agents; mod review; mod rollout_budget; mod rollout_reconstruction; @@ -581,6 +583,7 @@ impl Codex { .await; let multi_agent_version = resolve_multi_agent_version(&conversation_history, inherited_multi_agent_version); + let multi_agent_mode = conversation_history.get_multi_agent_mode(); config .validate_multi_agent_v2_config() .map_err(|err| CodexErr::InvalidRequest(err.to_string()))?; @@ -614,6 +617,7 @@ impl Codex { let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, + multi_agent_mode, model_reasoning_summary: config.model_reasoning_summary, service_tier, developer_instructions: config.developer_instructions.clone(), @@ -3240,6 +3244,17 @@ impl Session { { items.push(usage_hint_message); } + if let Some(multi_agent_mode) = multi_agents::effective_multi_agent_mode( + turn_context.multi_agent_version, + &turn_context.config.multi_agent_v2, + &session_source, + turn_context.multi_agent_mode, + turn_context.features.enabled(Feature::MultiAgentMode), + ) { + items.push(ContextualUserFragment::into( + MultiAgentModeInstructions::new(multi_agent_mode), + )); + } if let Some(contextual_user_message) = crate::context_manager::updates::build_contextual_user_message(contextual_user_sections) { diff --git a/codex-rs/core/src/session/multi_agents.rs b/codex-rs/core/src/session/multi_agents.rs index a78a9c938e..38651380eb 100644 --- a/codex-rs/core/src/session/multi_agents.rs +++ b/codex-rs/core/src/session/multi_agents.rs @@ -1,4 +1,6 @@ +use crate::config::MultiAgentV2Config; use crate::session::turn_context::TurnContext; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; @@ -16,6 +18,13 @@ pub(super) fn usage_hint_text<'a>( return None; } + configured_usage_hint_text_for_source(multi_agent_v2, session_source) +} + +fn configured_usage_hint_text_for_source<'a>( + multi_agent_v2: &'a MultiAgentV2Config, + session_source: &SessionSource, +) -> Option<&'a str> { match session_source { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. }) => { multi_agent_v2.subagent_usage_hint_text.as_deref() @@ -29,3 +38,31 @@ pub(super) fn usage_hint_text<'a>( SessionSource::Internal(_) | SessionSource::SubAgent(_) => None, } } + +fn multi_agent_mode_is_applicable( + multi_agent_version: MultiAgentVersion, + multi_agent_v2: &MultiAgentV2Config, + session_source: &SessionSource, +) -> bool { + multi_agent_version == MultiAgentVersion::V2 + && multi_agent_v2.usage_hint_enabled + && configured_usage_hint_text_for_source(multi_agent_v2, session_source).is_some() +} + +pub(crate) fn effective_multi_agent_mode( + multi_agent_version: MultiAgentVersion, + multi_agent_v2: &MultiAgentV2Config, + session_source: &SessionSource, + requested_multi_agent_mode: Option, + multi_agent_mode_enabled: bool, +) -> Option { + if !multi_agent_mode_is_applicable(multi_agent_version, multi_agent_v2, session_source) { + return None; + } + + Some(if multi_agent_mode_enabled { + requested_multi_agent_mode.unwrap_or_default() + } else { + MultiAgentMode::ExplicitRequestOnly + }) +} diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index bbdfc3f984..7fccc4d19f 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -123,6 +123,7 @@ pub(super) async fn spawn_review_thread( developer_instructions: None, user_instructions: None, collaboration_mode: parent_turn_context.collaboration_mode.clone(), + multi_agent_mode: parent_turn_context.multi_agent_mode, multi_agent_version: MultiAgentVersion::Disabled, personality: parent_turn_context.personality, approval_policy: parent_turn_context.approval_policy.clone(), diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 0b551ce728..43563d2339 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -102,6 +102,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, + multi_agent_mode: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -142,6 +143,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, + multi_agent_mode: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -1007,6 +1009,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, + multi_agent_mode: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -1090,6 +1093,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, + multi_agent_mode: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -1120,6 +1124,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, + multi_agent_mode: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -1243,6 +1248,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, + multi_agent_mode: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -1363,6 +1369,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, + multi_agent_mode: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -1527,6 +1534,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, + multi_agent_mode: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 1e8c250bc9..0c07f5e951 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -52,6 +52,7 @@ pub(crate) struct SessionConfiguration { pub(super) provider: ModelProviderInfo, pub(super) collaboration_mode: CollaborationMode, + pub(super) multi_agent_mode: Option, pub(super) model_reasoning_summary: Option, pub(super) service_tier: Option, @@ -192,6 +193,7 @@ impl SessionConfiguration { reasoning_summary: self.model_reasoning_summary, personality: self.personality, collaboration_mode: self.collaboration_mode.clone(), + multi_agent_mode: self.multi_agent_mode, session_source: self.session_source.clone(), forked_from_thread_id: self.forked_from_thread_id, parent_thread_id: self.parent_thread_id, @@ -228,6 +230,9 @@ impl SessionConfiguration { if let Some(collaboration_mode) = updates.collaboration_mode.clone() { next_configuration.collaboration_mode = collaboration_mode; } + if let Some(multi_agent_mode) = updates.multi_agent_mode { + next_configuration.multi_agent_mode = Some(multi_agent_mode); + } if let Some(summary) = updates.reasoning_summary { next_configuration.model_reasoning_summary = Some(summary); } @@ -422,6 +427,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) active_permission_profile: Option, pub(crate) windows_sandbox_level: Option, pub(crate) collaboration_mode: Option, + pub(crate) multi_agent_mode: Option, pub(crate) reasoning_summary: Option, pub(crate) service_tier: Option>, pub(crate) final_output_json_schema: Option>, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index e7532a1e57..6f82155304 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -2732,6 +2732,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, + multi_agent_mode: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -3349,6 +3350,7 @@ async fn set_rate_limits_retains_previous_credits() { let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, + multi_agent_mode: Default::default(), model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), loaded_agents_md: None, @@ -3455,6 +3457,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, + multi_agent_mode: Default::default(), model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), loaded_agents_md: None, @@ -3982,6 +3985,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, + multi_agent_mode: Default::default(), model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), loaded_agents_md: None, @@ -4848,6 +4852,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, + multi_agent_mode: Default::default(), model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), loaded_agents_md: None, @@ -4960,6 +4965,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, + multi_agent_mode: Default::default(), model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), loaded_agents_md: None, @@ -5205,6 +5211,7 @@ async fn make_session_with_config_and_rx( let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, + multi_agent_mode: Default::default(), model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), loaded_agents_md: None, @@ -5311,6 +5318,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, + multi_agent_mode: Default::default(), model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), loaded_agents_md: None, @@ -7017,6 +7025,7 @@ where let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, + multi_agent_mode: Default::default(), model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), loaded_agents_md: None, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index c03bdab7cf..18d56cdfec 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -125,6 +125,7 @@ pub struct TurnContext { pub(crate) developer_instructions: Option, pub(crate) user_instructions: Option, pub(crate) collaboration_mode: CollaborationMode, + pub(crate) multi_agent_mode: Option, pub(crate) multi_agent_version: MultiAgentVersion, pub(crate) personality: Option, pub(crate) approval_policy: Constrained, @@ -276,6 +277,7 @@ impl TurnContext { developer_instructions: self.developer_instructions.clone(), user_instructions: self.user_instructions.clone(), collaboration_mode, + multi_agent_mode: self.multi_agent_mode, multi_agent_version: self.multi_agent_version, personality: self.personality, approval_policy: self.approval_policy.clone(), @@ -381,6 +383,13 @@ impl TurnContext { personality: self.personality, collaboration_mode: Some(self.collaboration_mode.clone()), multi_agent_version: Some(self.multi_agent_version), + multi_agent_mode: super::multi_agents::effective_multi_agent_mode( + self.multi_agent_version, + &self.config.multi_agent_v2, + &self.session_source, + self.multi_agent_mode, + self.features.enabled(Feature::MultiAgentMode), + ), realtime_active: Some(self.realtime_active), effort: self.reasoning_effort.clone(), summary: ReasoningSummaryConfig::Auto, @@ -562,6 +571,7 @@ impl Session { .as_ref() .map(LoadedAgentsMd::render), collaboration_mode: session_configuration.collaboration_mode.clone(), + multi_agent_mode: session_configuration.multi_agent_mode, multi_agent_version, personality: session_configuration.personality, approval_policy: session_configuration.approval_policy.clone(), diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index f6e84f5ca2..800b52c775 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -73,6 +73,7 @@ mod model_switching; mod model_visible_layout; mod models_cache_ttl; mod models_etag_responses; +mod multi_agent_mode; mod openai_file_mcp; mod otel; mod override_updates; diff --git a/codex-rs/core/tests/suite/multi_agent_mode.rs b/codex-rs/core/tests/suite/multi_agent_mode.rs new file mode 100644 index 0000000000..ba91f2fd31 --- /dev/null +++ b/codex-rs/core/tests/suite/multi_agent_mode.rs @@ -0,0 +1,376 @@ +use anyhow::Result; +use codex_features::Feature; +use codex_protocol::config_types::MultiAgentMode; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use serde_json::Value; + +const NO_SPAWN_TEXT: &str = "Do not spawn sub-agents unless the user explicitly asks for sub-agents, delegation, or parallel agent work."; +const PROACTIVE_TEXT: &str = "Proactive multi-agent delegation is active."; + +fn developer_texts(input: &[Value]) -> Vec<&str> { + input + .iter() + .filter(|item| item.get("role").and_then(Value::as_str) == Some("developer")) + .filter_map(|item| item.get("content")?.as_array()) + .flatten() + .filter_map(|content| content.get("text")?.as_str()) + .collect() +} + +fn count_containing(texts: &[&str], target: &str) -> usize { + texts.iter().filter(|text| text.contains(target)).count() +} + +async fn submit_turn( + codex: &codex_core::CodexThread, + prompt: &str, + mode: Option, +) -> Result<()> { + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: prompt.to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: ThreadSettingsOverrides { + multi_agent_mode: mode, + ..Default::default() + }, + }) + .await?; + wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn multi_agent_mode_is_sticky_and_emits_only_on_change() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let responses = mount_sse_sequence( + &server, + (1..=3) + .map(|index| { + sse(vec![ + ev_response_created(&format!("resp-{index}")), + ev_completed(&format!("resp-{index}")), + ]) + }) + .collect(), + ) + .await; + let test = test_codex() + .with_config(|config| { + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::MultiAgentMode) + .expect("test config should allow feature update"); + }) + .build(&server) + .await?; + + submit_turn(&test.codex, "turn one", /*mode*/ None).await?; + assert_eq!(test.codex.config_snapshot().await.multi_agent_mode, None); + submit_turn(&test.codex, "turn two", Some(MultiAgentMode::Proactive)).await?; + submit_turn(&test.codex, "turn three", /*mode*/ None).await?; + + let requests = responses.requests(); + let inputs = requests + .iter() + .map(core_test_support::responses::ResponsesRequest::input) + .collect::>(); + let first = developer_texts(&inputs[0]); + let second = developer_texts(&inputs[1]); + let third = developer_texts(&inputs[2]); + + assert_eq!( + ( + count_containing(&first, MULTI_AGENT_MODE_OPEN_TAG), + count_containing(&first, NO_SPAWN_TEXT), + count_containing(&first, PROACTIVE_TEXT), + ), + (1, 1, 0) + ); + assert_eq!( + ( + count_containing(&second, MULTI_AGENT_MODE_OPEN_TAG), + count_containing(&second, NO_SPAWN_TEXT), + count_containing(&second, PROACTIVE_TEXT), + ), + (2, 1, 1) + ); + assert_eq!( + ( + count_containing(&third, MULTI_AGENT_MODE_OPEN_TAG), + count_containing(&third, NO_SPAWN_TEXT), + count_containing(&third, PROACTIVE_TEXT), + ), + (2, 1, 1) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn multi_agent_mode_feature_uses_explicit_mode_when_disabled() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let responses = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + let test = test_codex() + .with_config(|config| { + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + }) + .build(&server) + .await?; + + submit_turn(&test.codex, "hello", /*mode*/ None).await?; + + let input = responses.single_request().input(); + let texts = developer_texts(&input); + assert_eq!( + ( + count_containing(&texts, MULTI_AGENT_MODE_OPEN_TAG), + count_containing(&texts, NO_SPAWN_TEXT), + count_containing(&texts, PROACTIVE_TEXT), + ), + (1, 1, 0) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn resume_compares_against_previous_effective_multi_agent_mode() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let responses = mount_sse_sequence( + &server, + (1..=4) + .map(|index| { + sse(vec![ + ev_response_created(&format!("resp-{index}")), + ev_completed(&format!("resp-{index}")), + ]) + }) + .collect(), + ) + .await; + let initial = test_codex() + .with_config(|config| { + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + }) + .build(&server) + .await?; + let home = initial.home.clone(); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); + + submit_turn( + &initial.codex, + "before resume", + Some(MultiAgentMode::Proactive), + ) + .await?; + drop(initial); + + let mut resume_builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::MultiAgentMode) + .expect("test config should allow feature update"); + }); + let resumed = resume_builder.resume(&server, home, rollout_path).await?; + submit_turn( + &resumed.codex, + "after resume", + Some(MultiAgentMode::Proactive), + ) + .await?; + + let requests = responses.requests(); + let resumed_input = requests[1].input(); + let texts = developer_texts(&resumed_input); + assert_eq!( + ( + count_containing(&texts, MULTI_AGENT_MODE_OPEN_TAG), + count_containing(&texts, NO_SPAWN_TEXT), + count_containing(&texts, PROACTIVE_TEXT), + ), + (2, 1, 1) + ); + + let resumed_rollout_path = resumed + .session_configured + .rollout_path + .clone() + .expect("resumed rollout path"); + let resumed_home = resumed.home.clone(); + drop(resumed); + let mut same_mode_resume_builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::MultiAgentMode) + .expect("test config should allow feature update"); + }); + let resumed_same_mode = same_mode_resume_builder + .resume(&server, resumed_home, resumed_rollout_path) + .await?; + submit_turn( + &resumed_same_mode.codex, + "after same-mode resume", + /*mode*/ None, + ) + .await?; + + assert_eq!( + resumed_same_mode + .codex + .config_snapshot() + .await + .multi_agent_mode, + Some(MultiAgentMode::Proactive) + ); + let requests = responses.requests(); + let resumed_same_mode_input = requests[2].input(); + let texts = developer_texts(&resumed_same_mode_input); + assert_eq!( + ( + count_containing(&texts, MULTI_AGENT_MODE_OPEN_TAG), + count_containing(&texts, NO_SPAWN_TEXT), + count_containing(&texts, PROACTIVE_TEXT), + ), + (2, 1, 1) + ); + + let resumed_same_mode_rollout_path = resumed_same_mode + .session_configured + .rollout_path + .clone() + .expect("same-mode resumed rollout path"); + let resumed_same_mode_home = resumed_same_mode.home.clone(); + drop(resumed_same_mode); + let mut disabled_mode_resume_builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + }); + let resumed_disabled_mode = disabled_mode_resume_builder + .resume( + &server, + resumed_same_mode_home, + resumed_same_mode_rollout_path, + ) + .await?; + submit_turn( + &resumed_disabled_mode.codex, + "after disabled-mode resume", + /*mode*/ None, + ) + .await?; + + assert_eq!( + resumed_disabled_mode + .codex + .config_snapshot() + .await + .multi_agent_mode, + Some(MultiAgentMode::Proactive) + ); + let requests = responses.requests(); + let resumed_disabled_mode_input = requests[3].input(); + let texts = developer_texts(&resumed_disabled_mode_input); + assert_eq!( + ( + count_containing(&texts, MULTI_AGENT_MODE_OPEN_TAG), + count_containing(&texts, NO_SPAWN_TEXT), + count_containing(&texts, PROACTIVE_TEXT), + ), + (3, 2, 1) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn explicit_multi_agent_mode_is_retained_without_multi_agent_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let responses = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + let test = test_codex() + .with_config(|config| { + config + .features + .enable(Feature::MultiAgentMode) + .expect("test config should allow feature update"); + }) + .build(&server) + .await?; + + submit_turn(&test.codex, "hello", Some(MultiAgentMode::Proactive)).await?; + + assert_eq!( + test.codex.config_snapshot().await.multi_agent_mode, + Some(MultiAgentMode::Proactive) + ); + let input = responses.single_request().input(); + let texts = developer_texts(&input); + assert_eq!( + ( + count_containing(&texts, MULTI_AGENT_MODE_OPEN_TAG), + count_containing(&texts, PROACTIVE_TEXT), + ), + (0, 0) + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0b9ff204d4..073e6d8b49 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -41,6 +41,7 @@ fn resume_history( personality: None, collaboration_mode: None, multi_agent_version: None, + multi_agent_mode: None, realtime_active: None, effort: config.model_reasoning_effort.clone(), summary: config diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 04f0029fa6..a04e258552 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -891,6 +891,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { personality: None, output_schema, collaboration_mode: None, + multi_agent_mode: None, }, }, "turn/start", diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 8483ef97bd..6dc8827fe0 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -144,6 +144,8 @@ pub enum Feature { Collab, /// Enable task-path-based multi-agent routing. MultiAgentV2, + /// Enable per-turn multi-agent mode selection. + MultiAgentMode, /// Enable CSV-backed agent job tools. SpawnCsv, /// Enable apps. @@ -1018,6 +1020,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::MultiAgentMode, + key: "multi_agent_mode", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::SpawnCsv, key: "enable_fanout", diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 713945dbde..64cfefeb73 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -296,6 +296,20 @@ pub enum Personality { Pragmatic, } +/// Controls whether the model should only spawn sub-agents after an explicit +/// user request or may delegate proactively when doing so would help. +#[derive( + Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS, Default, +)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum MultiAgentMode { + #[default] + ExplicitRequestOnly, + Proactive, +} + #[derive( Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS, Default, )] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 99aa0a0b69..bc0186bc64 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -21,6 +21,7 @@ use crate::approvals::ElicitationRequestEvent; use crate::config_types::ApprovalsReviewer; use crate::config_types::CollaborationMode; use crate::config_types::ModeKind; +use crate::config_types::MultiAgentMode; use crate::config_types::Personality; use crate::config_types::ReasoningSummary as ReasoningSummaryConfig; use crate::config_types::WindowsSandboxLevel; @@ -104,6 +105,8 @@ pub const PLUGINS_INSTRUCTIONS_OPEN_TAG: &str = ""; pub const PLUGINS_INSTRUCTIONS_CLOSE_TAG: &str = ""; pub const COLLABORATION_MODE_OPEN_TAG: &str = ""; pub const COLLABORATION_MODE_CLOSE_TAG: &str = ""; +pub const MULTI_AGENT_MODE_OPEN_TAG: &str = ""; +pub const MULTI_AGENT_MODE_CLOSE_TAG: &str = ""; pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; @@ -479,6 +482,9 @@ pub struct ThreadSettingsOverrides { /// Takes precedence over model, effort, and developer instructions if set. pub collaboration_mode: Option, + /// Updated multi-agent mode for this turn and subsequent turns. + pub multi_agent_mode: Option, + /// Updated personality preference. pub personality: Option, } @@ -2548,6 +2554,14 @@ impl InitialHistory { } } + pub fn get_multi_agent_mode(&self) -> Option { + match self { + InitialHistory::New | InitialHistory::Cleared => None, + InitialHistory::Resumed(resumed) => multi_agent_mode_from_items(&resumed.history), + InitialHistory::Forked(items) => multi_agent_mode_from_items(items), + } + } + pub fn get_resumed_session_sources(&self) -> Option<(SessionSource, Option)> { let meta = self.get_resumed_session_meta()?; Some((meta.source.clone(), meta.thread_source.clone())) @@ -2860,6 +2874,17 @@ fn multi_agent_version_from_items( }) } +fn multi_agent_mode_from_items(items: &[RolloutItem]) -> Option { + items.iter().rev().find_map(|item| match item { + RolloutItem::TurnContext(turn_context) => turn_context.multi_agent_mode, + RolloutItem::SessionMeta(_) + | RolloutItem::ResponseItem(_) + | RolloutItem::InterAgentCommunication(_) + | RolloutItem::Compacted(_) + | RolloutItem::EventMsg(_) => None, + }) +} + #[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(rename_all = "snake_case")] @@ -3027,6 +3052,9 @@ pub struct TurnContextItem { pub collaboration_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub multi_agent_version: Option, + /// Effective model-visible mode used as the durable context-diff baseline. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub multi_agent_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub realtime_active: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -5399,6 +5427,7 @@ mod tests { personality: None, collaboration_mode: None, multi_agent_version: None, + multi_agent_mode: None, realtime_active: None, effort: None, summary: ReasoningSummaryConfig::Auto, diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 2558750f15..8701578ebf 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -1152,6 +1152,7 @@ async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Re personality: None, collaboration_mode: None, multi_agent_version: None, + multi_agent_mode: None, realtime_active: None, effort: None, summary: codex_protocol::config_types::ReasoningSummary::Auto, diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 4d25965c44..e156cc9113 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -368,6 +368,7 @@ mod tests { personality: None, collaboration_mode: None, multi_agent_version: None, + multi_agent_mode: None, realtime_active: None, effort: None, summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -412,6 +413,7 @@ mod tests { personality: None, collaboration_mode: None, multi_agent_version: None, + multi_agent_mode: None, realtime_active: None, effort: None, summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -452,6 +454,7 @@ mod tests { personality: None, collaboration_mode: None, multi_agent_version: None, + multi_agent_mode: None, realtime_active: None, effort: Some(ReasoningEffort::High), summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -489,6 +492,7 @@ mod tests { personality: None, collaboration_mode: None, multi_agent_version: None, + multi_agent_mode: None, realtime_active: None, effort: Some(ReasoningEffort::High), summary: codex_protocol::config_types::ReasoningSummary::Auto, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index a3734ac0e2..365ee92fca 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -786,6 +786,7 @@ impl AppServerSession { personality, output_schema, collaboration_mode, + multi_agent_mode: None, }, }) .await From 7abfcf220bbb57029e2ff5d9914124aef7ef3d0f Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Fri, 19 Jun 2026 01:50:44 -0700 Subject: [PATCH 070/112] Expose thread-level multi-agent mode (#28792) ## Why Once multi-agent mode can be selected per turn, clients also need to choose the initial selection when creating a thread and observe that selection through lifecycle and settings APIs. The selected value is intentionally distinct from the effective model-visible value: no client selection is represented as `null`, even though an eligible multi-agent v2 turn derives `explicitRequestOnly` as its effective default. ## What changed - Add the optional experimental `thread/start.multiAgentMode` parameter and pass it through thread creation. - Preserve an omitted initial value as an unset selection rather than eagerly storing `explicitRequestOnly`. - Apply an explicit `thread/start` selection to the first turn through the session configuration established at thread creation. - Restore the latest persisted effective mode as the selected baseline on cold resume when rollout history contains one. - Inherit the optional selected mode from a loaded parent when creating related runtime threads. - Return the current selected `multiAgentMode` from `thread/start`, `thread/resume`, `thread/fork`, and thread settings, using `null` when no mode is selected. - Keep lifecycle reporting independent from model capability and feature eligibility; core turn construction remains responsible for calculating and persisting the effective mode. ## Not covered - Clearing an existing loaded-session selection back to unset through `turn/start`; omitted or `null` currently retains the session's selection. - A TUI control, slash command, or `config.toml` preference. ## Verification - `CARGO_INCREMENTAL=0 just test -p codex-app-server-protocol` - `CARGO_INCREMENTAL=0 just test -p codex-app-server multi_agent_mode` The focused app-server coverage verifies explicit `thread/start` initialization, first-turn prompting, nullable reporting for an omitted selection, and retention of selections that are not currently runtime-eligible. ## Stack Stacked on #28685. This PR contains only the thread initialization and lifecycle/settings API layer. --- .../analytics/src/analytics_client_tests.rs | 2 + codex-rs/analytics/src/client_tests.rs | 3 + .../schema/json/ServerNotification.json | 8 ++ .../schema/json/v2/ThreadForkResponse.json | 8 ++ .../schema/json/v2/ThreadResumeResponse.json | 8 ++ .../v2/ThreadSettingsUpdatedNotification.json | 8 ++ .../schema/json/v2/ThreadStartParams.json | 8 ++ .../schema/json/v2/ThreadStartResponse.json | 8 ++ .../schema/typescript/v2/ThreadSettings.ts | 2 +- .../src/protocol/common.rs | 5 +- .../src/protocol/v2/tests.rs | 30 ++++ .../src/protocol/v2/thread.rs | 29 +++- codex-rs/app-server/README.md | 10 +- .../request_processors/thread_lifecycle.rs | 2 + .../request_processors/thread_processor.rs | 8 ++ .../src/request_processors/thread_summary.rs | 4 +- .../src/request_processors/turn_processor.rs | 2 +- codex-rs/app-server/src/thread_state.rs | 1 + .../app-server/tests/suite/v2/skills_list.rs | 1 + .../tests/suite/v2/thread_settings_update.rs | 109 ++++++++++++++ .../app-server/tests/suite/v2/turn_start.rs | 135 ++++++++++++++++++ codex-rs/core/src/agent/control.rs | 2 + .../core/src/agent/control/residency_tests.rs | 1 + codex-rs/core/src/agent/control/spawn.rs | 6 + codex-rs/core/src/agent/control_tests.rs | 108 +++++++++++++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/context_manager/updates.rs | 2 +- codex-rs/core/src/session/handlers.rs | 1 + codex-rs/core/src/session/mod.rs | 9 +- .../core/src/session/tests/guardian_tests.rs | 1 + codex-rs/core/src/session/turn_context.rs | 2 +- codex-rs/core/src/thread_manager.rs | 34 ++++- codex-rs/core/src/thread_manager_tests.rs | 5 + .../src/tools/handlers/multi_agents/spawn.rs | 1 + .../src/tools/handlers/multi_agents_tests.rs | 10 ++ .../tools/handlers/multi_agents_v2/spawn.rs | 10 ++ codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/agents_md.rs | 2 + .../tests/suite/subagent_notifications.rs | 1 + codex-rs/exec/src/lib_tests.rs | 1 + codex-rs/memories/write/src/runtime.rs | 1 + codex-rs/protocol/src/protocol.rs | 62 +++++--- .../tui/src/app/app_server_event_targets.rs | 1 + codex-rs/tui/src/app/tests.rs | 1 + codex-rs/tui/src/app_server_session.rs | 1 + .../tui/src/chatwidget/tests/app_server.rs | 1 + 46 files changed, 620 insertions(+), 36 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index dac4829ab3..a9a1d90e74 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -224,6 +224,7 @@ fn sample_thread_start_response( sandbox: AppServerSandboxPolicy::DangerFullAccess, active_permission_profile: None, reasoning_effort: None, + multi_agent_mode: Default::default(), }) } @@ -288,6 +289,7 @@ fn sample_thread_resume_response_with_source( sandbox: AppServerSandboxPolicy::DangerFullAccess, active_permission_profile: None, reasoning_effort: None, + multi_agent_mode: Default::default(), initial_turns_page: None, }) } diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index 18bb8c2159..a14fe7bf2d 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -314,6 +314,7 @@ fn sample_thread_start_response() -> ClientResponsePayload { sandbox: AppServerSandboxPolicy::DangerFullAccess, active_permission_profile: None, reasoning_effort: None, + multi_agent_mode: Default::default(), }) } @@ -331,6 +332,7 @@ fn sample_thread_resume_response() -> ClientResponsePayload { sandbox: AppServerSandboxPolicy::DangerFullAccess, active_permission_profile: None, reasoning_effort: None, + multi_agent_mode: Default::default(), initial_turns_page: None, }) } @@ -349,6 +351,7 @@ fn sample_thread_fork_response() -> ClientResponsePayload { sandbox: AppServerSandboxPolicy::DangerFullAccess, active_permission_profile: None, reasoning_effort: None, + multi_agent_mode: Default::default(), }) } diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 8509a1c69d..6ba699dce7 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2611,6 +2611,14 @@ ], "type": "object" }, + "MultiAgentMode": { + "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "enum": [ + "explicitRequestOnly", + "proactive" + ], + "type": "string" + }, "NetworkAccess": { "enum": [ "restricted", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index bebd194497..11efbb7e82 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -662,6 +662,14 @@ } ] }, + "MultiAgentMode": { + "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "enum": [ + "explicitRequestOnly", + "proactive" + ], + "type": "string" + }, "NetworkAccess": { "enum": [ "restricted", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 73e228845d..de242b62b3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -662,6 +662,14 @@ } ] }, + "MultiAgentMode": { + "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "enum": [ + "explicitRequestOnly", + "proactive" + ], + "type": "string" + }, "NetworkAccess": { "enum": [ "restricted", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json index fbcaee3ee8..67a0f5c5d9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json @@ -108,6 +108,14 @@ ], "type": "string" }, + "MultiAgentMode": { + "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "enum": [ + "explicitRequestOnly", + "proactive" + ], + "type": "string" + }, "NetworkAccess": { "enum": [ "restricted", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index d4291f6dc1..eebb5da960 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -194,6 +194,14 @@ "LegacyAppPathString": { "type": "string" }, + "MultiAgentMode": { + "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "enum": [ + "explicitRequestOnly", + "proactive" + ], + "type": "string" + }, "Personality": { "enum": [ "none", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 726088c0d9..4ad961c22c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -662,6 +662,14 @@ } ] }, + "MultiAgentMode": { + "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "enum": [ + "explicitRequestOnly", + "proactive" + ], + "type": "string" + }, "NetworkAccess": { "enum": [ "restricted", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts index bcfd0ad86c..b034ea80bb 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts @@ -11,4 +11,4 @@ import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; -export type ThreadSettings = { cwd: AbsolutePathBuf, approvalPolicy: AskForApproval, approvalsReviewer: ApprovalsReviewer, sandboxPolicy: SandboxPolicy, activePermissionProfile: ActivePermissionProfile | null, model: string, modelProvider: string, serviceTier: string | null, effort: ReasoningEffort | null, summary: ReasoningSummary | null, collaborationMode: CollaborationMode, personality: Personality | null, }; +export type ThreadSettings = {cwd: AbsolutePathBuf, approvalPolicy: AskForApproval, approvalsReviewer: ApprovalsReviewer, sandboxPolicy: SandboxPolicy, activePermissionProfile: ActivePermissionProfile | null, model: string, modelProvider: string, serviceTier: string | null, effort: ReasoningEffort | null, summary: ReasoningSummary | null, collaborationMode: CollaborationMode, personality: Personality | null}; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 643afcc58d..2f322aa5ff 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2583,6 +2583,7 @@ mod tests { sandbox: v2::SandboxPolicy::DangerFullAccess, active_permission_profile: None, reasoning_effort: None, + multi_agent_mode: None, }, }; @@ -2630,7 +2631,8 @@ mod tests { "type": "dangerFullAccess" }, "activePermissionProfile": null, - "reasoningEffort": null + "reasoningEffort": null, + "multiAgentMode": null } }), serde_json::to_value(&response)?, @@ -3545,6 +3547,7 @@ mod tests { developer_instructions: None, }, }, + multi_agent_mode: Default::default(), personality: None, }, }); diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 96cbb1ed23..0161cd4ef9 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -199,6 +199,7 @@ fn thread_resume_response_round_trips_initial_turns_page() { sandbox: SandboxPolicy::DangerFullAccess, active_permission_profile: None, reasoning_effort: None, + multi_agent_mode: Default::default(), initial_turns_page: Some(TurnsPage { data: Vec::new(), next_cursor: Some("cursor_next".to_string()), @@ -3701,6 +3702,14 @@ fn thread_lifecycle_responses_default_missing_optional_fields() { assert_eq!(resume.active_permission_profile, None); assert_eq!(resume.initial_turns_page, None); assert_eq!(fork.active_permission_profile, None); + assert_eq!( + ( + start.multi_agent_mode, + resume.multi_agent_mode, + fork.multi_agent_mode, + ), + (None, None, None) + ); let foreign_source: LegacyAppPathString = serde_json::from_value(json!(r"C:\workspace\AGENTS.md")).expect("foreign source"); @@ -3805,6 +3814,27 @@ fn turn_start_params_round_trip_multi_agent_mode() { ); } +#[test] +fn thread_start_params_round_trip_multi_agent_mode() { + let params: ThreadStartParams = serde_json::from_value(json!({ + "multiAgentMode": "proactive" + })) + .expect("params should deserialize"); + + assert_eq!( + params.multi_agent_mode, + Some(codex_protocol::config_types::MultiAgentMode::Proactive) + ); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(¶ms), + Some("thread/start.multiAgentMode") + ); + assert_eq!( + serde_json::to_value(params).expect("params should serialize")["multiAgentMode"], + "proactive" + ); +} + #[test] fn thread_settings_update_params_preserve_explicit_null_service_tier() { let params: ThreadSettingsUpdateParams = serde_json::from_value(json!({ diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 59adcbaa85..3c527d6219 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -14,6 +14,7 @@ use codex_experimental_api_macros::ExperimentalApi; pub use codex_protocol::capabilities::CapabilityRootLocation; pub use codex_protocol::capabilities::SelectedCapabilityRoot; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; pub use codex_protocol::dynamic_tools::DynamicToolFunctionSpec; @@ -93,6 +94,12 @@ pub struct ThreadStartParams { pub developer_instructions: Option, #[ts(optional = nullable)] pub personality: Option, + /// Set the initial multi-agent mode for this thread. + /// Omitted leaves the thread without a selected mode. Eligible multi-agent + /// v2 turns still default to `explicitRequestOnly`. + #[experimental("thread/start.multiAgentMode")] + #[ts(optional = nullable)] + pub multi_agent_mode: Option, #[ts(optional = nullable)] pub ephemeral: Option, #[ts(optional = nullable)] @@ -179,6 +186,10 @@ pub struct ThreadStartResponse { #[serde(default)] pub active_permission_profile: Option, pub reasoning_effort: Option, + /// Current selected multi-agent mode for this thread, if one was selected. + #[experimental("thread/start.multiAgentMode")] + #[serde(default)] + pub multi_agent_mode: Option, } impl ThreadStartResponse { @@ -239,6 +250,10 @@ pub struct ThreadSettingsUpdateParams { #[experimental("thread/settings/update.collaborationMode")] #[ts(optional = nullable)] pub collaboration_mode: Option, + /// Select the multi-agent mode for subsequent turns. + #[experimental("thread/settings/update.multiAgentMode")] + #[ts(optional = nullable)] + pub multi_agent_mode: Option, /// Override the personality for subsequent turns. #[ts(optional = nullable)] pub personality: Option, @@ -249,7 +264,7 @@ pub struct ThreadSettingsUpdateParams { #[ts(export_to = "v2/")] pub struct ThreadSettingsUpdateResponse {} -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct ThreadSettings { @@ -264,6 +279,10 @@ pub struct ThreadSettings { pub effort: Option, pub summary: Option, pub collaboration_mode: CollaborationMode, + /// Current selected multi-agent mode for this thread, if one was selected. + #[experimental("thread/settings.multiAgentMode")] + #[serde(default)] + pub multi_agent_mode: Option, pub personality: Option, } @@ -400,6 +419,10 @@ pub struct ThreadResumeResponse { #[serde(default)] pub active_permission_profile: Option, pub reasoning_effort: Option, + /// Current selected multi-agent mode for this thread, if one was selected. + #[experimental("thread/resume.multiAgentMode")] + #[serde(default)] + pub multi_agent_mode: Option, /// `thread/turns/list` page returned when requested by `initialTurnsPage`. #[experimental("thread/resume.initialTurnsPage")] #[serde(default)] @@ -555,6 +578,10 @@ pub struct ThreadForkResponse { #[serde(default)] pub active_permission_profile: Option, pub reasoning_effort: Option, + /// Current selected multi-agent mode for this thread, if one was selected. + #[experimental("thread/fork.multiAgentMode")] + #[serde(default)] + pub multi_agent_mode: Option, } impl ThreadForkResponse { diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 5914c3a858..f8ceb69d42 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -137,17 +137,17 @@ Example with notification opt-out: ## API Overview -- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are also started in that environment; HTTP MCP declarations remain inactive. -- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. +- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `multiAgentMode` selects the initial thread mode; omission leaves the selected mode unset, while eligible multi-agent v2 turns still default to `explicitRequestOnly`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are also started in that environment; HTTP MCP declarations remain inactive. +- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. Multi-agent mode restores the last effective mode from rollout history when available; clients can select another mode on the first `turn/start`. - `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`. -- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. `instructionSources` lists loaded instruction files using each source environment's native absolute path syntax, including files loaded from remote environments. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. +- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. `instructionSources` lists loaded instruction files using each source environment's native absolute path syntax, including files loaded from remote environments. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. Their experimental `multiAgentMode` field, and the corresponding thread setting, report the thread's current selected mode or `null` when no mode was selected. Turn construction separately determines whether that mode is applicable to the selected model and runtime configuration. - `thread/list` — page through stored threads; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Experimental clients can use `parentThreadId` to filter direct spawned children represented by persisted spawn-edge state. Review and Guardian threads are not included because they do not participate in that spawn-edge lifecycle. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. Subagent threads also include `parentThreadId` when the immediate parent is known. - `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. - `thread/turns/list` — experimental; page through a stored thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `itemsView`, `nextCursor`, and `backwardsCursor`. - `thread/turns/items/list` — experimental; reserved for paging full items for one turn. The API shape is present, but app-server currently returns an unsupported-method JSON-RPC error. - `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`. -- `thread/settings/update` — experimental; queue a partial update to a loaded thread’s next-turn settings without starting a turn or adding transcript items. Omitted fields leave settings unchanged; `serviceTier: null` clears the tier; `sandboxPolicy` and `permissions` cannot be combined. Returns `{}` when the update is accepted and emits `thread/settings/updated` with the full effective settings only if they actually change. `turn/start` settings overrides emit the same notification when they change the stored settings. +- `thread/settings/update` — experimental; queue a partial update to a loaded thread’s next-turn settings without starting a turn or adding transcript items. Omitted fields leave settings unchanged; `serviceTier: null` clears the tier; `multiAgentMode` selects `explicitRequestOnly` or `proactive` for subsequent turns; `sandboxPolicy` and `permissions` cannot be combined. Returns `{}` when the update is accepted and emits `thread/settings/updated` with the full effective settings only if they actually change. `turn/start` settings overrides emit the same notification when they change the stored settings. - `thread/memoryMode/set` — experimental; set a thread’s persisted memory eligibility to `"enabled"` or `"disabled"` for either a loaded thread or a stored rollout; returns `{}` on success. - `memory/reset` — experimental; clear the current `CODEX_HOME/memories` directory and reset persisted memory stage data in sqlite while preserving existing thread memory modes; returns `{}` on success. - `thread/goal/set` — create or update the single persisted goal for a materialized thread; returns the current goal and emits `thread/goal/updated`. @@ -168,7 +168,7 @@ Example with notification opt-out: - `thread/backgroundTerminals/list` — list running background terminals for a loaded thread (experimental; requires `capabilities.experimentalApi`); returns `data` with the running terminal ids. - `thread/backgroundTerminals/terminate` — terminate one running background terminal by app-server `processId` (experimental; requires `capabilities.experimentalApi`); returns whether a process was terminated. - `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. -- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". Experimental `multiAgentMode` accepts `explicitRequestOnly` or `proactive`; omission keeps the loaded session's current mode. The requested mode is retained for the loaded session without rejecting unsupported configurations. Eligible multi-agent v2 turns use the requested mode when `features.multi_agent_mode` is enabled and otherwise use explicit-request-only developer instructions. +- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". Experimental `multiAgentMode` accepts `explicitRequestOnly` or `proactive`; omission keeps the loaded session's current selected mode, including an unset mode. The requested mode is retained for the loaded session without rejecting unsupported configurations. Eligible multi-agent v2 turns default an unset mode to explicit-request-only, use the selected mode when `features.multi_agent_mode` is enabled, and otherwise use explicit-request-only developer instructions. - `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success. - `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Review and manual compaction turns reject `turn/steer`. - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. diff --git a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs index fd9e93e189..21adf46134 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -639,6 +639,7 @@ pub(super) async fn handle_pending_thread_resume_request( active_permission_profile, workspace_roots, reasoning_effort, + multi_agent_mode, .. } = config_snapshot; let instruction_sources = pending.instruction_sources; @@ -661,6 +662,7 @@ pub(super) async fn handle_pending_thread_resume_request( sandbox, active_permission_profile, reasoning_effort, + multi_agent_mode, initial_turns_page, }; outgoing.send_response(request_id, response).await; diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 1aa6e4eeac..a3e4b49ce8 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -2,6 +2,7 @@ use super::*; use crate::error_code::method_not_found; use codex_app_server_protocol::SelectedCapabilityRoot; use codex_extension_api::ExtensionDataInit; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; @@ -902,6 +903,7 @@ impl ThreadRequestProcessor { mock_experimental_field: _mock_experimental_field, experimental_raw_events, personality, + multi_agent_mode, ephemeral, session_start_source, thread_source, @@ -955,6 +957,7 @@ impl ThreadRequestProcessor { supports_openai_form_elicitation, config, typesafe_overrides, + multi_agent_mode, dynamic_tools, selected_capability_roots.unwrap_or_default(), session_start_source, @@ -1029,6 +1032,7 @@ impl ThreadRequestProcessor { supports_openai_form_elicitation: bool, config_overrides: Option>, typesafe_overrides: ConfigOverrides, + multi_agent_mode: Option, dynamic_tools: Option>, selected_capability_roots: Vec, session_start_source: Option, @@ -1152,6 +1156,7 @@ impl ThreadRequestProcessor { thread_source, dynamic_tools, metrics_service_name: service_name, + multi_agent_mode, parent_trace: request_trace, environments, thread_extension_init, @@ -1257,6 +1262,7 @@ impl ThreadRequestProcessor { sandbox, active_permission_profile, reasoning_effort: config_snapshot.reasoning_effort, + multi_agent_mode: config_snapshot.multi_agent_mode, }; let notif = thread_started_notification(thread); listener_task_context @@ -2787,6 +2793,7 @@ impl ThreadRequestProcessor { sandbox, active_permission_profile, reasoning_effort: session_configured.reasoning_effort, + multi_agent_mode: config_snapshot.multi_agent_mode, initial_turns_page, }; @@ -3508,6 +3515,7 @@ impl ThreadRequestProcessor { sandbox, active_permission_profile, reasoning_effort: session_configured.reasoning_effort, + multi_agent_mode: config_snapshot.multi_agent_mode, }; let notif = thread_started_notification(thread); diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index 67d2d9eabc..bb2c6708cd 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -1,5 +1,4 @@ use super::*; - #[cfg(test)] use chrono::DateTime; #[cfg(test)] @@ -206,6 +205,7 @@ pub(crate) fn thread_settings_from_config_snapshot( effort: config_snapshot.reasoning_effort.clone(), summary: config_snapshot.reasoning_summary, collaboration_mode: config_snapshot.collaboration_mode.clone(), + multi_agent_mode: config_snapshot.multi_agent_mode, personality: config_snapshot.personality, } } @@ -226,6 +226,7 @@ pub(crate) fn thread_settings_from_core_snapshot( reasoning_summary, personality, collaboration_mode, + multi_agent_mode, } = snapshot; let sandbox_policy = thread_response_sandbox_policy(&permission_profile, cwd.as_path()); ThreadSettings { @@ -242,6 +243,7 @@ pub(crate) fn thread_settings_from_core_snapshot( effort: reasoning_effort, summary: reasoning_summary, collaboration_mode, + multi_agent_mode, personality, } } diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index c6a739afcb..7b5cafe0aa 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -729,7 +729,7 @@ impl TurnRequestProcessor { effort: params.effort, summary: params.summary, collaboration_mode: params.collaboration_mode, - multi_agent_mode: None, + multi_agent_mode: params.multi_agent_mode, personality: params.personality, }, ) diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 91de76b99c..e4e4e42adf 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -241,6 +241,7 @@ mod tests { developer_instructions: None, }, }, + multi_agent_mode: Default::default(), personality: None, } } diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index 491c060850..ef7ee6c1d1 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -789,6 +789,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<( base_instructions: None, developer_instructions: None, personality: None, + multi_agent_mode: None, ephemeral: None, session_start_source: None, thread_source: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs index 29dbee26d1..efdf13aae9 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs @@ -22,7 +22,10 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput as V2UserInput; use codex_core::test_support::all_model_presets; +use codex_features::Feature; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; +use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG; use core_test_support::responses; use pretty_assertions::assert_eq; use serde_json::Value; @@ -94,6 +97,112 @@ async fn thread_settings_update_emits_notification_and_updates_future_turns() -> Ok(()) } +#[tokio::test] +async fn thread_settings_update_multi_agent_mode_applies_to_future_turns() -> Result<()> { + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_sequence( + &server, + (1..=2) + .map(|index| { + responses::sse(vec![ + responses::ev_response_created(&format!("resp-{index}")), + responses::ev_assistant_message(&format!("msg-{index}"), "done"), + responses::ev_completed(&format!("resp-{index}")), + ]) + }) + .collect(), + ) + .await; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &BTreeMap::from([ + (Feature::MultiAgentV2, true), + (Feature::MultiAgentMode, true), + ]), + /*auto_compact_limit*/ 200_000, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let thread = start_thread(&mut mcp).await?.thread; + + start_text_turn(&mut mcp, thread.id.clone()).await?; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + assert_eq!(response_mock.requests().len(), 1); + + send_thread_settings_update( + &mut mcp, + ThreadSettingsUpdateParams { + thread_id: thread.id.clone(), + multi_agent_mode: Some(MultiAgentMode::Proactive), + ..Default::default() + }, + ) + .await?; + assert_eq!( + response_mock.requests().len(), + 1, + "settings-only update should not start a model request" + ); + + let updated = read_thread_settings_updated(&mut mcp).await?; + assert_eq!(updated.thread_id, thread.id); + assert_eq!( + updated.thread_settings.multi_agent_mode, + Some(MultiAgentMode::Proactive) + ); + + start_text_turn(&mut mcp, thread.id).await?; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = response_mock.requests(); + let first_developer_texts = requests[0].message_input_texts("developer"); + let second_developer_texts = requests[1].message_input_texts("developer"); + assert_eq!( + first_developer_texts + .iter() + .filter(|text| text.contains(MULTI_AGENT_MODE_OPEN_TAG)) + .count(), + 1 + ); + assert_eq!( + second_developer_texts + .iter() + .filter(|text| text.contains(MULTI_AGENT_MODE_OPEN_TAG)) + .count(), + 2 + ); + assert_eq!( + second_developer_texts + .iter() + .filter(|text| text.contains("Proactive multi-agent delegation is active.")) + .count(), + 1 + ); + assert_eq!( + second_developer_texts + .iter() + .filter(|text| text + .contains("Do not spawn sub-agents unless the user explicitly asks for sub-agents")) + .count(), + 1 + ); + Ok(()) +} + #[tokio::test] async fn thread_settings_update_cwd_retargets_default_environment() -> Result<()> { let server = responses::start_mock_server().await; diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 058674653a..10d1599967 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -76,6 +76,7 @@ use codex_protocol::config_types::Settings; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; use codex_protocol::models::ImageDetail; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use codex_utils_absolute_path::test_support::PathExt; use core_test_support::responses; @@ -1827,6 +1828,140 @@ async fn turn_start_accepts_multi_agent_mode_v2() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_start_multi_agent_mode_initializes_first_turn() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::from([ + (Feature::MultiAgentV2, true), + (Feature::MultiAgentMode, true), + ]), + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + multi_agent_mode: Some(MultiAgentMode::Proactive), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { + thread, + multi_agent_mode, + .. + } = to_response::(thread_resp)?; + assert_eq!(multi_agent_mode, Some(MultiAgentMode::Proactive)); + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _: TurnStartResponse = to_response(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let developer_texts = response_mock + .single_request() + .message_input_texts("developer"); + assert!( + developer_texts.iter().any(|text| { + text.contains(MULTI_AGENT_MODE_OPEN_TAG) + && text.contains("Proactive multi-agent delegation is active.") + }), + "expected proactive multi-agent mode instructions in developer input, got {developer_texts:?}" + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_reports_selected_multi_agent_mode() -> Result<()> { + skip_if_no_network!(Ok(())); + + let cases = [ + ( + BTreeMap::from([(Feature::MultiAgentV2, true)]), + Some(MultiAgentMode::Proactive), + Some(MultiAgentMode::Proactive), + ), + ( + BTreeMap::new(), + Some(MultiAgentMode::Proactive), + Some(MultiAgentMode::Proactive), + ), + ( + BTreeMap::from([ + (Feature::MultiAgentV2, true), + (Feature::MultiAgentMode, true), + ]), + None, + None, + ), + ]; + + for (features, requested_multi_agent_mode, expected_multi_agent_mode) in cases { + let server = responses::start_mock_server().await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never", &features)?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + multi_agent_mode: requested_multi_agent_mode, + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let response = to_response::(thread_resp)?; + + assert_eq!(response.multi_agent_mode, expected_multi_agent_mode); + } + + Ok(()) +} + #[tokio::test] async fn turn_start_change_personality_mid_thread_v2() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 410c97cbd5..2f8d035435 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -19,6 +19,7 @@ use crate::thread_rollout_truncation::truncate_rollout_to_last_n_fork_turns; use codex_protocol::AgentPath; use codex_protocol::SessionId; use codex_protocol::ThreadId; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::models::ContentItem; @@ -68,6 +69,7 @@ pub(crate) struct SpawnAgentOptions { pub(crate) fork_mode: Option, pub(crate) parent_thread_id: Option, pub(crate) environments: Option>, + pub(crate) initial_multi_agent_mode: Option, } #[derive(Clone, Debug)] diff --git a/codex-rs/core/src/agent/control/residency_tests.rs b/codex-rs/core/src/agent/control/residency_tests.rs index cf043971e2..f91a77364a 100644 --- a/codex-rs/core/src/agent/control/residency_tests.rs +++ b/codex-rs/core/src/agent/control/residency_tests.rs @@ -142,6 +142,7 @@ async fn spawn_v2_subagent( /*forked_from_thread_id*/ None, Some(ThreadSource::Subagent), /*metrics_service_name*/ None, + /*initial_multi_agent_mode*/ None, /*inherited_environments*/ None, /*inherited_exec_policy*/ None, /*environments*/ None, diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index bc6dba1a48..3f746ef790 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -1,11 +1,13 @@ use super::residency::is_v2_resident_session_source; use super::*; +use codex_protocol::config_types::MultiAgentMode; const AGENT_NAMES: &str = include_str!("../agent_names.txt"); struct SpawnAgentThreadInheritance { environments: Option, exec_policy: Option>, + inherited_multi_agent_mode: Option, } fn default_agent_nickname_list() -> Vec<&'static str> { @@ -237,6 +239,7 @@ impl AgentControl { exec_policy: self .inherited_exec_policy_for_source(&state, session_source.as_ref(), &config) .await, + inherited_multi_agent_mode: options.initial_multi_agent_mode, }; let (session_source, mut agent_metadata) = match session_source { Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { @@ -283,6 +286,7 @@ impl AgentControl { /*forked_from_thread_id*/ None, /*thread_source*/ Some(ThreadSource::Subagent), /*metrics_service_name*/ None, + inheritance.inherited_multi_agent_mode, inheritance.environments, inheritance.exec_policy, options.environments.clone(), @@ -388,6 +392,7 @@ impl AgentControl { let SpawnAgentThreadInheritance { environments: inherited_environments, exec_policy: inherited_exec_policy, + inherited_multi_agent_mode, } = inheritance; if options.fork_parent_spawn_call_id.is_none() { return Err(CodexErr::Fatal( @@ -513,6 +518,7 @@ impl AgentControl { /*thread_source*/ Some(ThreadSource::Subagent), /*parent_thread_id*/ Some(parent_thread_id), /*forked_from_thread_id*/ Some(parent_thread_id), + inherited_multi_agent_mode, inherited_environments, inherited_exec_policy, options.environments.clone(), diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index fdd9b08375..66b5b5c4cb 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -14,6 +14,7 @@ use codex_features::Feature; use codex_login::CodexAuth; use codex_protocol::AgentPath; use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::models::ContentItem; use codex_protocol::models::MessagePhase; use codex_protocol::models::ResponseItem; @@ -816,6 +817,45 @@ async fn spawn_agent_creates_thread_and_sends_prompt() { assert_eq!(captured, Some(expected)); } +#[tokio::test] +async fn spawn_thread_subagent_uses_supplied_initial_multi_agent_mode_without_history() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, _parent_thread) = harness.start_thread().await; + + let child_thread_id = harness + .control + .spawn_agent_with_metadata( + harness.config.clone(), + text_input("child task"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + })), + SpawnAgentOptions { + initial_multi_agent_mode: Some(MultiAgentMode::Proactive), + ..Default::default() + }, + ) + .await + .expect("spawn child without parent history") + .thread_id; + let child_snapshot = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should be registered") + .config_snapshot() + .await; + + assert_eq!( + child_snapshot.multi_agent_mode, + Some(MultiAgentMode::Proactive) + ); +} + #[tokio::test] async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { let harness = AgentControlHarness::new().await; @@ -838,6 +878,15 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { .expect("start parent thread"); let parent_thread_id = new_thread.thread_id; let parent_thread = new_thread.thread; + parent_thread + .codex + .session + .update_settings(crate::session::SessionSettingsUpdate { + multi_agent_mode: Some(MultiAgentMode::Proactive), + ..Default::default() + }) + .await + .expect("update parent multi-agent mode"); parent_thread .inject_user_message_without_turn("parent seed context".to_string()) .await; @@ -923,6 +972,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { SpawnAgentOptions { fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()), fork_mode: Some(SpawnAgentForkMode::FullHistory), + initial_multi_agent_mode: Some(MultiAgentMode::Proactive), ..Default::default() }, ) @@ -935,6 +985,10 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { .get_thread(child_thread_id) .await .expect("child thread should be registered"); + assert_eq!( + child_thread.config_snapshot().await.multi_agent_mode, + Some(MultiAgentMode::Proactive) + ); assert_ne!(child_thread_id, parent_thread_id); let history = child_thread.codex.session.clone_history().await; let expected_history = [ @@ -1242,6 +1296,15 @@ async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() { async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() { let harness = AgentControlHarness::new().await; let (parent_thread_id, parent_thread) = harness.start_thread().await; + parent_thread + .codex + .session + .update_settings(crate::session::SessionSettingsUpdate { + multi_agent_mode: Some(MultiAgentMode::Proactive), + ..Default::default() + }) + .await + .expect("update parent multi-agent mode"); parent_thread .inject_user_message_without_turn("old parent context".to_string()) @@ -1326,6 +1389,7 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() { SpawnAgentOptions { fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()), fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)), + initial_multi_agent_mode: Some(MultiAgentMode::Proactive), ..Default::default() }, ) @@ -1338,6 +1402,10 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() { .get_thread(child_thread_id) .await .expect("child thread should be registered"); + assert_eq!( + child_thread.config_snapshot().await.multi_agent_mode, + Some(MultiAgentMode::Proactive) + ); let history = child_thread.codex.session.clone_history().await; assert!( @@ -2185,7 +2253,7 @@ async fn spawn_thread_subagent_uses_role_specific_nickname_candidates() { } #[tokio::test] -async fn resume_thread_subagent_restores_stored_nickname_and_role() { +async fn resume_thread_subagent_restores_stored_metadata_and_effective_multi_agent_mode() { let (home, mut config) = test_config().await; config .features @@ -2207,7 +2275,7 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() { manager, control, }; - let (parent_thread_id, _parent_thread) = harness.start_thread().await; + let (parent_thread_id, parent_thread) = harness.start_thread().await; let agent_path = AgentPath::from_string("/root/explorer".to_string()) .expect("test agent path should be valid"); @@ -2232,6 +2300,38 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() { .get_thread(child_thread_id) .await .expect("child thread should exist"); + let mut child_turn_context = child_thread + .codex + .session + .new_default_turn() + .await + .to_turn_context_item(); + child_turn_context.multi_agent_mode = Some(MultiAgentMode::Proactive); + child_thread + .codex + .session + .persist_rollout_items(&[RolloutItem::TurnContext(child_turn_context)]) + .await; + child_thread + .codex + .session + .ensure_rollout_materialized() + .await; + child_thread + .codex + .session + .flush_rollout() + .await + .expect("flush child effective multi-agent mode"); + parent_thread + .codex + .session + .update_settings(crate::session::SessionSettingsUpdate { + multi_agent_mode: Some(MultiAgentMode::ExplicitRequestOnly), + ..Default::default() + }) + .await + .expect("change parent multi-agent mode before child resume"); let mut status_rx = harness .control .subscribe_status(child_thread_id) @@ -2320,6 +2420,10 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() { assert_eq!(resumed_agent_path, Some(agent_path)); assert_eq!(resumed_nickname, Some(original_nickname)); assert_eq!(resumed_role, Some("explorer".to_string())); + assert_eq!( + resumed_snapshot.multi_agent_mode, + Some(MultiAgentMode::Proactive) + ); let _ = harness .control diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index dab902ec37..9f5b867f75 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -122,6 +122,7 @@ pub(crate) async fn run_codex_thread_interactive( attestation_provider: parent_session.services.attestation_provider.clone(), external_time_provider: Some(Arc::clone(&parent_session.services.time_provider)), inherited_multi_agent_version: Some(MultiAgentVersion::Disabled), + initial_multi_agent_mode: None, })) .or_cancel(&cancel_token) .await??; diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index f3becabc6e..7cf877a728 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -106,7 +106,7 @@ fn build_multi_agent_mode_update_item( &next.config.multi_agent_v2, &next.session_source, next.multi_agent_mode, - next.features.enabled(Feature::MultiAgentMode), + next.config.features.enabled(Feature::MultiAgentMode), ); let previous = previous?; if previous.multi_agent_mode == effective_multi_agent_mode { diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 98aef25724..2feee3e9f3 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -178,6 +178,7 @@ async fn thread_settings_applied_event(sess: &Session) -> EventMsg { reasoning_summary: snapshot.reasoning_summary, personality: snapshot.personality, collaboration_mode: snapshot.collaboration_mode, + multi_agent_mode: snapshot.multi_agent_mode, }, }) } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 8b7a15ff08..c627fe5371 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -445,6 +445,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) attestation_provider: Option>, pub(crate) external_time_provider: Option>, pub(crate) inherited_multi_agent_version: Option, + pub(crate) initial_multi_agent_mode: Option, } pub(crate) fn resolve_multi_agent_version( @@ -529,6 +530,7 @@ impl Codex { attestation_provider, external_time_provider, inherited_multi_agent_version, + initial_multi_agent_mode, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -583,7 +585,7 @@ impl Codex { .await; let multi_agent_version = resolve_multi_agent_version(&conversation_history, inherited_multi_agent_version); - let multi_agent_mode = conversation_history.get_multi_agent_mode(); + let multi_agent_mode = initial_multi_agent_mode; config .validate_multi_agent_v2_config() .map_err(|err| CodexErr::InvalidRequest(err.to_string()))?; @@ -3249,7 +3251,10 @@ impl Session { &turn_context.config.multi_agent_v2, &session_source, turn_context.multi_agent_mode, - turn_context.features.enabled(Feature::MultiAgentMode), + turn_context + .config + .features + .enabled(Feature::MultiAgentMode), ) { items.push(ContextualUserFragment::into( MultiAgentModeInstructions::new(multi_agent_mode), diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 5de331ee1f..7011e6d0f5 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -738,6 +738,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { attestation_provider: None, external_time_provider: None, inherited_multi_agent_version: None, + initial_multi_agent_mode: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 18d56cdfec..30d7d28e56 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -388,7 +388,7 @@ impl TurnContext { &self.config.multi_agent_v2, &self.session_source, self.multi_agent_mode, - self.features.enabled(Feature::MultiAgentMode), + self.config.features.enabled(Feature::MultiAgentMode), ), realtime_active: Some(self.realtime_active), effort: self.reasoning_effort.clone(), diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 814c8fe764..3a0e374750 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -36,6 +36,7 @@ use codex_models_manager::manager::RefreshStrategy; use codex_models_manager::manager::SharedModelsManager; use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationModeMask; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::openai_models::ModelPreset; @@ -184,6 +185,7 @@ pub struct StartThreadOptions { pub thread_source: Option, pub dynamic_tools: Vec, pub metrics_service_name: Option, + pub multi_agent_mode: Option, pub parent_trace: Option, pub environments: Vec, pub thread_extension_init: ExtensionDataInit, @@ -607,6 +609,7 @@ impl ThreadManager { thread_source: None, dynamic_tools, metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments, thread_extension_init: ExtensionDataInit::default(), @@ -646,6 +649,7 @@ impl ThreadManager { thread_source, options.dynamic_tools, options.metrics_service_name, + options.multi_agent_mode, /*inherited_environments*/ None, /*inherited_exec_policy*/ None, options.parent_trace, @@ -730,6 +734,7 @@ impl ThreadManager { let (session_source, thread_source) = initial_history .get_resumed_session_sources() .unwrap_or_else(|| (self.state.session_source.clone(), None)); + let initial_multi_agent_mode = initial_history.get_latest_effective_multi_agent_mode(); Box::pin(self.state.spawn_thread_with_source( config, initial_history, @@ -741,6 +746,7 @@ impl ThreadManager { thread_source, Vec::new(), /*metrics_service_name*/ None, + initial_multi_agent_mode, /*inherited_environments*/ None, /*inherited_exec_policy*/ None, parent_trace, @@ -773,6 +779,7 @@ impl ThreadManager { /*thread_source*/ None, Vec::new(), /*metrics_service_name*/ None, + /*initial_multi_agent_mode*/ None, /*parent_trace*/ None, environments, /*thread_extension_init*/ ExtensionDataInit::default(), @@ -799,6 +806,7 @@ impl ThreadManager { let (session_source, thread_source) = initial_history .get_resumed_session_sources() .unwrap_or_else(|| (self.state.session_source.clone(), None)); + let initial_multi_agent_mode = initial_history.get_latest_effective_multi_agent_mode(); Box::pin(self.state.spawn_thread_with_source( config, initial_history, @@ -810,6 +818,7 @@ impl ThreadManager { thread_source, Vec::new(), /*metrics_service_name*/ None, + initial_multi_agent_mode, /*inherited_environments*/ None, /*inherited_exec_policy*/ None, /*parent_trace*/ None, @@ -960,18 +969,25 @@ impl ThreadManager { ) -> CodexResult { // `forked_from_id()` describes this history's existing lineage. When // forking a resumed thread, the child copies the resumed thread itself. - let forked_from_thread_id = match &history { + let source_thread_id = match &history { InitialHistory::Resumed(resumed) => Some(resumed.conversation_id), InitialHistory::Forked(_) => history.forked_from_id(), InitialHistory::New | InitialHistory::Cleared => None, }; + let initial_multi_agent_mode = match source_thread_id { + Some(thread_id) => match self.get_thread(thread_id).await { + Ok(thread) => thread.config_snapshot().await.multi_agent_mode, + Err(_) => history.get_latest_effective_multi_agent_mode(), + }, + None => history.get_latest_effective_multi_agent_mode(), + }; let multi_agent_version = self .state .effective_multi_agent_version_for_spawn( &history, /*session_source*/ None, /*parent_thread_id*/ None, - forked_from_thread_id, + source_thread_id, &config, ) .await; @@ -989,10 +1005,11 @@ impl ThreadManager { Arc::clone(&self.state.auth_manager), agent_control, /*parent_thread_id*/ None, - forked_from_thread_id, + source_thread_id, thread_source, Vec::new(), /*metrics_service_name*/ None, + initial_multi_agent_mode, parent_trace, environments, /*thread_extension_init*/ ExtensionDataInit::default(), @@ -1217,6 +1234,7 @@ impl ThreadManagerState { /*forked_from_thread_id*/ None, /*thread_source*/ None, /*metrics_service_name*/ None, + /*initial_multi_agent_mode*/ None, /*inherited_environments*/ None, /*inherited_exec_policy*/ None, /*environments*/ None, @@ -1234,6 +1252,7 @@ impl ThreadManagerState { forked_from_thread_id: Option, thread_source: Option, metrics_service_name: Option, + initial_multi_agent_mode: Option, inherited_environments: Option, inherited_exec_policy: Option>, environments: Option>, @@ -1252,6 +1271,7 @@ impl ThreadManagerState { thread_source, Vec::new(), metrics_service_name, + initial_multi_agent_mode, inherited_environments, inherited_exec_policy, /*parent_trace*/ None, @@ -1279,6 +1299,7 @@ impl ThreadManagerState { let environments = default_thread_environment_selections(self.environment_manager.as_ref(), &config.cwd); let thread_source = initial_history.get_resumed_thread_source(); + let initial_multi_agent_mode = initial_history.get_latest_effective_multi_agent_mode(); Box::pin(self.spawn_thread_with_source( config, initial_history, @@ -1290,6 +1311,7 @@ impl ThreadManagerState { thread_source, Vec::new(), /*metrics_service_name*/ None, + initial_multi_agent_mode, inherited_environments, inherited_exec_policy, /*parent_trace*/ None, @@ -1311,6 +1333,7 @@ impl ThreadManagerState { thread_source: Option, parent_thread_id: Option, forked_from_thread_id: Option, + initial_multi_agent_mode: Option, inherited_environments: Option, inherited_exec_policy: Option>, environments: Option>, @@ -1329,6 +1352,7 @@ impl ThreadManagerState { thread_source, Vec::new(), /*metrics_service_name*/ None, + initial_multi_agent_mode, inherited_environments, inherited_exec_policy, /*parent_trace*/ None, @@ -1353,6 +1377,7 @@ impl ThreadManagerState { thread_source: Option, dynamic_tools: Vec, metrics_service_name: Option, + initial_multi_agent_mode: Option, parent_trace: Option, environments: Vec, thread_extension_init: ExtensionDataInit, @@ -1370,6 +1395,7 @@ impl ThreadManagerState { thread_source, dynamic_tools, metrics_service_name, + initial_multi_agent_mode, /*inherited_environments*/ None, /*inherited_exec_policy*/ None, parent_trace, @@ -1394,6 +1420,7 @@ impl ThreadManagerState { thread_source: Option, dynamic_tools: Vec, metrics_service_name: Option, + initial_multi_agent_mode: Option, inherited_environments: Option, inherited_exec_policy: Option>, parent_trace: Option, @@ -1473,6 +1500,7 @@ impl ThreadManagerState { attestation_provider: self.attestation_provider.clone(), external_time_provider: self.external_time_provider.clone(), inherited_multi_agent_version: multi_agent_version, + initial_multi_agent_mode, })) .await?; let new_thread = self diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index bfb7865bb2..d4515e87d8 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -322,6 +322,7 @@ async fn start_thread_keeps_internal_threads_hidden_from_normal_lookups() { thread_source: None, dynamic_tools: Vec::new(), metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments: Vec::new(), thread_extension_init: Default::default(), @@ -462,6 +463,7 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors() thread_source: None, dynamic_tools: Vec::new(), metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments: Vec::new(), thread_extension_init: selected_root_init("selected-a", "env-a"), @@ -477,6 +479,7 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors() thread_source: None, dynamic_tools: Vec::new(), metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments: Vec::new(), thread_extension_init: selected_root_init("selected-b", "env-b"), @@ -569,6 +572,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { thread_source: None, dynamic_tools: Vec::new(), metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments: environments.clone(), thread_extension_init: Default::default(), @@ -852,6 +856,7 @@ async fn resume_stopped_thread_from_rollout_preserves_thread_source() { thread_source: Some(ThreadSource::User), dynamic_tools: Vec::new(), metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments: Vec::new(), thread_extension_init: Default::default(), diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index 204385934e..fcad4bb62f 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -132,6 +132,7 @@ async fn handle_spawn_agent( fork_mode: args.fork_context.then_some(SpawnAgentForkMode::FullHistory), parent_thread_id: Some(session.thread_id), environments: Some(turn.environments.to_selections()), + initial_multi_agent_mode: None, }, )) .await diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index eefea1eb8f..eecdfc178d 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -24,6 +24,7 @@ use codex_model_provider_info::built_in_model_providers; use codex_protocol::AgentPath; use codex_protocol::ThreadId; use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::ShellEnvironmentPolicy; use codex_protocol::models::BaseInstructions; @@ -1147,6 +1148,11 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); + config + .features + .enable(Feature::MultiAgentMode) + .expect("test config should allow feature update"); + turn.multi_agent_mode = Some(MultiAgentMode::Proactive); set_turn_config(&mut turn, config); let session = Arc::new(session); @@ -1185,6 +1191,10 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat child_snapshot.session_source.get_agent_path().as_deref(), Some("/root/test_process") ); + assert_eq!( + child_snapshot.multi_agent_mode, + Some(MultiAgentMode::Proactive) + ); assert!(manager.captured_ops().iter().any(|(id, op)| { *id == child_thread_id && matches!( diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index 51813c5c19..ed63102d6a 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -50,6 +50,15 @@ async fn handle_spawn_agent( let arguments = function_arguments(payload)?; let args: SpawnAgentArgs = parse_arguments(&arguments)?; let fork_mode = args.fork_mode()?; + let multi_agent_mode = crate::session::multi_agents::effective_multi_agent_mode( + turn.multi_agent_version, + &turn.config.multi_agent_v2, + &turn.session_source, + turn.multi_agent_mode, + turn.config + .features + .enabled(codex_features::Feature::MultiAgentMode), + ); let role_name = args .agent_type .as_deref() @@ -134,6 +143,7 @@ async fn handle_spawn_agent( fork_mode, parent_thread_id: Some(session.thread_id), environments: Some(turn.environments.to_selections()), + initial_multi_agent_mode: multi_agent_mode, }, ), ) diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index e0e7b20287..d843e0097b 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -641,6 +641,7 @@ impl TestCodexBuilder { thread_source: None, dynamic_tools: Vec::new(), metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments, thread_extension_init: Default::default(), diff --git a/codex-rs/core/tests/suite/agents_md.rs b/codex-rs/core/tests/suite/agents_md.rs index e3a08447c5..498a666079 100644 --- a/codex-rs/core/tests/suite/agents_md.rs +++ b/codex-rs/core/tests/suite/agents_md.rs @@ -441,6 +441,7 @@ async fn loads_user_instructions_without_a_primary_environment() -> Result<()> { thread_source: None, dynamic_tools: Vec::new(), metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments: Vec::new(), thread_extension_init: Default::default(), @@ -648,6 +649,7 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho thread_source: None, dynamic_tools: Vec::new(), metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments: vec![ TurnEnvironmentSelection { diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index a31d7e3616..2b9395f598 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -751,6 +751,7 @@ async fn subagent_stop_replaces_stop_and_skips_internal_subagents() -> Result<() thread_source: None, dynamic_tools: Vec::new(), metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments: Vec::new(), thread_extension_init: Default::default(), diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 526c5fc487..4e341d773b 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -790,5 +790,6 @@ fn sample_thread_start_response() -> ThreadStartResponse { }, active_permission_profile: None, reasoning_effort: None, + multi_agent_mode: Default::default(), } } diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index 6cc856ddb4..0835e15520 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -311,6 +311,7 @@ impl MemoryStartupContext { thread_source: Some(ThreadSource::MemoryConsolidation), dynamic_tools: Vec::new(), metrics_service_name: None, + multi_agent_mode: None, parent_trace: None, environments, thread_extension_init: Default::default(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index bc0186bc64..1fe732c4ff 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1992,6 +1992,8 @@ pub struct ThreadSettingsSnapshot { #[serde(skip_serializing_if = "Option::is_none")] pub personality: Option, pub collaboration_mode: CollaborationMode, + #[serde(default)] + pub multi_agent_mode: Option, } #[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq, JsonSchema, TS)] @@ -2554,12 +2556,24 @@ impl InitialHistory { } } - pub fn get_multi_agent_mode(&self) -> Option { - match self { - InitialHistory::New | InitialHistory::Cleared => None, - InitialHistory::Resumed(resumed) => multi_agent_mode_from_items(&resumed.history), - InitialHistory::Forked(items) => multi_agent_mode_from_items(items), - } + pub fn get_latest_effective_multi_agent_mode(&self) -> Option { + let items = match self { + InitialHistory::New | InitialHistory::Cleared => return None, + InitialHistory::Resumed(resumed) => &resumed.history, + InitialHistory::Forked(items) => items, + }; + items + .iter() + .rev() + .find_map(|item| match item { + RolloutItem::TurnContext(turn_context) => Some(turn_context), + RolloutItem::SessionMeta(_) + | RolloutItem::ResponseItem(_) + | RolloutItem::InterAgentCommunication(_) + | RolloutItem::Compacted(_) + | RolloutItem::EventMsg(_) => None, + }) + .and_then(|turn_context| turn_context.multi_agent_mode) } pub fn get_resumed_session_sources(&self) -> Option<(SessionSource, Option)> { @@ -2874,17 +2888,6 @@ fn multi_agent_version_from_items( }) } -fn multi_agent_mode_from_items(items: &[RolloutItem]) -> Option { - items.iter().rev().find_map(|item| match item { - RolloutItem::TurnContext(turn_context) => turn_context.multi_agent_mode, - RolloutItem::SessionMeta(_) - | RolloutItem::ResponseItem(_) - | RolloutItem::InterAgentCommunication(_) - | RolloutItem::Compacted(_) - | RolloutItem::EventMsg(_) => None, - }) -} - #[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(rename_all = "snake_case")] @@ -5399,6 +5402,31 @@ mod tests { Ok(()) } + #[test] + fn latest_effective_multi_agent_mode_uses_latest_turn_context_even_when_unset() -> Result<()> { + let turn_context_item = |multi_agent_mode| -> Result { + let mut value = json!({ + "cwd": test_path_buf("/tmp"), + "approval_policy": "never", + "sandbox_policy": { "type": "danger-full-access" }, + "model": "gpt-5", + "summary": "auto", + }); + value["multi_agent_mode"] = serde_json::to_value(multi_agent_mode)?; + Ok(RolloutItem::TurnContext(serde_json::from_value(value)?)) + }; + + assert_eq!( + InitialHistory::Forked(vec![ + turn_context_item(Some(MultiAgentMode::Proactive))?, + turn_context_item(/*multi_agent_mode*/ None)?, + ]) + .get_latest_effective_multi_agent_mode(), + None + ); + Ok(()) + } + #[test] fn turn_context_item_serializes_network_when_present() -> Result<()> { let item = TurnContextItem { diff --git a/codex-rs/tui/src/app/app_server_event_targets.rs b/codex-rs/tui/src/app/app_server_event_targets.rs index e29d0e9d5d..7e84e42271 100644 --- a/codex-rs/tui/src/app/app_server_event_targets.rs +++ b/codex-rs/tui/src/app/app_server_event_targets.rs @@ -230,6 +230,7 @@ mod tests { developer_instructions: None, }, }, + multi_agent_mode: Default::default(), personality: None, } } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 51151206fa..0fc561cb2a 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -6038,6 +6038,7 @@ async fn inactive_thread_settings_notification_updates_cached_collaboration_mode effort: collaboration_mode.settings.reasoning_effort.clone(), summary: None, collaboration_mode: collaboration_mode.clone(), + multi_agent_mode: Default::default(), personality: Some(Personality::Pragmatic), }, }; diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 365ee92fca..9ed78fed58 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -2381,6 +2381,7 @@ mod tests { .into(), active_permission_profile: None, reasoning_effort: None, + multi_agent_mode: Default::default(), initial_turns_page: None, }; diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 383ab43053..a10c2cca93 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -30,6 +30,7 @@ fn thread_settings_for_test( developer_instructions: None, }, }, + multi_agent_mode: Default::default(), personality: Some(Personality::Pragmatic), }, } From dac588f41398e8b628d71838d5745dad430477f1 Mon Sep 17 00:00:00 2001 From: rka-oai Date: Fri, 19 Jun 2026 02:00:01 -0700 Subject: [PATCH 071/112] [codex] abort turns when rollout budgets expire (token budget 3/3) (#28707) ## Stack Depends on #28494. ## Description This PR propagates shared rollout-budget exhaustion through the existing `CodexErr::TurnAborted` task result. Each thread records its model usage against the same ledger. Once the ledger is exhausted, that usage update and all later usage updates return `TurnAborted`. The task wrapper emits the normal aborted-turn event and lifecycle instead of completing the turn. This is intentionally a soft boundary: there is no cross-thread `Op::Interrupt` fanout. An in-flight thread can finish its current response before it observes the exhausted ledger, but every thread aborts at its next usage-accounting boundary. ## Tests The integration coverage verifies that: - the response that exhausts the budget aborts its turn; - a later response also aborts because the shared ledger remains exhausted; and - sub-agent usage draws from the same shared ledger; and - local and remote-v2 compaction abort without retrying or emitting a generic error. Local checks: - `just test -p codex-core exhausted_budget_aborts_current_and_later_turns` - `just test -p codex-core subagent_usage_draws_from_the_shared_budget` - `just test -p codex-core abort_regular_task_emits_marker_before_turn_aborted` - `just test -p codex-core compaction_budget_exhaustion_aborts_without_error_or_retry` - `just fix -p codex-core` - `just fmt` - `git diff --check` The full workspace test suite was not run locally. --- codex-rs/core/src/compact.rs | 6 +- codex-rs/core/src/compact_remote_v2.rs | 5 +- codex-rs/core/src/rollout_budget.rs | 6 +- codex-rs/core/src/session/mod.rs | 12 +- codex-rs/core/src/session/rollout_budget.rs | 13 +- codex-rs/core/src/session/tests.rs | 25 ++-- codex-rs/core/src/session/turn.rs | 38 ++++-- codex-rs/core/src/tasks/compact.rs | 11 +- codex-rs/core/src/tasks/mod.rs | 67 ++++++---- codex-rs/core/src/tasks/regular.rs | 9 +- codex-rs/core/src/tasks/review.rs | 5 +- codex-rs/core/src/tasks/user_shell.rs | 5 +- codex-rs/core/tests/suite/rollout_budget.rs | 133 ++++++++++++++++++++ 13 files changed, 266 insertions(+), 69 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 7b93195de4..7a2eaf935f 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -254,8 +254,8 @@ async fn run_compact_task_inner_impl( Ok(()) => { break; } - Err(CodexErr::Interrupted) => { - return Err(CodexErr::Interrupted); + Err(err @ (CodexErr::Interrupted | CodexErr::TurnAborted)) => { + return Err(err); } Err(e @ CodexErr::ContextWindowExceeded) => { if turn_input_len > 1 { @@ -657,7 +657,7 @@ async fn drain_to_completed( } Ok(ResponseEvent::Completed { token_usage, .. }) => { sess.update_token_usage_info(turn_context, token_usage.as_ref()) - .await; + .await?; return Ok(()); } Ok(_) => continue, diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index d941388b07..6f10a474a1 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -165,6 +165,9 @@ async fn run_remote_compact_task_inner( attempt .track(sess.as_ref(), status, codex_error, analytics_details) .await; + if matches!(&result, Err(CodexErr::TurnAborted)) { + return result; + } if let Err(err) = result { sess.track_turn_codex_error(turn_context, &err); let event = EventMsg::Error( @@ -281,7 +284,7 @@ async fn run_remote_compact_task_inner_impl( token_usage, } = compaction_output_result?; if let Some(token_usage) = token_usage { - sess.record_rollout_budget_usage(&token_usage); + sess.record_rollout_budget_usage(&token_usage)?; analytics_details.active_context_tokens_before = Some(token_usage.input_tokens); analytics_details.compaction_summary_tokens = Some(token_usage.output_tokens); analytics_details.cached_input_tokens = Some(token_usage.cached_input_tokens); diff --git a/codex-rs/core/src/rollout_budget.rs b/codex-rs/core/src/rollout_budget.rs index accabac761..b8901a9e36 100644 --- a/codex-rs/core/src/rollout_budget.rs +++ b/codex-rs/core/src/rollout_budget.rs @@ -40,13 +40,15 @@ impl RolloutBudget { }); } - pub(crate) fn record_usage(&self, usage: &TokenUsage) { + /// Returns true once the configured budget is exhausted, including on later calls. + pub(crate) fn record_usage(&self, usage: &TokenUsage) -> bool { let Some(mut state) = self.lock() else { - return; + return false; }; state.weighted_tokens_used += usage.output_tokens.max(0) as f64 * state.config.sampling_token_weight + usage.non_cached_input() as f64 * state.config.prefill_token_weight; + state.weighted_tokens_used >= state.config.limit_tokens as f64 } pub(crate) fn pending_reminder( diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index c627fe5371..3c1f06f6b6 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -3410,17 +3410,19 @@ impl Session { &self, turn_context: &TurnContext, token_usage: Option<&TokenUsage>, - ) { - self.record_token_usage_info(turn_context, token_usage) + ) -> CodexResult<()> { + let result = self + .record_token_usage_info(turn_context, token_usage) .await; self.send_token_count_event(turn_context).await; + result } pub(crate) async fn record_token_usage_info( &self, turn_context: &TurnContext, token_usage: Option<&TokenUsage>, - ) { + ) -> CodexResult<()> { if let Some(token_usage) = token_usage { let token_info = { let mut state = self.state.lock().await; @@ -3434,7 +3436,7 @@ impl Session { } state.token_info() }; - self.record_rollout_budget_usage(token_usage); + let budget_result = self.record_rollout_budget_usage(token_usage); if let Some(token_info) = token_info.as_ref() { for contributor in self.services.extensions.token_usage_contributors() { contributor @@ -3447,7 +3449,9 @@ impl Session { .await; } } + budget_result?; } + Ok(()) } pub(crate) async fn recompute_token_usage(&self, turn_context: &TurnContext) { diff --git a/codex-rs/core/src/session/rollout_budget.rs b/codex-rs/core/src/session/rollout_budget.rs index 4627d95ee7..fa90cf7b97 100644 --- a/codex-rs/core/src/session/rollout_budget.rs +++ b/codex-rs/core/src/session/rollout_budget.rs @@ -1,6 +1,8 @@ use super::session::Session; use super::turn_context::TurnContext; use crate::context::ContextualUserFragment; +use codex_protocol::error::CodexErr; +use codex_protocol::error::Result as CodexResult; use codex_protocol::protocol::TokenUsage; pub(super) async fn maybe_record_reminder( @@ -21,10 +23,15 @@ pub(super) async fn maybe_record_reminder( } impl Session { - pub(crate) fn record_rollout_budget_usage(&self, usage: &TokenUsage) { - self.services + pub(crate) fn record_rollout_budget_usage(&self, usage: &TokenUsage) -> CodexResult<()> { + if self + .services .agent_control .rollout_budget() - .record_usage(usage); + .record_usage(usage) + { + return Err(CodexErr::TurnAborted); + } + Ok(()) } } diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 6f82155304..bca6be6a74 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -70,6 +70,7 @@ use crate::state::ActiveTurn; use crate::state::TaskKind; use crate::tasks::SessionTask; use crate::tasks::SessionTaskContext; +use crate::tasks::SessionTaskResult; use crate::tasks::UserShellCommandMode; use crate::tasks::execute_user_shell_command; use crate::tools::ToolRouter; @@ -2150,10 +2151,12 @@ async fn record_token_usage_info_notifies_extension_contributors() { session .record_token_usage_info(&turn_context, Some(&first_usage)) - .await; + .await + .expect("first usage should be recorded"); session .record_token_usage_info(&turn_context, Some(&second_usage)) - .await; + .await + .expect("second usage should be recorded"); let mut expected_total_usage = first_usage.clone(); expected_total_usage.add_assign(&second_usage); @@ -6474,13 +6477,13 @@ async fn spawn_task_turn_span_inherits_dispatch_trace_context() { _ctx: Arc, _input: Vec, _cancellation_token: CancellationToken, - ) -> Option { + ) -> SessionTaskResult { let mut trace = self .captured_trace .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); *trace = current_span_w3c_trace_context(); - None + Ok(None) } } @@ -8676,8 +8679,8 @@ impl SessionTask for CompletingTask { _ctx: Arc, _input: Vec, _cancellation_token: CancellationToken, - ) -> Option { - None + ) -> SessionTaskResult { + Ok(None) } } @@ -8702,10 +8705,10 @@ impl SessionTask for NeverEndingTask { _ctx: Arc, _input: Vec, cancellation_token: CancellationToken, - ) -> Option { + ) -> SessionTaskResult { if self.listen_to_cancellation_token { cancellation_token.cancelled().await; - return None; + return Ok(None); } loop { sleep(Duration::from_secs(60)).await; @@ -8731,14 +8734,14 @@ impl SessionTask for GuardianDeniedApprovalTask { ctx: Arc, _input: Vec, cancellation_token: CancellationToken, - ) -> Option { + ) -> SessionTaskResult { let session = session.clone_session(); for _ in 0..3 { crate::guardian::record_guardian_denial_for_test(&session, &ctx, &ctx.sub_id).await; } cancellation_token.cancelled().await; - None + Ok(None) } } @@ -8961,7 +8964,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() .await .expect("steer pending input into active turn"); - sess.on_task_finished(Arc::clone(&tc), /*last_agent_message*/ None) + sess.on_task_finished(Arc::clone(&tc), /*task_result*/ Ok(None)) .await; let history = sess.clone_history().await; diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 25e4e1a6af..42fab7a506 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -144,7 +144,7 @@ pub(crate) async fn run_turn( input: Vec, prewarmed_client_session: Option, cancellation_token: CancellationToken, -) -> Option { +) -> CodexResult> { let mut client_session = prewarmed_client_session.unwrap_or_else(|| sess.services.model_client.new_session()); // TODO(ccunningham): Pre-turn compaction runs before context updates and the @@ -152,25 +152,31 @@ pub(crate) async fn run_turn( // diffs/full reinjection + user input) and trigger compaction preemptively // when they would push the thread over the compaction threshold. if let Err(err) = run_pre_sampling_compact(&sess, &turn_context, &mut client_session).await { + if matches!(err, CodexErr::TurnAborted) { + return Err(err); + } let error = err.to_codex_protocol_error(); sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) .await; error!("Failed to run pre-sampling compact"); - return None; + return Ok(None); } sess.record_context_updates_and_set_reference_context_item(turn_context.as_ref()) .await; - let (injection_items, explicitly_enabled_connectors) = - build_skills_and_plugins(&sess, turn_context.as_ref(), &input, &cancellation_token).await?; + let Some((injection_items, explicitly_enabled_connectors)) = + build_skills_and_plugins(&sess, turn_context.as_ref(), &input, &cancellation_token).await + else { + return Ok(None); + }; if run_pending_session_start_hooks(&sess, &turn_context).await { - return None; + return Ok(None); } let mut can_drain_pending_input = input.is_empty(); if run_hooks_and_record_inputs(&sess, &turn_context, &input).await { - return None; + return Ok(None); } sess.merge_connector_selection(explicitly_enabled_connectors.clone()) @@ -336,10 +342,13 @@ pub(crate) async fn run_turn( ) .await { + if matches!(err, CodexErr::TurnAborted) { + return Err(err); + } let error = err.to_codex_protocol_error(); sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) .await; - return None; + return Ok(None); } can_drain_pending_input = !model_needs_follow_up; continue; @@ -386,15 +395,14 @@ pub(crate) async fn run_turn( ) .await { - return None; + return Ok(None); } break; } continue; } - Err(CodexErr::TurnAborted) => { - // Aborted turn is reported via a different event. - break; + Err(err @ CodexErr::TurnAborted) => { + return Err(err); } Err(codex_error @ CodexErr::InvalidImageRequest()) => { { @@ -433,7 +441,7 @@ pub(crate) async fn run_turn( } } - last_agent_message + Ok(last_agent_message) } #[instrument(level = "trace", skip_all)] @@ -2197,10 +2205,14 @@ async fn try_run_sampling_request( &mut assistant_message_stream_parsers, ) .await; - sess.record_token_usage_info(&turn_context, token_usage.as_ref()) + let budget_result = sess + .record_token_usage_info(&turn_context, token_usage.as_ref()) .await; should_emit_token_count = true; should_emit_turn_diff = true; + if let Err(err) = budget_result { + break Err(err); + } if let Some(false) = end_turn { needs_follow_up = true; } diff --git a/codex-rs/core/src/tasks/compact.rs b/codex-rs/core/src/tasks/compact.rs index dd9d6cb079..2c45fd8ee2 100644 --- a/codex-rs/core/src/tasks/compact.rs +++ b/codex-rs/core/src/tasks/compact.rs @@ -2,10 +2,12 @@ use std::sync::Arc; use super::SessionTask; use super::SessionTaskContext; +use super::SessionTaskResult; use super::emit_compact_metric; use crate::session::TurnInput; use crate::session::turn_context::TurnContext; use crate::state::TaskKind; +use codex_protocol::error::CodexErr; use codex_protocol::user_input::UserInput; use tokio_util::sync::CancellationToken; @@ -27,9 +29,9 @@ impl SessionTask for CompactTask { ctx: Arc, _input: Vec, _cancellation_token: CancellationToken, - ) -> Option { + ) -> SessionTaskResult { let session = session.clone_session(); - let _ = if crate::compact::should_use_remote_compact_task(ctx.provider.info()) { + let result = if crate::compact::should_use_remote_compact_task(ctx.provider.info()) { if ctx .config .features @@ -67,6 +69,9 @@ impl SessionTask for CompactTask { }]; crate::compact::run_compact_task(session.clone(), ctx, input).await }; - None + if let Err(err @ CodexErr::TurnAborted) = result { + return Err(err); + } + Ok(None) } } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 3ff0e2d5f7..b24d43df3d 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -54,6 +54,8 @@ use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::WarningEvent; use codex_features::Feature; +use codex_protocol::error::CodexErr; +use codex_protocol::error::Result as CodexResult; use codex_protocol::models::ContentItem; pub(crate) use compact::CompactTask; pub(crate) use regular::RegularTask; @@ -65,6 +67,8 @@ pub(crate) use user_shell::execute_user_shell_command; const GRACEFULL_INTERRUPTION_TIMEOUT_MS: u64 = 100; const TASK_COMPACT_METRIC: &str = "codex.task.compact"; +pub(crate) type SessionTaskResult = CodexResult>; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum InterruptedTurnHistoryMarker { Disabled, @@ -222,14 +226,16 @@ pub(crate) trait SessionTask: Send + Sync + 'static { /// provided `cancellation_token` is cancelled when the session requests an /// abort; implementers should watch for it and terminate quickly once it /// fires. Returning [`Some`] yields a final message that - /// [`Session::on_task_finished`] will emit to the client. + /// [`Session::on_task_finished`] will emit to the client. Returning + /// [`CodexErr::TurnAborted`] completes the task through the aborted-turn + /// lifecycle instead. fn run( self: Arc, session: Arc, ctx: Arc, input: Vec, cancellation_token: CancellationToken, - ) -> impl std::future::Future> + Send; + ) -> impl std::future::Future + Send; /// Gives the task a chance to perform cleanup after an abort. /// @@ -258,7 +264,7 @@ pub(crate) trait AnySessionTask: Send + Sync + 'static { ctx: Arc, input: Vec, cancellation_token: CancellationToken, - ) -> BoxFuture<'static, Option>; + ) -> BoxFuture<'static, SessionTaskResult>; fn abort<'a>( &'a self, @@ -285,7 +291,7 @@ where ctx: Arc, input: Vec, cancellation_token: CancellationToken, - ) -> BoxFuture<'static, Option> { + ) -> BoxFuture<'static, SessionTaskResult> { Box::pin(SessionTask::run( self, session, @@ -395,7 +401,7 @@ impl Session { let handle = tokio::spawn( async move { let ctx_for_finish = Arc::clone(&ctx); - let last_agent_message = task_for_run + let task_result = task_for_run .run( Arc::clone(&session_ctx), ctx, @@ -418,8 +424,8 @@ impl Session { .await; } if !task_cancellation_token.is_cancelled() { - // Emit completion uniformly from spawn site so all tasks share the same lifecycle. - sess.on_task_finished(Arc::clone(&ctx_for_finish), last_agent_message) + // Finish uniformly from the spawn site so all tasks share the same lifecycle. + sess.on_task_finished(Arc::clone(&ctx_for_finish), task_result) .await; } done_clone.notify_waiters(); @@ -557,8 +563,16 @@ impl Session { pub async fn on_task_finished( self: &Arc, turn_context: Arc, - last_agent_message: Option, + task_result: SessionTaskResult, ) { + let (last_agent_message, abort_reason) = match task_result { + Ok(last_agent_message) => (last_agent_message, None), + Err(CodexErr::TurnAborted) => (None, Some(TurnAbortReason::Interrupted)), + Err(err) => { + warn!(%err, "session task returned an unexpected error"); + (None, None) + } + }; turn_context .turn_metadata_state .cancel_git_enrichment_task(); @@ -730,25 +744,36 @@ impl Session { .turn_timing_state .completed_at_and_duration_ms() .await; - let time_to_first_token_ms = turn_context - .turn_timing_state - .time_to_first_token_ms() - .await; self.services .analytics_events_client .track_turn_profile(TurnProfileFact { turn_id: turn_context.sub_id.clone(), profile: turn_context.turn_timing_state.complete_profile(), }); - self.emit_turn_stop_lifecycle(turn_context.extension_data.as_ref()) - .await; - let event = EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: turn_context.sub_id.clone(), - last_agent_message, - completed_at, - duration_ms, - time_to_first_token_ms, - }); + let event = if let Some(reason) = abort_reason { + self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref()) + .await; + EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: Some(turn_context.sub_id.clone()), + reason, + completed_at, + duration_ms, + }) + } else { + let time_to_first_token_ms = turn_context + .turn_timing_state + .time_to_first_token_ms() + .await; + self.emit_turn_stop_lifecycle(turn_context.extension_data.as_ref()) + .await; + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: turn_context.sub_id.clone(), + last_agent_message, + completed_at, + duration_ms, + time_to_first_token_ms, + }) + }; self.send_event(turn_context.as_ref(), event).await; self.services .guardian_rejection_circuit_breaker diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index c5cc2da31d..40837728a2 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -14,6 +14,7 @@ use tracing::trace_span; use super::SessionTask; use super::SessionTaskContext; +use super::SessionTaskResult; #[derive(Default)] pub(crate) struct RegularTask; @@ -39,7 +40,7 @@ impl SessionTask for RegularTask { ctx: Arc, input: Vec, cancellation_token: CancellationToken, - ) -> Option { + ) -> SessionTaskResult { let sess = session.clone_session(); let turn_extension_data = session.turn_extension_data(); let run_turn_span = trace_span!("run_turn"); @@ -61,7 +62,7 @@ impl SessionTask for RegularTask { .instrument(trace_span!("regular_task.prepare_run_turn")) .await; let prewarmed_client_session = match prewarmed_client_session { - SessionStartupPrewarmResolution::Cancelled => return None, + SessionStartupPrewarmResolution::Cancelled => return Ok(None), SessionStartupPrewarmResolution::Unavailable { .. } => None, SessionStartupPrewarmResolution::Ready(prewarmed_client_session) => { Some(*prewarmed_client_session) @@ -79,9 +80,9 @@ impl SessionTask for RegularTask { cancellation_token.child_token(), ) .instrument(run_turn_span.clone()) - .await; + .await?; if !sess.input_queue.has_pending_input(&sess.active_turn).await { - return last_agent_message; + return Ok(last_agent_message); } next_input = Vec::new(); } diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index bb8670237e..7c18a5f787 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -29,6 +29,7 @@ use codex_protocol::user_input::UserInput; use super::SessionTask; use super::SessionTaskContext; +use super::SessionTaskResult; #[derive(Clone, Copy)] pub(crate) struct ReviewTask; @@ -54,7 +55,7 @@ impl SessionTask for ReviewTask { ctx: Arc, input: Vec, cancellation_token: CancellationToken, - ) -> Option { + ) -> SessionTaskResult { session.session.services.session_telemetry.counter( "codex.task.review", /*inc*/ 1, @@ -84,7 +85,7 @@ impl SessionTask for ReviewTask { if !cancellation_token.is_cancelled() { exit_review_mode(session.clone_session(), output.clone(), ctx.clone()).await; } - None + Ok(None) } async fn abort(&self, session: Arc, ctx: Arc) { diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 7cb9841db7..c5b44a6494 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -41,6 +41,7 @@ use codex_shell_command::parse_command::parse_command; use super::SessionTask; use super::SessionTaskContext; +use super::SessionTaskResult; use crate::session::session::Session; use codex_protocol::models::PermissionProfile; @@ -82,7 +83,7 @@ impl SessionTask for UserShellCommandTask { turn_context: Arc, _input: Vec, cancellation_token: CancellationToken, - ) -> Option { + ) -> SessionTaskResult { execute_user_shell_command( session.clone_session(), turn_context, @@ -91,7 +92,7 @@ impl SessionTask for UserShellCommandTask { UserShellCommandMode::StandaloneTurn, ) .await; - None + Ok(None) } } diff --git a/codex-rs/core/tests/suite/rollout_budget.rs b/codex-rs/core/tests/suite/rollout_budget.rs index c2a044eb52..61c6dfe424 100644 --- a/codex-rs/core/tests/suite/rollout_budget.rs +++ b/codex-rs/core/tests/suite/rollout_budget.rs @@ -4,6 +4,8 @@ use codex_features::Feature; use codex_model_provider_info::built_in_model_providers; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; +use codex_protocol::protocol::TurnAbortReason; +use codex_protocol::user_input::UserInput; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -20,6 +22,7 @@ use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use serde_json::json; use std::time::Duration; +use test_case::test_case; use tokio::time::timeout; const ROLLOUT_BUDGET: RolloutBudgetConfig = RolloutBudgetConfig { @@ -192,6 +195,136 @@ async fn subagent_usage_draws_from_the_shared_budget() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exhausted_budget_aborts_current_and_later_turns() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("exhaust-budget"), + ev_completed_with_tokens("exhaust-budget", /*total_tokens*/ 30), + ]), + sse(vec![ + ev_response_created("already-exhausted"), + ev_completed_with_tokens("already-exhausted", /*total_tokens*/ 1), + ]), + ], + ) + .await; + let test = test_codex() + .with_config(|config| { + config.rollout_budget = Some(RolloutBudgetConfig { + limit_tokens: 30, + reminder_interval_tokens: 10, + ..ROLLOUT_BUDGET + }); + }) + .build(&server) + .await?; + + for prompt in ["exhaust the budget", "try another turn"] { + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: prompt.to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + + let event = wait_for_event(&test.codex, |event| match event { + EventMsg::TurnAborted(_) => true, + EventMsg::TurnComplete(_) => { + panic!("exhausted budget completed the turn instead of aborting") + } + _ => false, + }) + .await; + let EventMsg::TurnAborted(abort) = event else { + unreachable!("event filter only accepts TurnAborted") + }; + assert_eq!(abort.reason, TurnAbortReason::Interrupted); + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[test_case(false ; "local")] +#[test_case(true ; "remote_v2")] +async fn compaction_budget_exhaustion_aborts_without_error_or_retry(remote_v2: bool) -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let compact_response = if remote_v2 { + sse(vec![ + json!({ + "type": "response.output_item.done", + "item": { + "type": "compaction", + "encrypted_content": "encrypted-summary", + } + }), + ev_completed_with_tokens("compact", /*total_tokens*/ 10), + ]) + } else { + sse(vec![ + ev_response_created("compact"), + ev_assistant_message("compact-summary", "compact summary"), + ev_completed_with_tokens("compact", /*total_tokens*/ 10), + ]) + }; + let responses = mount_sse_sequence(&server, vec![compact_response]).await; + let mut model_provider = built_in_model_providers(/*openai_base_url*/ None)["openai"].clone(); + model_provider.base_url = Some(format!("{}/v1", server.uri())); + model_provider.supports_websockets = false; + if !remote_v2 { + model_provider.name = "OpenAI-compatible test provider".to_string(); + } + let test = test_codex() + .with_config(move |config| { + config.model_provider = model_provider; + config.rollout_budget = Some(RolloutBudgetConfig { + limit_tokens: 10, + reminder_interval_tokens: 5, + ..ROLLOUT_BUDGET + }); + if remote_v2 { + config + .features + .enable(Feature::RemoteCompactionV2) + .expect("test config should allow remote compaction v2"); + } + }) + .build(&server) + .await?; + + test.codex.submit(Op::Compact).await?; + let event = wait_for_event(&test.codex, |event| match event { + EventMsg::TurnAborted(_) => true, + EventMsg::Error(error) => panic!("budget exhaustion emitted an error: {}", error.message), + EventMsg::TurnComplete(_) => { + panic!("budget-exhausting compaction completed instead of aborting") + } + _ => false, + }) + .await; + let EventMsg::TurnAborted(abort) = event else { + unreachable!("event filter only accepts TurnAborted") + }; + assert_eq!(abort.reason, TurnAbortReason::Interrupted); + assert_eq!(responses.requests().len(), 1, "compaction should not retry"); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn restates_the_current_remainder_after_compaction() -> Result<()> { skip_if_no_network!(Ok(())); From bd5d31b250af454080c4b1baa89adbf2597f16b4 Mon Sep 17 00:00:00 2001 From: jif Date: Fri, 19 Jun 2026 12:49:45 +0100 Subject: [PATCH 072/112] Scope network approvals by environment (#28899) Stacked on #28766. ## Why Network approvals are environment-scoped: allowing a host in one execution environment should not allow the same host in another environment. #28766 adds the inert IDs and constructor plumbing. This PR applies the behavior on top. ## What changed - Route managed network traffic through per-environment HTTP and SOCKS proxy listeners. - Stamp HTTP, HTTPS CONNECT, SOCKS TCP, and SOCKS UDP policy requests with the source environment at the proxy boundary. - Carry the selected execution environment through shell, unified exec, zsh-fork, and sandbox transform paths. - Include the environment in pending, approved-for-session, and denied-for-session network approval cache keys. - Include the environment in approval IDs and approval prompts. - Preserve legacy fallback for unattributed requests, but deny when active-call attribution is ambiguous. - Fail closed if an environment-specific proxy endpoint cannot be prepared. ## Validation - just fmt - CI will run tests and clippy --- codex-rs/cli/src/debug_sandbox.rs | 4 +- codex-rs/core/src/exec.rs | 32 +- .../src/tools/handlers/shell/shell_command.rs | 5 +- codex-rs/core/src/tools/network_approval.rs | 125 +++++-- .../core/src/tools/network_approval_tests.rs | 88 ++++- codex-rs/core/src/tools/runtimes/mod_tests.rs | 1 + codex-rs/core/src/tools/runtimes/shell.rs | 8 +- .../tools/runtimes/shell/unix_escalation.rs | 24 +- .../runtimes/shell/unix_escalation_tests.rs | 1 + .../core/src/tools/runtimes/unified_exec.rs | 28 +- codex-rs/core/src/tools/sandboxing.rs | 3 +- codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/network_approval.rs | 339 ++++++++++++++++++ codex-rs/network-proxy/src/http_proxy.rs | 124 +++++-- codex-rs/network-proxy/src/proxy.rs | 207 ++++++++++- codex-rs/network-proxy/src/socks5.rs | 61 +++- codex-rs/sandboxing/src/lib.rs | 3 + codex-rs/sandboxing/src/manager.rs | 9 +- codex-rs/sandboxing/src/seatbelt.rs | 28 +- codex-rs/sandboxing/src/seatbelt_tests.rs | 44 ++- 20 files changed, 1026 insertions(+), 109 deletions(-) create mode 100644 codex-rs/core/tests/suite/network_approval.rs diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 55a22d0155..5e5bcb6c4e 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -282,9 +282,11 @@ async fn run_command_under_sandbox( network_sandbox_policy, sandbox_policy_cwd: sandbox_policy_cwd.as_path(), enforce_managed_network, + environment_id: None, network: network.as_ref(), extra_allow_unix_sockets: allow_unix_sockets, - }); + }) + .map_err(|err| anyhow::anyhow!(err))?; spawn_debug_sandbox_child( PathBuf::from("/usr/bin/sandbox-exec"), args, diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 0142f995f5..9d55ae8864 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -128,6 +128,16 @@ fn select_process_exec_tool_sandbox_type( ) } +fn network_proxy_environment_error( + network_environment_id: Option<&str>, + err: impl std::fmt::Display, +) -> CodexErr { + let environment_id = network_environment_id.unwrap_or("default"); + CodexErr::Io(io::Error::other(format!( + "failed to prepare network proxy for environment `{environment_id}`: {err}" + ))) +} + /// Mechanism to terminate an exec invocation before it finishes naturally. #[derive(Clone, Debug)] pub enum ExecExpiration { @@ -345,7 +355,11 @@ pub fn build_exec_request( tracing::debug!("Sandbox type: {sandbox_type:?}"); if let Some(network) = network.as_ref() { - network.apply_to_env(&mut env); + network + .apply_to_env_for_optional_environment(&mut env, network_environment_id.as_deref()) + .map_err(|err| { + network_proxy_environment_error(network_environment_id.as_deref(), err) + })?; } let (program, args) = command.split_first().ok_or_else(|| { CodexErr::Io(io::Error::new( @@ -598,7 +612,7 @@ async fn exec_windows_sandbox( cwd, mut env, network, - network_environment_id: _, + network_environment_id, expiration, capture_policy, windows_sandbox_level, @@ -606,7 +620,11 @@ async fn exec_windows_sandbox( .. } = params; if let Some(network) = network.as_ref() { - network.apply_to_env(&mut env); + network + .apply_to_env_for_optional_environment(&mut env, network_environment_id.as_deref()) + .map_err(|err| { + network_proxy_environment_error(network_environment_id.as_deref(), err) + })?; } // Windows sandbox capture still receives timeout and cancellation separately. @@ -947,7 +965,7 @@ async fn exec( cwd, mut env, network, - network_environment_id: _, + network_environment_id, arg0, expiration, capture_policy, @@ -961,7 +979,11 @@ async fn exec( justification: _, } = params; if let Some(network) = network.as_ref() { - network.apply_to_env(&mut env); + network + .apply_to_env_for_optional_environment(&mut env, network_environment_id.as_deref()) + .map_err(|err| { + network_proxy_environment_error(network_environment_id.as_deref(), err) + })?; } let (program, args) = command.split_first().ok_or_else(|| { diff --git a/codex-rs/core/src/tools/handlers/shell/shell_command.rs b/codex-rs/core/src/tools/handlers/shell/shell_command.rs index 6176a07d62..f10c736371 100644 --- a/codex-rs/core/src/tools/handlers/shell/shell_command.rs +++ b/codex-rs/core/src/tools/handlers/shell/shell_command.rs @@ -105,7 +105,10 @@ impl ShellCommandHandler { Some(thread_id), ), network: turn_context.network.clone(), - network_environment_id: None, + network_environment_id: turn_context + .environments + .primary() + .map(|environment| environment.environment_id.clone()), sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), windows_sandbox_level: turn_context.windows_sandbox_level, windows_sandbox_private_desktop: turn_context diff --git a/codex-rs/core/src/tools/network_approval.rs b/codex-rs/core/src/tools/network_approval.rs index b05a745526..2b63164b4d 100644 --- a/codex-rs/core/src/tools/network_approval.rs +++ b/codex-rs/core/src/tools/network_approval.rs @@ -51,6 +51,7 @@ pub(crate) struct NetworkApprovalSpec { pub mode: NetworkApprovalMode, pub trigger: GuardianNetworkAccessTrigger, pub command: String, + pub environment_id: String, } #[derive(Clone, Debug)] @@ -120,14 +121,20 @@ impl ActiveNetworkApproval { #[derive(Clone, Debug, Eq, Hash, PartialEq)] struct HostApprovalKey { + environment_id: String, host: String, protocol: &'static str, port: u16, } impl HostApprovalKey { - fn from_request(request: &NetworkPolicyRequest, protocol: NetworkApprovalProtocol) -> Self { + fn from_request( + request: &NetworkPolicyRequest, + protocol: NetworkApprovalProtocol, + environment_id: String, + ) -> Self { Self { + environment_id, host: request.host.to_ascii_lowercase(), protocol: protocol_key_label(protocol), port: request.port, @@ -224,9 +231,16 @@ struct ActiveNetworkApprovalCall { turn_id: String, trigger: GuardianNetworkAccessTrigger, command: String, + environment_id: String, cancellation_token: CancellationToken, } +enum ActiveNetworkApprovalAttribution { + None, + Single(Arc), + Ambiguous, +} + #[derive(Default)] struct NetworkApprovalCallState { active_calls: IndexMap>, @@ -267,6 +281,7 @@ impl NetworkApprovalService { turn_id: String, trigger: GuardianNetworkAccessTrigger, command: String, + environment_id: String, cancellation_token: CancellationToken, ) { let mut calls = self.calls.lock().await; @@ -278,6 +293,7 @@ impl NetworkApprovalService { turn_id, trigger, command, + environment_id, cancellation_token, }), ); @@ -299,6 +315,18 @@ impl NetworkApprovalService { None } + async fn resolve_active_call_attribution(&self) -> ActiveNetworkApprovalAttribution { + let calls = self.calls.lock().await; + match calls.active_calls.len() { + 0 => ActiveNetworkApprovalAttribution::None, + 1 => calls.active_calls.values().next().cloned().map_or( + ActiveNetworkApprovalAttribution::None, + ActiveNetworkApprovalAttribution::Single, + ), + _ => ActiveNetworkApprovalAttribution::Ambiguous, + } + } + async fn get_or_create_pending_approval( &self, key: HostApprovalKey, @@ -384,7 +412,10 @@ impl NetworkApprovalService { } fn approval_id_for_key(key: &HostApprovalKey) -> String { - format!("network#{}#{}#{}", key.protocol, key.host, key.port) + format!( + "network#{}#{}#{}#{}", + key.environment_id, key.protocol, key.host, key.port + ) } pub(crate) async fn handle_inline_policy_request( @@ -400,7 +431,38 @@ impl NetworkApprovalService { NetworkProtocol::Socks5Tcp => NetworkApprovalProtocol::Socks5Tcp, NetworkProtocol::Socks5Udp => NetworkApprovalProtocol::Socks5Udp, }; - let key = HostApprovalKey::from_request(&request, protocol); + let (owner_call, active_environment_id) = + if let Some(environment_id) = request.environment_id.clone() { + let owner_call = match self.resolve_active_call_attribution().await { + ActiveNetworkApprovalAttribution::Single(call) => { + (call.environment_id == environment_id).then_some(call) + } + ActiveNetworkApprovalAttribution::None + | ActiveNetworkApprovalAttribution::Ambiguous => None, + }; + (owner_call, Some(environment_id)) + } else { + match self.resolve_active_call_attribution().await { + ActiveNetworkApprovalAttribution::None => (None, None), + ActiveNetworkApprovalAttribution::Single(call) => { + let environment_id = call.environment_id.clone(); + (Some(call), Some(environment_id)) + } + ActiveNetworkApprovalAttribution::Ambiguous => { + return NetworkDecision::deny(REASON_NOT_ALLOWED); + } + } + }; + let turn_context = Self::active_turn_context(session.as_ref()).await; + let Some(environment_id) = active_environment_id.or_else(|| { + turn_context + .as_ref() + .and_then(|turn_context| turn_context.environments.primary()) + .map(|environment| environment.environment_id.clone()) + }) else { + return NetworkDecision::deny(REASON_NOT_ALLOWED); + }; + let key = HostApprovalKey::from_request(&request, protocol, environment_id.clone()); { let denied_hosts = self.session_denied_hosts.lock().await; @@ -426,35 +488,43 @@ impl NetworkApprovalService { format!("Network access to \"{target}\" was blocked by policy."); let prompt_reason = format!("{} is not in the allowed_domains", request.host); - let Some(turn_context) = Self::active_turn_context(session.as_ref()).await else { + let Some(turn_context) = turn_context else { pending.set_decision(PendingApprovalDecision::Deny).await; self.pending_host_approvals.lock().await.remove(&key); - self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy( - policy_denial_message, - )) - .await; + if let Some(owner_call) = owner_call.as_ref() { + self.record_call_outcome( + &owner_call.registration_id, + NetworkApprovalOutcome::DeniedByPolicy(policy_denial_message), + ) + .await; + } return NetworkDecision::deny(REASON_NOT_ALLOWED); }; if !permission_profile_allows_network_approval_flow(&turn_context.permission_profile()) { pending.set_decision(PendingApprovalDecision::Deny).await; self.pending_host_approvals.lock().await.remove(&key); - self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy( - policy_denial_message, - )) - .await; + if let Some(owner_call) = owner_call.as_ref() { + self.record_call_outcome( + &owner_call.registration_id, + NetworkApprovalOutcome::DeniedByPolicy(policy_denial_message), + ) + .await; + } return NetworkDecision::deny(REASON_NOT_ALLOWED); } if !allows_network_approval_flow(turn_context.approval_policy.value()) { pending.set_decision(PendingApprovalDecision::Deny).await; self.pending_host_approvals.lock().await.remove(&key); - self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy( - policy_denial_message, - )) - .await; + if let Some(owner_call) = owner_call.as_ref() { + self.record_call_outcome( + &owner_call.registration_id, + NetworkApprovalOutcome::DeniedByPolicy(policy_denial_message), + ) + .await; + } return NetworkDecision::deny(REASON_NOT_ALLOWED); } - let owner_call = self.resolve_single_active_call().await; let network_approval_context = NetworkApprovalContext { host: request.host.clone(), protocol, @@ -519,15 +589,28 @@ impl NetworkApprovalService { .await } else { let available_decisions = None; + let cwd = if let Some(owner_call) = owner_call.as_ref() { + owner_call.trigger.cwd.clone() + } else { + turn_context + .environments + .turn_environments + .iter() + .find(|environment| environment.environment_id == environment_id) + .and_then(|environment| environment.cwd().to_abs_path().ok()) + .unwrap_or_else(|| { + #[allow(deprecated)] + turn_context.cwd.clone() + }) + }; session .request_command_approval( turn_context.as_ref(), guardian_approval_id, /*approval_id*/ None, - /*environment_id*/ None, + Some(environment_id), prompt_command, - #[allow(deprecated)] - turn_context.cwd.clone(), + cwd, Some(prompt_reason), Some(network_approval_context.clone()), /*proposed_execpolicy_amendment*/ None, @@ -712,6 +795,7 @@ pub(crate) async fn begin_network_approval( mode, trigger, command, + environment_id, } = spec?; if !managed_network_active || network.is_none() { return None; @@ -727,6 +811,7 @@ pub(crate) async fn begin_network_approval( turn_id.to_string(), trigger, command, + environment_id, cancellation_token.clone(), ) .await; diff --git a/codex-rs/core/src/tools/network_approval_tests.rs b/codex-rs/core/src/tools/network_approval_tests.rs index 25bcfe255d..60c7215b1b 100644 --- a/codex-rs/core/src/tools/network_approval_tests.rs +++ b/codex-rs/core/src/tools/network_approval_tests.rs @@ -13,6 +13,7 @@ use tokio_util::sync::CancellationToken; async fn pending_approvals_are_deduped_per_host_protocol_and_port() { let service = NetworkApprovalService::default(); let key = HostApprovalKey { + environment_id: "local".to_string(), host: "example.com".to_string(), protocol: "http", port: 443, @@ -30,11 +31,13 @@ async fn pending_approvals_are_deduped_per_host_protocol_and_port() { async fn pending_approvals_do_not_dedupe_across_ports() { let service = NetworkApprovalService::default(); let first_key = HostApprovalKey { + environment_id: "local".to_string(), host: "example.com".to_string(), protocol: "https", port: 443, }; let second_key = HostApprovalKey { + environment_id: "local".to_string(), host: "example.com".to_string(), protocol: "https", port: 8443, @@ -48,6 +51,56 @@ async fn pending_approvals_do_not_dedupe_across_ports() { assert!(!Arc::ptr_eq(&first, &second)); } +#[tokio::test] +async fn pending_approvals_do_not_dedupe_across_environments() { + let service = NetworkApprovalService::default(); + let first_key = HostApprovalKey { + environment_id: "local".to_string(), + host: "example.com".to_string(), + protocol: "https", + port: 443, + }; + let second_key = HostApprovalKey { + environment_id: "remote".to_string(), + ..first_key.clone() + }; + + let (first, first_is_owner) = service.get_or_create_pending_approval(first_key).await; + let (second, second_is_owner) = service.get_or_create_pending_approval(second_key).await; + + assert!(first_is_owner); + assert!(second_is_owner); + assert!(!Arc::ptr_eq(&first, &second)); +} + +#[tokio::test] +async fn session_approved_hosts_are_scoped_by_environment() { + let service = NetworkApprovalService::default(); + let local_key = HostApprovalKey { + environment_id: "local".to_string(), + host: "example.com".to_string(), + protocol: "https", + port: 443, + }; + let remote_key = HostApprovalKey { + environment_id: "remote".to_string(), + ..local_key.clone() + }; + service + .session_approved_hosts + .lock() + .await + .insert(local_key); + + assert!( + !service + .session_approved_hosts + .lock() + .await + .contains(&remote_key) + ); +} + #[tokio::test] async fn session_approved_hosts_preserve_protocol_and_port_scope() { let source = NetworkApprovalService::default(); @@ -55,16 +108,19 @@ async fn session_approved_hosts_preserve_protocol_and_port_scope() { let mut approved_hosts = source.session_approved_hosts.lock().await; approved_hosts.extend([ HostApprovalKey { + environment_id: "local".to_string(), host: "example.com".to_string(), protocol: "https", port: 443, }, HostApprovalKey { + environment_id: "local".to_string(), host: "example.com".to_string(), protocol: "https", port: 8443, }, HostApprovalKey { + environment_id: "local".to_string(), host: "example.com".to_string(), protocol: "http", port: 80, @@ -82,22 +138,32 @@ async fn session_approved_hosts_preserve_protocol_and_port_scope() { .iter() .cloned() .collect::>(); - copied.sort_by(|a, b| (&a.host, a.protocol, a.port).cmp(&(&b.host, b.protocol, b.port))); + copied.sort_by(|a, b| { + (&a.environment_id, &a.host, a.protocol, a.port).cmp(&( + &b.environment_id, + &b.host, + b.protocol, + b.port, + )) + }); assert_eq!( copied, vec![ HostApprovalKey { + environment_id: "local".to_string(), host: "example.com".to_string(), protocol: "http", port: 80, }, HostApprovalKey { + environment_id: "local".to_string(), host: "example.com".to_string(), protocol: "https", port: 443, }, HostApprovalKey { + environment_id: "local".to_string(), host: "example.com".to_string(), protocol: "https", port: 8443, @@ -112,6 +178,7 @@ async fn sync_session_approved_hosts_to_replaces_existing_target_hosts() { { let mut approved_hosts = source.session_approved_hosts.lock().await; approved_hosts.insert(HostApprovalKey { + environment_id: "local".to_string(), host: "source.example.com".to_string(), protocol: "https", port: 443, @@ -122,6 +189,7 @@ async fn sync_session_approved_hosts_to_replaces_existing_target_hosts() { { let mut approved_hosts = target.session_approved_hosts.lock().await; approved_hosts.insert(HostApprovalKey { + environment_id: "local".to_string(), host: "stale.example.com".to_string(), protocol: "https", port: 8443, @@ -141,6 +209,7 @@ async fn sync_session_approved_hosts_to_replaces_existing_target_hosts() { assert_eq!( copied, vec![HostApprovalKey { + environment_id: "local".to_string(), host: "source.example.com".to_string(), protocol: "https", port: 443, @@ -237,6 +306,7 @@ async fn register_call_with_default_shell_trigger( tty: None, }, "curl https://example.com".to_string(), + "local".to_string(), cancellation_token.clone(), ) .await; @@ -263,6 +333,7 @@ async fn active_call_preserves_triggering_command_context() { "turn-1".to_string(), expected.clone(), "curl https://example.com".to_string(), + "remote".to_string(), CancellationToken::new(), ) .await; @@ -274,6 +345,21 @@ async fn active_call_preserves_triggering_command_context() { assert_eq!(&call.trigger, &expected); assert_eq!(call.command, "curl https://example.com"); + assert_eq!(call.environment_id, "remote"); +} + +#[tokio::test] +async fn multiple_active_calls_are_ambiguous_even_in_the_same_environment() { + let service = NetworkApprovalService::default(); + register_call_with_default_shell_trigger(&service, "registration-1").await; + register_call_with_default_shell_trigger(&service, "registration-2").await; + + match service.resolve_active_call_attribution().await { + ActiveNetworkApprovalAttribution::Ambiguous => {} + ActiveNetworkApprovalAttribution::None | ActiveNetworkApprovalAttribution::Single(_) => { + panic!("multiple active calls should be ambiguous") + } + } } #[tokio::test] diff --git a/codex-rs/core/src/tools/runtimes/mod_tests.rs b/codex-rs/core/src/tools/runtimes/mod_tests.rs index 542589ad89..91db26aaae 100644 --- a/codex-rs/core/src/tools/runtimes/mod_tests.rs +++ b/codex-rs/core/src/tools/runtimes/mod_tests.rs @@ -126,6 +126,7 @@ async fn explicit_escalation_prepares_exec_without_managed_network() -> anyhow:: Some(&proxy), SandboxPermissions::RequireEscalated, ), + /*environment_id*/ None, ) .expect("prepare exec request"); diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index f88c2be3d4..0d386103db 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -236,6 +236,7 @@ impl ToolRuntime for ShellRuntime { tty: None, }, command: req.hook_command.clone(), + environment_id: req.turn_environment.environment_id.clone(), }) } @@ -321,7 +322,12 @@ impl ToolRuntime for ShellRuntime { capture_policy: ExecCapturePolicy::ShellTool, }; let env = attempt - .env_for(command, options, managed_network) + .env_for( + command, + options, + managed_network, + Some(&req.turn_environment.environment_id), + ) .map_err(ToolError::Codex)?; let out = execute_env(env, Self::stdout_stream(ctx)) .await diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 2acbf37781..c08148c7ee 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -68,6 +68,7 @@ use codex_shell_escalation::Stopwatch; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use std::collections::HashMap; +use std::io; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -143,6 +144,7 @@ pub(super) async fn try_run_zsh_fork( command, options, managed_network_for_sandbox_permissions(req.network.as_ref(), req.sandbox_permissions), + Some(&req.turn_environment.environment_id), ) .map_err(ToolError::Codex)?; let crate::sandboxing::ExecRequest { @@ -151,7 +153,7 @@ pub(super) async fn try_run_zsh_fork( env: sandbox_env, exec_server_env_config: _, network: sandbox_network, - network_environment_id: _, + network_environment_id, expiration: _sandbox_expiration, capture_policy: _capture_policy, sandbox, @@ -190,6 +192,7 @@ pub(super) async fn try_run_zsh_fork( sandbox, env: sandbox_env, network: sandbox_network, + network_environment_id, windows_sandbox_level, arg0, sandbox_policy_cwd, @@ -302,6 +305,7 @@ pub(crate) async fn prepare_unified_exec_zsh_fork( sandbox: exec_request.sandbox, env: exec_request.env.clone(), network: exec_request.network.clone(), + network_environment_id: exec_request.network_environment_id.clone(), windows_sandbox_level: exec_request.windows_sandbox_level, arg0: exec_request.arg0.clone(), sandbox_policy_cwd, @@ -810,6 +814,7 @@ struct CoreShellCommandExecutor { sandbox: SandboxType, env: HashMap, network: Option, + network_environment_id: Option, windows_sandbox_level: WindowsSandboxLevel, arg0: Option, sandbox_policy_cwd: AbsolutePathBuf, @@ -880,7 +885,7 @@ impl CoreShellCommandExecutor { env: exec_env, exec_server_env_config: None, network: self.network.clone(), - network_environment_id: None, + network_environment_id: self.network_environment_id.clone(), expiration: ExecExpiration::Cancellation(cancel_rx), capture_policy: ExecCapturePolicy::ShellTool, sandbox: self.sandbox, @@ -1012,7 +1017,7 @@ impl CoreShellCommandExecutor { permissions: permission_profile, sandbox, enforce_managed_network: self.network.is_some(), - environment_id: None, + environment_id: self.network_environment_id.as_deref(), network: self.network.as_ref(), sandbox_policy_cwd: &sandbox_policy_cwd, codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.as_deref(), @@ -1026,7 +1031,18 @@ impl CoreShellCommandExecutor { self.windows_sandbox_workspace_roots.clone(), ); if let Some(network) = exec_request.network.as_ref() { - network.apply_to_env(&mut exec_request.env); + network + .apply_to_env_for_optional_environment( + &mut exec_request.env, + self.network_environment_id.as_deref(), + ) + .map_err(|err| { + let environment_id = + self.network_environment_id.as_deref().unwrap_or("default"); + CodexErr::Io(io::Error::other(format!( + "failed to prepare network proxy for environment `{environment_id}`: {err}" + ))) + })?; } Ok(PreparedExec { diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index 2b00147612..5478c1d946 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -366,6 +366,7 @@ async fn unsandboxed_intercepted_exec_strips_managed_network_env() -> anyhow::Re sandbox: SandboxType::None, env: HashMap::new(), network: None, + network_environment_id: None, windows_sandbox_level: WindowsSandboxLevel::Disabled, arg0: None, sandbox_policy_cwd: workdir.clone(), diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 80f0990b5c..0d6c93b4d0 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -53,6 +53,7 @@ use codex_tools::UnifiedExecShellMode; use codex_utils_path_uri::PathUri; use futures::future::BoxFuture; use std::collections::HashMap; +use std::io; use tokio_util::sync::CancellationToken; use tracing::error; @@ -283,6 +284,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt tty: Some(req.tty), }, command: req.hook_command.clone(), + environment_id: req.turn_environment.environment_id.clone(), }) } @@ -321,7 +323,17 @@ impl<'a> ToolRuntime for UnifiedExecRunt ); let mut env = exec_env_for_sandbox_permissions(&req.env, launch_sandbox_permissions); if let Some(network) = managed_network { - network.apply_to_env(&mut env); + network + .apply_to_env_for_optional_environment( + &mut env, + Some(&req.turn_environment.environment_id), + ) + .map_err(|err| { + ToolError::Codex(CodexErr::Io(io::Error::other(format!( + "failed to prepare network proxy for environment `{}`: {err}", + req.turn_environment.environment_id + )))) + })?; } let explicit_env_overrides = req.explicit_env_overrides.clone(); #[cfg(unix)] @@ -383,7 +395,12 @@ impl<'a> ToolRuntime for UnifiedExecRunt })?; let options = unified_exec_options(attempt.network_denial_cancellation_token.clone()); let mut exec_env = attempt - .env_for(command, options, managed_network) + .env_for( + command, + options, + managed_network, + Some(&req.turn_environment.environment_id), + ) .map_err(ToolError::Codex)?; exec_env.exec_server_env_config = req.exec_server_env_config.clone(); match zsh_fork_backend::maybe_prepare_unified_exec( @@ -443,7 +460,12 @@ impl<'a> ToolRuntime for UnifiedExecRunt })?; let options = unified_exec_options(attempt.network_denial_cancellation_token.clone()); let mut exec_env = attempt - .env_for(command, options, managed_network) + .env_for( + command, + options, + managed_network, + Some(&req.turn_environment.environment_id), + ) .map_err(ToolError::Codex)?; exec_env.exec_server_env_config = req.exec_server_env_config.clone(); self.manager diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index e80cbbba1a..48c06413b1 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -426,6 +426,7 @@ impl<'a> SandboxAttempt<'a> { command: SandboxCommand, options: ExecOptions, network: Option<&NetworkProxy>, + environment_id: Option<&str>, ) -> Result { let request = self .manager @@ -434,7 +435,7 @@ impl<'a> SandboxAttempt<'a> { permissions: self.permissions, sandbox: self.sandbox, enforce_managed_network: self.enforce_managed_network, - environment_id: None, + environment_id, network, sandbox_policy_cwd: self.sandbox_cwd, codex_linux_sandbox_exe: self diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 800b52c775..492f309795 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -74,6 +74,7 @@ mod model_visible_layout; mod models_cache_ttl; mod models_etag_responses; mod multi_agent_mode; +mod network_approval; mod openai_file_mcp; mod otel; mod override_updates; diff --git a/codex-rs/core/tests/suite/network_approval.rs b/codex-rs/core/tests/suite/network_approval.rs new file mode 100644 index 0000000000..14db16c60a --- /dev/null +++ b/codex-rs/core/tests/suite/network_approval.rs @@ -0,0 +1,339 @@ +use anyhow::Context; +use anyhow::Result; +use codex_config::types::ApprovalsReviewer; +use codex_core::config::Constrained; +use codex_exec_server::CreateDirectoryOptions; +use codex_exec_server::LOCAL_ENVIRONMENT_ID; +use codex_exec_server::REMOTE_ENVIRONMENT_ID; +use codex_exec_server::RemoveOptions; +use codex_features::Feature; +use codex_protocol::approvals::NetworkApprovalContext; +use codex_protocol::approvals::NetworkApprovalProtocol; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecApprovalRequestEvent; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::TurnEnvironmentSelection; +use codex_protocol::protocol::TurnEnvironmentSelections; +use codex_protocol::user_input::UserInput; +use codex_utils_path_uri::PathUri; +use core_test_support::PathBufExt; +use core_test_support::PathExt; +use core_test_support::get_remote_test_env; +use core_test_support::managed_network_requirements_loader; +use core_test_support::responses::ResponseMock; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::skip_if_sandbox; +use core_test_support::skip_if_windows; +use core_test_support::skip_if_wine_exec; +use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::local; +use core_test_support::test_codex::test_codex; +use core_test_support::test_codex::turn_permission_fields; +use core_test_support::wait_for_event; +use core_test_support::wait_for_event_with_timeout; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use std::fs; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; +use tempfile::TempDir; + +const NETWORK_TEST_HOST: &str = "codex-network-test.invalid"; +const NETWORK_TEST_TARGET: &str = "http://codex-network-test.invalid:80"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn approved_network_host_for_one_environment_still_prompts_in_another() -> Result<()> { + skip_if_wine_exec!(Ok(()), "uses the POSIX/Python network fixture"); + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); + let Some(_remote_env) = get_remote_test_env() else { + return Ok(()); + }; + + let server = start_mock_server().await; + let test = managed_network_unified_exec_test(&server).await?; + let local_cwd = TempDir::new()?; + let remote_cwd = PathBuf::from(format!( + "/tmp/codex-network-approval-{}", + SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() + )) + .abs(); + let remote_cwd_uri = PathUri::from_path(&remote_cwd)?; + test.fs() + .create_directory( + &remote_cwd_uri, + CreateDirectoryOptions { recursive: true }, + /*sandbox*/ None, + ) + .await?; + let environments = vec![ + local(local_cwd.path().abs()), + TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: PathUri::from_abs_path(&remote_cwd), + }, + ]; + + mount_exec_network_turn( + &server, + "resp-network-local", + "exec-network-local", + network_fetch_args(LOCAL_ENVIRONMENT_ID), + ) + .await?; + submit_managed_network_turn( + &test, + "fetch from the local environment", + environments.clone(), + ) + .await?; + let approval = expect_network_approval(&test, LOCAL_ENVIRONMENT_ID).await?; + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: ReviewDecision::ApprovedForSession, + }) + .await?; + wait_for_turn_complete(&test).await; + + mount_exec_network_turn( + &server, + "resp-network-remote", + "exec-network-remote", + network_fetch_args(REMOTE_ENVIRONMENT_ID), + ) + .await?; + submit_managed_network_turn( + &test, + "fetch from the remote environment", + environments.clone(), + ) + .await?; + let approval = expect_network_approval(&test, REMOTE_ENVIRONMENT_ID).await?; + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: ReviewDecision::Denied, + }) + .await?; + wait_for_turn_complete(&test).await; + + test.fs() + .remove( + &remote_cwd_uri, + RemoveOptions { + recursive: true, + force: true, + }, + /*sandbox*/ None, + ) + .await?; + + Ok(()) +} + +async fn managed_network_unified_exec_test(server: &wiremock::MockServer) -> Result { + let home = Arc::new(TempDir::new()?); + fs::write( + home.path().join("config.toml"), + r#"default_permissions = "workspace" + +[permissions.workspace.filesystem] +":minimal" = "read" + +[permissions.workspace.network] +enabled = true +mode = "limited" +allow_local_binding = true +"#, + )?; + let approval_policy = AskForApproval::OnFailure; + let permission_profile = PermissionProfile::workspace_write_with( + &[], + NetworkSandboxPolicy::Enabled, + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ); + let permission_profile_for_config = permission_profile.clone(); + let mut builder = test_codex() + .with_home(home) + .with_cloud_config_bundle(managed_network_requirements_loader()) + .with_config(move |config| { + config.use_experimental_unified_exec_tool = true; + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config + .permissions + .set_permission_profile(permission_profile_for_config) + .expect("set permission profile"); + }); + let test = builder.build_with_remote_and_local_env(server).await?; + assert!( + test.config.managed_network_requirements_enabled(), + "expected managed network requirements to be enabled" + ); + assert!( + test.config.permissions.network.is_some(), + "expected managed network proxy config to be present" + ); + test.session_configured + .network_proxy + .as_ref() + .expect("expected runtime managed network proxy addresses"); + + Ok(test) +} + +async fn mount_exec_network_turn( + server: &wiremock::MockServer, + response_prefix: &str, + call_id: &str, + args: Value, +) -> Result { + let responses = vec![ + sse(vec![ + ev_response_created(&format!("{response_prefix}-1")), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed(&format!("{response_prefix}-1")), + ]), + sse(vec![ + ev_response_created(&format!("{response_prefix}-2")), + ev_assistant_message(&format!("{response_prefix}-msg"), "done"), + ev_completed(&format!("{response_prefix}-2")), + ]), + ]; + Ok(mount_sse_sequence(server, responses).await) +} + +fn network_fetch_args(environment_id: &str) -> Value { + json!({ + "shell": "/bin/sh", + "cmd": format!("python3 -c \"import urllib.request; opener = urllib.request.build_opener(urllib.request.ProxyHandler()); print('OK:' + opener.open('http://{NETWORK_TEST_HOST}', timeout=2).read().decode(errors='replace'))\""), + "login": false, + "yield_time_ms": 1_000, + "environment_id": environment_id, + }) +} + +async fn submit_managed_network_turn( + test: &TestCodex, + prompt: &str, + environments: Vec, +) -> Result<()> { + let permission_profile = PermissionProfile::workspace_write_with( + &[], + NetworkSandboxPolicy::Enabled, + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ); + let (sandbox_policy, permission_profile) = + turn_permission_fields(permission_profile, test.config.cwd.as_path()); + let turn_environment_selections = + TurnEnvironmentSelections::new(test.config.cwd.clone(), environments); + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: prompt.into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + environments: Some(turn_environment_selections), + approval_policy: Some(AskForApproval::OnFailure), + approvals_reviewer: Some(ApprovalsReviewer::User), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: test.session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, + }) + .await?; + + Ok(()) +} + +async fn expect_network_approval( + test: &TestCodex, + expected_environment_id: &str, +) -> Result { + let deadline = std::time::Instant::now() + Duration::from_secs(30); + let remaining = deadline + .checked_duration_since(std::time::Instant::now()) + .context("timed out waiting for network approval request")?; + let event = wait_for_event_with_timeout( + &test.codex, + |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) + ) + }, + remaining, + ) + .await; + match event { + EventMsg::ExecApprovalRequest(approval) => { + assert_eq!( + approval.command, + vec![ + "network-access".to_string(), + NETWORK_TEST_TARGET.to_string() + ] + ); + assert_eq!( + approval.network_approval_context, + Some(NetworkApprovalContext { + host: NETWORK_TEST_HOST.to_string(), + protocol: NetworkApprovalProtocol::Http, + }) + ); + assert_eq!( + approval.environment_id.as_deref(), + Some(expected_environment_id) + ); + Ok(approval) + } + EventMsg::TurnComplete(_) => { + panic!("expected network approval request before completion"); + } + other => panic!("unexpected event: {other:?}"), + } +} + +async fn wait_for_turn_complete(test: &TestCodex) { + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; +} diff --git a/codex-rs/network-proxy/src/http_proxy.rs b/codex-rs/network-proxy/src/http_proxy.rs index c07ff17606..3f02ad3fc0 100644 --- a/codex-rs/network-proxy/src/http_proxy.rs +++ b/codex-rs/network-proxy/src/http_proxy.rs @@ -87,6 +87,7 @@ pub async fn run_http_proxy( state: Arc, addr: SocketAddr, policy_decider: Option>, + environment_id: Option, ) -> Result<()> { let listener = TcpListener::build() .bind(addr) @@ -99,23 +100,25 @@ pub async fn run_http_proxy( .map_err(anyhow::Error::from) .with_context(|| format!("bind HTTP proxy: {addr}"))?; - run_http_proxy_with_listener(state, listener, policy_decider).await + run_http_proxy_with_listener(state, listener, policy_decider, environment_id).await } pub async fn run_http_proxy_with_std_listener( state: Arc, listener: StdTcpListener, policy_decider: Option>, + environment_id: Option, ) -> Result<()> { let listener = TcpListener::try_from(listener).context("convert std listener to HTTP proxy listener")?; - run_http_proxy_with_listener(state, listener, policy_decider).await + run_http_proxy_with_listener(state, listener, policy_decider, environment_id).await } async fn run_http_proxy_with_listener( state: Arc, listener: TcpListener, policy_decider: Option>, + environment_id: Option, ) -> Result<()> { ensure_rustls_crypto_provider(); @@ -133,7 +136,10 @@ async fn run_http_proxy_with_listener( MethodMatcher::CONNECT, service_fn({ let policy_decider = policy_decider.clone(); - move |req| http_connect_accept(policy_decider.clone(), req) + let environment_id = environment_id.clone(); + move |req| { + http_connect_accept(policy_decider.clone(), environment_id.clone(), req) + } }), service_fn(http_connect_proxy), ), @@ -141,7 +147,8 @@ async fn run_http_proxy_with_listener( ) .into_layer(service_fn({ let policy_decider = policy_decider.clone(); - move |req| http_plain_proxy(policy_decider.clone(), req) + let environment_id = environment_id.clone(); + move |req| http_plain_proxy(policy_decider.clone(), environment_id.clone(), req) })), ); @@ -155,6 +162,7 @@ async fn run_http_proxy_with_listener( async fn http_connect_accept( policy_decider: Option>, + environment_id: Option, mut req: Request, ) -> Result<(Response, Request), Response> { let app_state = req @@ -200,7 +208,7 @@ async fn http_connect_accept( protocol: NetworkProtocol::HttpsConnect, host: host.clone(), port: authority.port, - environment_id: None, + environment_id, client_addr: client.clone(), method: Some("CONNECT".to_string()), command: None, @@ -479,6 +487,7 @@ async fn forward_connect_tunnel( async fn http_plain_proxy( policy_decider: Option>, + environment_id: Option, mut req: Request, ) -> Result { let app_state = match req.extensions().get::>().cloned() { @@ -684,7 +693,7 @@ async fn http_plain_proxy( protocol: NetworkProtocol::Http, host: host.clone(), port, - environment_id: None, + environment_id, client_addr: client.clone(), method: Some(req.method().as_str().to_string()), command: None, @@ -1052,6 +1061,7 @@ mod tests { use std::net::Ipv4Addr; use std::net::TcpListener as StdTcpListener; use std::sync::Arc; + use std::sync::Mutex; use tokio::io::AsyncReadExt; use tokio::io::AsyncWriteExt; use tokio::net::TcpListener as TokioTcpListener; @@ -1076,9 +1086,11 @@ mod tests { .unwrap(); req.extensions_mut().insert(state); - let response = http_connect_accept(/*policy_decider*/ None, req) - .await - .unwrap_err(); + let response = http_connect_accept( + /*policy_decider*/ None, /*environment_id*/ None, req, + ) + .await + .unwrap_err(); assert_eq!(response.status(), StatusCode::FORBIDDEN); assert_eq!( response.headers().get("x-proxy-error").unwrap(), @@ -1106,12 +1118,53 @@ mod tests { .unwrap(); req.extensions_mut().insert(state); - let (response, _request) = http_connect_accept(/*policy_decider*/ None, req) - .await - .unwrap(); + let (response, _request) = http_connect_accept( + /*policy_decider*/ None, /*environment_id*/ None, req, + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::OK); } + #[tokio::test] + async fn http_connect_accept_passes_environment_id_to_decider() { + let state = Arc::new(network_proxy_state_for_policy( + NetworkProxySettings::default(), + )); + let seen_environment_id = Arc::new(Mutex::new(None)); + let decider: Arc = Arc::new({ + let seen_environment_id = seen_environment_id.clone(); + move |request: NetworkPolicyRequest| { + *seen_environment_id + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = request.environment_id; + async { NetworkDecision::Allow } + } + }); + + let mut req = Request::builder() + .method(Method::CONNECT) + .uri("https://example.com:443") + .header("host", "example.com:443") + .body(Body::empty()) + .unwrap(); + req.extensions_mut().insert(state); + + let (response, _request) = + http_connect_accept(Some(decider), Some("remote".to_string()), req) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + seen_environment_id + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_deref(), + Some("remote") + ); + } + #[tokio::test] async fn http_connect_accept_blocks_hooked_host_in_full_mode_without_mitm_state() { let mut policy = NetworkProxySettings { @@ -1138,9 +1191,11 @@ mod tests { .unwrap(); req.extensions_mut().insert(state); - let response = http_connect_accept(/*policy_decider*/ None, req) - .await - .unwrap_err(); + let response = http_connect_accept( + /*policy_decider*/ None, /*environment_id*/ None, req, + ) + .await + .unwrap_err(); assert_eq!(response.status(), StatusCode::FORBIDDEN); assert_eq!( response.headers().get("x-proxy-error").unwrap(), @@ -1177,7 +1232,7 @@ mod tests { .local_addr() .expect("proxy listener should expose local addr"); let proxy_task = tokio::spawn(run_http_proxy_with_std_listener( - state, listener, /*policy_decider*/ None, + state, listener, /*policy_decider*/ None, /*environment_id*/ None, )); let mut stream = tokio::net::TcpStream::connect(proxy_addr) @@ -1228,9 +1283,11 @@ mod tests { .expect("request should build"); req.extensions_mut().insert(state); - let response = http_plain_proxy(/*policy_decider*/ None, req) - .await - .unwrap(); + let response = http_plain_proxy( + /*policy_decider*/ None, /*environment_id*/ None, req, + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); assert_eq!( @@ -1253,9 +1310,11 @@ mod tests { .expect("request should build"); req.extensions_mut().insert(state); - let response = http_plain_proxy(/*policy_decider*/ None, req) - .await - .unwrap(); + let response = http_plain_proxy( + /*policy_decider*/ None, /*environment_id*/ None, req, + ) + .await + .unwrap(); if cfg!(target_os = "macos") { assert_eq!(response.status(), StatusCode::FORBIDDEN); @@ -1285,9 +1344,11 @@ mod tests { .expect("request should build"); req.extensions_mut().insert(state); - let response = http_plain_proxy(/*policy_decider*/ None, req) - .await - .unwrap(); + let response = http_plain_proxy( + /*policy_decider*/ None, /*environment_id*/ None, req, + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::BAD_GATEWAY); } @@ -1309,9 +1370,11 @@ mod tests { .unwrap(); req.extensions_mut().insert(state); - let response = http_connect_accept(/*policy_decider*/ None, req) - .await - .unwrap_err(); + let response = http_connect_accept( + /*policy_decider*/ None, /*environment_id*/ None, req, + ) + .await + .unwrap_err(); assert_eq!(response.status(), StatusCode::FORBIDDEN); assert_eq!( response.headers().get("x-proxy-error").unwrap(), @@ -1332,7 +1395,10 @@ mod tests { .unwrap(); req.extensions_mut().insert(state); - let response = http_plain_proxy(/*policy_decider*/ None, req).await; + let response = http_plain_proxy( + /*policy_decider*/ None, /*environment_id*/ None, req, + ) + .await; assert_eq!(response.unwrap().status(), StatusCode::BAD_REQUEST); } diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index c3685a4310..ef56846599 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -222,11 +222,13 @@ impl NetworkProxyBuilder { http_addr, socks_addr, socks_enabled: current_cfg.network.enable_socks5, + socks5_udp_enabled: current_cfg.network.enable_socks5_udp, runtime_settings: Arc::new(RwLock::new(NetworkProxyRuntimeSettings::from_config( ¤t_cfg, )?)), reserved_listeners, policy_decider: self.policy_decider, + environment_proxies: Arc::new(Mutex::new(HashMap::new())), }) } } @@ -323,15 +325,29 @@ impl NetworkProxyRuntimeSettings { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct EnvironmentProxyAddrs { + http_addr: SocketAddr, + socks_addr: SocketAddr, +} + +struct EnvironmentProxy { + addrs: EnvironmentProxyAddrs, + http_task: JoinHandle>, + socks_task: Option>>, +} + #[derive(Clone)] pub struct NetworkProxy { state: Arc, http_addr: SocketAddr, socks_addr: SocketAddr, socks_enabled: bool, + socks5_udp_enabled: bool, runtime_settings: Arc>, reserved_listeners: Option>, policy_decider: Option>, + environment_proxies: Arc>>, } impl std::fmt::Debug for NetworkProxy { @@ -640,20 +656,135 @@ impl NetworkProxy { }) } - pub fn apply_to_env(&self, env: &mut HashMap) { + fn apply_to_env_for_addrs( + &self, + env: &mut HashMap, + addrs: EnvironmentProxyAddrs, + ) { let runtime_settings = self.runtime_settings(); // Enforce proxying for child processes. Proxy endpoint values are always rewritten; // managed MITM CA vars preserve child-scoped overrides after proxy startup. apply_proxy_env_overrides( env, - self.http_addr, - self.socks_addr, + addrs.http_addr, + addrs.socks_addr, self.socks_enabled, runtime_settings.allow_local_binding, runtime_settings.mitm_ca_trust_bundle.as_ref(), ); } + pub fn apply_to_env(&self, env: &mut HashMap) { + self.apply_to_env_for_addrs( + env, + EnvironmentProxyAddrs { + http_addr: self.http_addr, + socks_addr: self.socks_addr, + }, + ); + } + + pub fn apply_to_env_for_environment( + &self, + env: &mut HashMap, + environment_id: &str, + ) -> Result<()> { + let addrs = self.environment_proxy_addrs(environment_id)?; + self.apply_to_env_for_addrs(env, addrs); + Ok(()) + } + + pub fn apply_to_env_for_optional_environment( + &self, + env: &mut HashMap, + environment_id: Option<&str>, + ) -> Result<()> { + match environment_id { + Some(environment_id) => self.apply_to_env_for_environment(env, environment_id), + None => { + self.apply_to_env(env); + Ok(()) + } + } + } + + fn environment_proxy_addrs(&self, environment_id: &str) -> Result { + let mut proxies = self + .environment_proxies + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(proxy) = proxies.get(environment_id) { + return Ok(proxy.addrs); + } + + let runtime = tokio::runtime::Handle::try_current().with_context(|| { + format!("failed to create network proxy for environment `{environment_id}`") + })?; + let listeners = + reserve_loopback_ephemeral_listeners(self.socks_enabled).with_context(|| { + format!("failed to reserve network proxy for environment `{environment_id}`") + })?; + let http_addr = listeners.http_addr().with_context(|| { + format!("failed to read HTTP proxy address for environment `{environment_id}`") + })?; + let socks_addr = listeners.socks_addr(self.socks_addr).with_context(|| { + format!("failed to read SOCKS proxy address for environment `{environment_id}`") + })?; + let addrs = EnvironmentProxyAddrs { + http_addr, + socks_addr, + }; + let ReservedListenerSet { + http_listener, + socks_listener, + } = listeners; + + let environment_id = environment_id.to_string(); + let http_state = self.state.clone(); + let http_decider = self.policy_decider.clone(); + let http_environment_id = Some(environment_id.clone()); + let http_task = runtime.spawn(async move { + http_proxy::run_http_proxy_with_std_listener( + http_state, + http_listener, + http_decider, + http_environment_id, + ) + .await + }); + + let socks_task = if self.socks_enabled { + let socks_state = self.state.clone(); + let socks_decider = self.policy_decider.clone(); + let socks_environment_id = Some(environment_id.clone()); + let socks5_udp_enabled = self.socks5_udp_enabled; + socks_listener.map(|listener| { + runtime.spawn(async move { + socks5::run_socks5_with_std_listener( + socks_state, + listener, + socks_decider, + socks_environment_id, + socks5_udp_enabled, + ) + .await + }) + }) + } else { + None + }; + + proxies.insert( + environment_id, + EnvironmentProxy { + addrs, + http_task, + socks_task, + }, + ); + Ok(addrs) + } + pub async fn replace_config_state(&self, new_state: ConfigState) -> Result<()> { let current_cfg = self.state.current_cfg().await?; anyhow::ensure!( @@ -717,10 +848,23 @@ impl NetworkProxy { let http_task = tokio::spawn(async move { match http_listener { Some(listener) => { - http_proxy::run_http_proxy_with_std_listener(http_state, listener, http_decider) - .await + http_proxy::run_http_proxy_with_std_listener( + http_state, + listener, + http_decider, + /*environment_id*/ None, + ) + .await + } + None => { + http_proxy::run_http_proxy( + http_state, + http_addr, + http_decider, + /*environment_id*/ None, + ) + .await } - None => http_proxy::run_http_proxy(http_state, http_addr, http_decider).await, } }); @@ -736,6 +880,7 @@ impl NetworkProxy { socks_state, listener, socks_decider, + /*environment_id*/ None, enable_socks5_udp, ) .await @@ -745,6 +890,7 @@ impl NetworkProxy { socks_state, socks_addr, socks_decider, + /*environment_id*/ None, enable_socks5_udp, ) .await @@ -758,6 +904,7 @@ impl NetworkProxy { Ok(NetworkProxyHandle { http_task: Some(http_task), socks_task, + environment_proxies: self.environment_proxies.clone(), completed: false, }) } @@ -766,6 +913,7 @@ impl NetworkProxy { pub struct NetworkProxyHandle { http_task: Option>>, socks_task: Option>>, + environment_proxies: Arc>>, completed: bool, } @@ -774,6 +922,7 @@ impl NetworkProxyHandle { Self { http_task: Some(tokio::spawn(async { Ok(()) })), socks_task: None, + environment_proxies: Arc::new(Mutex::new(HashMap::new())), completed: true, } } @@ -787,6 +936,7 @@ impl NetworkProxyHandle { None => None, }; self.completed = true; + abort_environment_proxies(self.environment_proxies.clone()).await; http_result??; if let Some(socks_result) = socks_result { socks_result??; @@ -796,6 +946,7 @@ impl NetworkProxyHandle { pub async fn shutdown(mut self) -> Result<()> { abort_tasks(self.http_task.take(), self.socks_task.take()).await; + abort_environment_proxies(self.environment_proxies.clone()).await; self.completed = true; Ok(()) } @@ -816,6 +967,21 @@ async fn abort_tasks( abort_task(socks_task).await; } +async fn abort_environment_proxies( + environment_proxies: Arc>>, +) { + let proxies = { + let mut guard = environment_proxies + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + guard.drain().map(|(_, proxy)| proxy).collect::>() + }; + for proxy in proxies { + abort_task(Some(proxy.http_task)).await; + abort_task(proxy.socks_task).await; + } +} + impl Drop for NetworkProxyHandle { fn drop(&mut self) { if self.completed { @@ -823,8 +989,10 @@ impl Drop for NetworkProxyHandle { } let http_task = self.http_task.take(); let socks_task = self.socks_task.take(); + let environment_proxies = self.environment_proxies.clone(); tokio::spawn(async move { abort_tasks(http_task, socks_task).await; + abort_environment_proxies(environment_proxies).await; }); } } @@ -905,6 +1073,33 @@ mod tests { ); } + #[tokio::test] + async fn apply_to_env_for_environment_uses_distinct_proxy_ports() -> Result<()> { + let state = Arc::new(network_proxy_state_for_policy( + NetworkProxySettings::default(), + )); + let proxy = NetworkProxy::builder().state(state).build().await?; + let handle = proxy.run().await?; + + let mut local_env = HashMap::new(); + proxy.apply_to_env_for_environment(&mut local_env, "local")?; + let mut remote_env = HashMap::new(); + proxy.apply_to_env_for_environment(&mut remote_env, "remote")?; + + assert_ne!(local_env.get("HTTP_PROXY"), remote_env.get("HTTP_PROXY")); + assert_ne!( + local_env.get("HTTP_PROXY"), + Some(&format!("http://{}", proxy.http_addr())) + ); + assert_ne!( + remote_env.get("HTTP_PROXY"), + Some(&format!("http://{}", proxy.http_addr())) + ); + + handle.shutdown().await?; + Ok(()) + } + #[tokio::test] async fn managed_proxy_builder_does_not_reserve_socks_listener_when_disabled() { let settings = NetworkProxySettings { diff --git a/codex-rs/network-proxy/src/socks5.rs b/codex-rs/network-proxy/src/socks5.rs index a4f36db562..5cc7b2eed3 100644 --- a/codex-rs/network-proxy/src/socks5.rs +++ b/codex-rs/network-proxy/src/socks5.rs @@ -64,6 +64,7 @@ pub async fn run_socks5( state: Arc, addr: SocketAddr, policy_decider: Option>, + environment_id: Option, enable_socks5_udp: bool, ) -> Result<()> { let listener = TcpListener::build() @@ -74,24 +75,40 @@ pub async fn run_socks5( .map_err(anyhow::Error::from) .with_context(|| format!("bind SOCKS5 proxy: {addr}"))?; - run_socks5_with_listener(state, listener, policy_decider, enable_socks5_udp).await + run_socks5_with_listener( + state, + listener, + policy_decider, + environment_id, + enable_socks5_udp, + ) + .await } pub async fn run_socks5_with_std_listener( state: Arc, listener: StdTcpListener, policy_decider: Option>, + environment_id: Option, enable_socks5_udp: bool, ) -> Result<()> { let listener = TcpListener::try_from(listener).context("convert std listener to SOCKS5 proxy listener")?; - run_socks5_with_listener(state, listener, policy_decider, enable_socks5_udp).await + run_socks5_with_listener( + state, + listener, + policy_decider, + environment_id, + enable_socks5_udp, + ) + .await } async fn run_socks5_with_listener( state: Arc, listener: TcpListener, policy_decider: Option>, + environment_id: Option, enable_socks5_udp: bool, ) -> Result<()> { let addr = listener @@ -115,10 +132,12 @@ async fn run_socks5_with_listener( let tcp_connector = TargetCheckedTcpConnector::new(state.clone()); let policy_tcp_connector = service_fn({ let policy_decider = policy_decider.clone(); + let environment_id = environment_id.clone(); move |req: TcpRequest| { let tcp_connector = tcp_connector.clone(); let policy_decider = policy_decider.clone(); - async move { handle_socks5_tcp(req, tcp_connector, policy_decider).await } + let environment_id = environment_id.clone(); + async move { handle_socks5_tcp(req, tcp_connector, policy_decider, environment_id).await } } }); @@ -131,13 +150,18 @@ async fn run_socks5_with_listener( if enable_socks5_udp { let udp_state = state.clone(); let udp_decider = policy_decider.clone(); - let udp_relay = DefaultUdpRelay::default().with_async_inspector(service_fn({ - move |request: RelayRequest| { - let udp_state = udp_state.clone(); - let udp_decider = udp_decider.clone(); - async move { inspect_socks5_udp(request, udp_state, udp_decider).await } - } - })); + let udp_relay = + DefaultUdpRelay::default().with_async_inspector(service_fn({ + let environment_id = environment_id.clone(); + move |request: RelayRequest| { + let udp_state = udp_state.clone(); + let udp_decider = udp_decider.clone(); + let environment_id = environment_id.clone(); + async move { + inspect_socks5_udp(request, udp_state, udp_decider, environment_id).await + } + } + })); let socks_acceptor = base.with_udp_associator(udp_relay); listener .serve(AddInputExtensionLayer::new(state).into_layer(socks_acceptor)) @@ -154,6 +178,7 @@ async fn handle_socks5_tcp( req: TcpRequest, tcp_connector: TargetCheckedTcpConnector, policy_decider: Option>, + environment_id: Option, ) -> Result, BoxError> { let app_state = req .extensions() @@ -268,7 +293,7 @@ async fn handle_socks5_tcp( protocol: NetworkProtocol::Socks5Tcp, host: host.clone(), port, - environment_id: None, + environment_id, client_addr: client.clone(), method: None, command: None, @@ -519,6 +544,7 @@ async fn inspect_socks5_udp( request: RelayRequest, state: Arc, policy_decider: Option>, + environment_id: Option, ) -> io::Result { let RelayRequest { server_address, @@ -625,7 +651,7 @@ async fn inspect_socks5_udp( protocol: NetworkProtocol::Socks5Udp, host: host.clone(), port, - environment_id: None, + environment_id, client_addr: client.clone(), method: None, command: None, @@ -783,6 +809,7 @@ mod tests { request, TargetCheckedTcpConnector::new(state.clone()), /*policy_decider*/ None, + /*environment_id*/ None, ) .await }) @@ -826,6 +853,7 @@ mod tests { request, TargetCheckedTcpConnector::new(state), /*policy_decider*/ None, + /*environment_id*/ None, ) .await .expect("limited-mode HTTPS should use MITM"); @@ -851,6 +879,7 @@ mod tests { request, TargetCheckedTcpConnector::new(state), /*policy_decider*/ None, + /*environment_id*/ None, ) .await }) @@ -896,6 +925,7 @@ mod tests { request, TargetCheckedTcpConnector::new(state), /*policy_decider*/ None, + /*environment_id*/ None, ) .await .expect_err("limited-mode HTTPS requires MITM"); @@ -933,6 +963,7 @@ mod tests { request, TargetCheckedTcpConnector::new(state), /*policy_decider*/ None, + /*environment_id*/ None, ) .await .expect("hooked HTTPS should use MITM"); @@ -967,6 +998,7 @@ mod tests { request, TargetCheckedTcpConnector::new(state), /*policy_decider*/ None, + /*environment_id*/ None, ) .await .expect_err("hooked non-HTTPS SOCKS should require MITM"); @@ -992,7 +1024,10 @@ mod tests { }; let (result, events) = capture_events(|| async { - inspect_socks5_udp(request, state, /*policy_decider*/ None).await + inspect_socks5_udp( + request, state, /*policy_decider*/ None, /*environment_id*/ None, + ) + .await }) .await; assert!(result.is_err(), "limited-mode UDP request should be denied"); diff --git a/codex-rs/sandboxing/src/lib.rs b/codex-rs/sandboxing/src/lib.rs index bebfefe067..22c4984c5a 100644 --- a/codex-rs/sandboxing/src/lib.rs +++ b/codex-rs/sandboxing/src/lib.rs @@ -48,6 +48,9 @@ impl From for CodexErr { SandboxTransformError::MissingLinuxSandboxExecutable => { CodexErr::LandlockSandboxExecutableNotProvided } + SandboxTransformError::EnvironmentNetworkProxy(message) => { + CodexErr::UnsupportedOperation(message) + } #[cfg(target_os = "linux")] SandboxTransformError::Wsl1UnsupportedForBubblewrap => { CodexErr::UnsupportedOperation(crate::bwrap::WSL1_BWRAP_WARNING.to_string()) diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index f00e056907..dd7aba3614 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -209,6 +209,7 @@ pub enum SandboxTransformError { source: io::Error, }, MissingLinuxSandboxExecutable, + EnvironmentNetworkProxy(String), #[cfg(target_os = "linux")] Wsl1UnsupportedForBubblewrap, #[cfg(not(target_os = "macos"))] @@ -233,6 +234,9 @@ impl std::fmt::Display for SandboxTransformError { Self::MissingLinuxSandboxExecutable => { write!(f, "missing codex-linux-sandbox executable path") } + Self::EnvironmentNetworkProxy(err) => { + write!(f, "failed to prepare environment network proxy: {err}") + } #[cfg(target_os = "linux")] Self::Wsl1UnsupportedForBubblewrap => write!(f, "{WSL1_BWRAP_WARNING}"), #[cfg(not(target_os = "macos"))] @@ -251,6 +255,7 @@ impl std::error::Error for SandboxTransformError { Self::InvalidCommandCwd { source, .. } | Self::InvalidSandboxPolicyCwd { source, .. } => Some(source), Self::MissingLinuxSandboxExecutable => None, + Self::EnvironmentNetworkProxy(_) => None, #[cfg(target_os = "linux")] Self::Wsl1UnsupportedForBubblewrap => None, #[cfg(not(target_os = "macos"))] @@ -347,9 +352,11 @@ impl SandboxManager { network_sandbox_policy: pending.effective_network_policy, sandbox_policy_cwd: pending.native_sandbox_policy_cwd.as_path(), enforce_managed_network, + environment_id, network, extra_allow_unix_sockets: &[], - }); + }) + .map_err(SandboxTransformError::EnvironmentNetworkProxy)?; let mut full_command = Vec::with_capacity(1 + args.len()); full_command.push(MACOS_PATH_TO_SEATBELT_EXECUTABLE.to_string()); full_command.append(&mut args); diff --git a/codex-rs/sandboxing/src/seatbelt.rs b/codex-rs/sandboxing/src/seatbelt.rs index 7057ec46b9..d3233e705b 100644 --- a/codex-rs/sandboxing/src/seatbelt.rs +++ b/codex-rs/sandboxing/src/seatbelt.rs @@ -104,8 +104,9 @@ struct UnixSocketPathParam { fn proxy_policy_inputs( network: Option<&NetworkProxy>, + environment_id: Option<&str>, extra_allow_unix_sockets: &[AbsolutePathBuf], -) -> ProxyPolicyInputs { +) -> Result { let extra_allowed = extra_allow_unix_sockets .iter() .filter_map(|socket_path| normalize_path_for_sandbox(socket_path.as_path())) @@ -114,7 +115,9 @@ fn proxy_policy_inputs( match network { Some(network) => { let mut env = HashMap::new(); - network.apply_to_env(&mut env); + network + .apply_to_env_for_optional_environment(&mut env, environment_id) + .map_err(|err| err.to_string())?; let unix_domain_socket_policy = if network.dangerously_allow_all_unix_sockets() { UnixDomainSocketPolicy::AllowAll } else { @@ -136,19 +139,19 @@ fn proxy_policy_inputs( allowed.extend(extra_allowed); UnixDomainSocketPolicy::Restricted { allowed } }; - ProxyPolicyInputs { + Ok(ProxyPolicyInputs { ports: proxy_loopback_ports_from_env(&env), has_proxy_config: has_proxy_url_env_vars(&env), allow_local_binding: network.allow_local_binding(), unix_domain_socket_policy, - } + }) } - None => ProxyPolicyInputs { + None => Ok(ProxyPolicyInputs { unix_domain_socket_policy: UnixDomainSocketPolicy::Restricted { allowed: extra_allowed, }, ..Default::default() - }, + }), } } @@ -572,7 +575,7 @@ fn create_seatbelt_command_args_for_legacy_policy( sandbox_policy_cwd: &Path, enforce_managed_network: bool, network: Option<&NetworkProxy>, -) -> Vec { +) -> Result, String> { let file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( sandbox_policy, sandbox_policy_cwd, @@ -583,6 +586,7 @@ fn create_seatbelt_command_args_for_legacy_policy( network_sandbox_policy: NetworkSandboxPolicy::from(sandbox_policy), sandbox_policy_cwd, enforce_managed_network, + environment_id: None, network, extra_allow_unix_sockets: &[], }) @@ -595,17 +599,21 @@ pub struct CreateSeatbeltCommandArgsParams<'a> { pub network_sandbox_policy: NetworkSandboxPolicy, pub sandbox_policy_cwd: &'a Path, pub enforce_managed_network: bool, + pub environment_id: Option<&'a str>, pub network: Option<&'a NetworkProxy>, pub extra_allow_unix_sockets: &'a [AbsolutePathBuf], } -pub fn create_seatbelt_command_args(args: CreateSeatbeltCommandArgsParams<'_>) -> Vec { +pub fn create_seatbelt_command_args( + args: CreateSeatbeltCommandArgsParams<'_>, +) -> Result, String> { let CreateSeatbeltCommandArgsParams { command, file_system_sandbox_policy, network_sandbox_policy, sandbox_policy_cwd, enforce_managed_network, + environment_id, network, extra_allow_unix_sockets, } = args; @@ -701,7 +709,7 @@ pub fn create_seatbelt_command_args(args: CreateSeatbeltCommandArgsParams<'_>) - } }; - let proxy = proxy_policy_inputs(network, extra_allow_unix_sockets); + let proxy = proxy_policy_inputs(network, environment_id, extra_allow_unix_sockets)?; let network_policy = dynamic_network_policy_for_network(network_sandbox_policy, enforce_managed_network, &proxy); @@ -737,7 +745,7 @@ pub fn create_seatbelt_command_args(args: CreateSeatbeltCommandArgsParams<'_>) - seatbelt_args.extend(definition_args); seatbelt_args.push("--".to_string()); seatbelt_args.extend(command); - seatbelt_args + Ok(seatbelt_args) } #[cfg(test)] diff --git a/codex-rs/sandboxing/src/seatbelt_tests.rs b/codex-rs/sandboxing/src/seatbelt_tests.rs index 337eb56378..e0ff0e6652 100644 --- a/codex-rs/sandboxing/src/seatbelt_tests.rs +++ b/codex-rs/sandboxing/src/seatbelt_tests.rs @@ -205,9 +205,11 @@ fn explicit_unreadable_paths_are_excluded_from_full_disk_read_and_write_access() network_sandbox_policy: NetworkSandboxPolicy::Restricted, sandbox_policy_cwd: Path::new("/"), enforce_managed_network: false, + environment_id: None, network: None, extra_allow_unix_sockets: &[], - }); + }) + .unwrap(); let policy = seatbelt_policy_arg(&args); let unreadable_roots = file_system_policy.get_unreadable_roots_with_cwd(Path::new("/")); @@ -277,9 +279,11 @@ fn explicit_unreadable_paths_are_excluded_from_readable_roots() { network_sandbox_policy: NetworkSandboxPolicy::Restricted, sandbox_policy_cwd: Path::new("/"), enforce_managed_network: false, + environment_id: None, network: None, extra_allow_unix_sockets: &[], - }); + }) + .unwrap(); let policy = seatbelt_policy_arg(&args); let readable_roots = file_system_policy.get_readable_roots_with_cwd(Path::new("/")); @@ -392,7 +396,8 @@ fn seatbelt_args_without_extension_profile_keep_legacy_preferences_read_access() cwd.as_path(), /*enforce_managed_network*/ false, /*network*/ None, - ); + ) + .unwrap(); let policy = &args[1]; assert!(policy.contains("(allow user-preference-read)")); assert!(!policy.contains("(allow user-preference-write)")); @@ -580,9 +585,11 @@ fn create_seatbelt_args_allowlists_explicit_unix_socket_paths_without_proxy() { network_sandbox_policy: NetworkSandboxPolicy::Restricted, sandbox_policy_cwd: cwd.path(), enforce_managed_network: false, + environment_id: None, network: None, extra_allow_unix_sockets: &extra_allow_unix_sockets, - }); + }) + .unwrap(); let policy = seatbelt_policy_arg(&args); assert!( @@ -638,9 +645,11 @@ async fn create_seatbelt_args_merges_proxy_and_explicit_unix_socket_paths() -> a network_sandbox_policy: NetworkSandboxPolicy::Restricted, sandbox_policy_cwd: cwd.path(), enforce_managed_network: false, + environment_id: None, network: Some(&network_proxy), extra_allow_unix_sockets: &extra_allow_unix_sockets, - }); + }) + .unwrap(); let expected_explicit_socket = normalize_path_for_sandbox(Path::new(explicit_socket)) .expect("explicit socket root should normalize"); @@ -679,9 +688,11 @@ fn create_seatbelt_args_preserves_full_network_with_explicit_unix_socket_paths() network_sandbox_policy: NetworkSandboxPolicy::Enabled, sandbox_policy_cwd: cwd.path(), enforce_managed_network: false, + environment_id: None, network: None, extra_allow_unix_sockets: &extra_allow_unix_sockets, - }); + }) + .unwrap(); let policy = seatbelt_policy_arg(&args); assert!( @@ -869,7 +880,8 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { &cwd, /*enforce_managed_network*/ false, /*network*/ None, - ); + ) + .unwrap(); let policy_text = seatbelt_policy_arg(&args); assert!( @@ -1008,7 +1020,8 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { &cwd, /*enforce_managed_network*/ false, /*network*/ None, - ); + ) + .unwrap(); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&write_hooks_file_args) .current_dir(&cwd) @@ -1044,7 +1057,8 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { &cwd, /*enforce_managed_network*/ false, /*network*/ None, - ); + ) + .unwrap(); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&write_allowed_file_args) .current_dir(&cwd) @@ -1108,7 +1122,8 @@ fn create_seatbelt_args_block_first_time_dot_codex_creation_with_metadata_name_r repo_root.as_path(), /*enforce_managed_network*/ false, /*network*/ None, - ); + ) + .unwrap(); let policy_text = seatbelt_policy_arg(&args); assert!( @@ -1160,7 +1175,8 @@ fn create_seatbelt_args_with_read_only_git_pointer_file() { &cwd, /*enforce_managed_network*/ false, /*network*/ None, - ); + ) + .unwrap(); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&args) @@ -1196,7 +1212,8 @@ fn create_seatbelt_args_with_read_only_git_pointer_file() { &cwd, /*enforce_managed_network*/ false, /*network*/ None, - ); + ) + .unwrap(); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&gitdir_args) .current_dir(&cwd) @@ -1259,7 +1276,8 @@ fn create_seatbelt_args_for_cwd_as_git_repo() { vulnerable_root.as_path(), /*enforce_managed_network*/ false, /*network*/ None, - ); + ) + .unwrap(); let slash_tmp = PathBuf::from("/tmp") .canonicalize() From 7951397e4af9b52bac46a6cac90f1047c31b31a2 Mon Sep 17 00:00:00 2001 From: jif Date: Fri, 19 Jun 2026 13:08:31 +0100 Subject: [PATCH 073/112] Document raw response item compatibility (#29086) Adds a short AGENTS.md note asking reviewers to treat raw response item events as compatibility-sensitive, even while they are experimental. This keeps future app-server changes from accidentally breaking Codex Cloud consumers of raw response item events. --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 730df0b881..0cb869c23a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,6 +105,7 @@ Codex maintains a context (history of messages) that is sent to the model in inf Search for breaking changes in external integration surfaces: - app-server APIs +- raw response item events (`rawResponseItem/*`), even while experimental - CLI parameters - configuration loading - resuming sessions from existing rollouts From 3a2712ea141a5acb243689f803f26e48ff751771 Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Fri, 19 Jun 2026 05:35:57 -0700 Subject: [PATCH 074/112] Add indexed web search mode (#28489) ## Summary - Add `web_search = "indexed"` alongside `disabled`, `cached`, and `live`. - Use that same resolved mode for both hosted and standalone web search. - For hosted search, send `index_gated_web_access: true` with external web access enabled only when `indexed` is selected. - For standalone search, preserve the existing boolean wire values for existing modes (`cached` maps to `false` and `live` to `true`) and send `"indexed"` only for `indexed`; `disabled` keeps the tool unavailable. - Carry the mode through managed configuration requirements and generated schemas. ## Why Indexed search provides a middle ground between cached-only search and unrestricted live page fetching. Search queries can remain live while direct page fetches are limited to URLs admitted by the server. The existing `web_search` setting remains the single source of truth, so hosted and standalone executors cannot drift into different access modes. Without an explicit `indexed` selection, the existing model-visible tool and request shapes are unchanged. ```toml web_search = "indexed" [features] standalone_web_search = true ``` ## Validation - `just fmt` - `just test -p codex-api` (`126 passed`) - `just test -p codex-web-search-extension` (`7 passed`) - `just test -p codex-core code_mode_can_call_indexed_standalone_web_search` (`1 passed`) - Focused configuration, hosted request, standalone request, and managed-requirement coverage is included in the PR; remaining suites run in CI. The full workspace test suite was not run locally. --- .../codex_app_server_protocol.schemas.json | 1 + .../codex_app_server_protocol.v2.schemas.json | 1 + .../schema/json/v2/ConfigReadResponse.json | 1 + .../v2/ConfigRequirementsReadResponse.json | 1 + .../schema/typescript/WebSearchMode.ts | 2 +- codex-rs/codex-api/src/endpoint/search.rs | 3 +- codex-rs/codex-api/src/lib.rs | 2 + codex-rs/codex-api/src/search.rs | 17 +++++++- codex-rs/config/src/config_requirements.rs | 36 ++++++++++++++++- codex-rs/config/src/config_toml.rs | 2 +- codex-rs/core/config.schema.json | 3 +- codex-rs/core/src/config/config_tests.rs | 33 ++++++++++++++++ codex-rs/core/src/config/mod.rs | 2 +- codex-rs/core/src/tools/hosted_spec.rs | 12 +++--- codex-rs/core/src/tools/hosted_spec_tests.rs | 1 + codex-rs/core/src/tools/spec_plan_tests.rs | 1 + codex-rs/core/tests/suite/code_mode.rs | 19 +++++++-- .../core/tests/suite/deprecation_notice.rs | 2 +- codex-rs/core/tests/suite/web_search.rs | 39 +++++++++++++++++++ codex-rs/ext/web-search/src/extension.rs | 38 ++++++++++++++++-- codex-rs/features/src/lib.rs | 2 +- codex-rs/protocol/src/config_types.rs | 5 ++- codex-rs/tools/src/tool_spec.rs | 2 + codex-rs/tools/src/tool_spec_tests.rs | 2 + 24 files changed, 204 insertions(+), 23 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 87726d30c4..f8f8db58b5 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -20355,6 +20355,7 @@ "enum": [ "disabled", "cached", + "indexed", "live" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 0c2f2f54bf..81a79c0c97 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -18134,6 +18134,7 @@ "enum": [ "disabled", "cached", + "indexed", "live" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index c7fa352940..ef2411534d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -809,6 +809,7 @@ "enum": [ "disabled", "cached", + "indexed", "live" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index e57bd48b79..edbb7f0bcd 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -503,6 +503,7 @@ "enum": [ "disabled", "cached", + "indexed", "live" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/typescript/WebSearchMode.ts b/codex-rs/app-server-protocol/schema/typescript/WebSearchMode.ts index 695c13e3f6..0544fd09c8 100644 --- a/codex-rs/app-server-protocol/schema/typescript/WebSearchMode.ts +++ b/codex-rs/app-server-protocol/schema/typescript/WebSearchMode.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type WebSearchMode = "disabled" | "cached" | "live"; +export type WebSearchMode = "disabled" | "cached" | "indexed" | "live"; diff --git a/codex-rs/codex-api/src/endpoint/search.rs b/codex-rs/codex-api/src/endpoint/search.rs index 864f7ec9b0..93f6249f10 100644 --- a/codex-rs/codex-api/src/endpoint/search.rs +++ b/codex-rs/codex-api/src/endpoint/search.rs @@ -55,6 +55,7 @@ mod tests { use crate::provider::RetryConfig; use crate::search::AllowedCaller; use crate::search::ApproximateLocation; + use crate::search::ExternalWebAccess; use crate::search::LocationType; use crate::search::OpenOperation; use crate::search::SearchCommands; @@ -193,7 +194,7 @@ mod tests { caption: Some(true), }), allowed_callers: Some(vec![AllowedCaller::Direct]), - external_web_access: Some(true), + external_web_access: Some(ExternalWebAccess::Boolean(true)), }), max_output_tokens: Some(2500), }, diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index df6723f023..4881e77fbc 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -81,6 +81,8 @@ pub use crate::requests::Compression; pub use crate::search::AllowedCaller; pub use crate::search::ApproximateLocation; pub use crate::search::ClickOperation; +pub use crate::search::ExternalWebAccess; +pub use crate::search::ExternalWebAccessMode; pub use crate::search::FinanceAssetType; pub use crate::search::FinanceOperation; pub use crate::search::FindOperation; diff --git a/codex-rs/codex-api/src/search.rs b/codex-rs/codex-api/src/search.rs index bae7c8a7da..b896186af8 100644 --- a/codex-rs/codex-api/src/search.rs +++ b/codex-rs/codex-api/src/search.rs @@ -211,6 +211,21 @@ pub enum SearchResponseLength { Long, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ExternalWebAccessMode { + Offline, + Indexed, + Online, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(untagged)] +pub enum ExternalWebAccess { + Boolean(bool), + Mode(ExternalWebAccessMode), +} + #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] pub struct SearchSettings { #[serde(skip_serializing_if = "Option::is_none")] @@ -224,7 +239,7 @@ pub struct SearchSettings { #[serde(skip_serializing_if = "Option::is_none")] pub allowed_callers: Option>, #[serde(skip_serializing_if = "Option::is_none")] - pub external_web_access: Option, + pub external_web_access: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index ca4407ce43..ac51734536 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -665,10 +665,11 @@ fn is_glob_metacharacter(ch: char) -> bool { } #[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -#[serde(rename_all = "lowercase")] +#[serde(rename_all = "snake_case")] pub enum WebSearchModeRequirement { Disabled, Cached, + Indexed, Live, } @@ -677,6 +678,7 @@ impl From for WebSearchModeRequirement { match mode { WebSearchMode::Disabled => WebSearchModeRequirement::Disabled, WebSearchMode::Cached => WebSearchModeRequirement::Cached, + WebSearchMode::Indexed => WebSearchModeRequirement::Indexed, WebSearchMode::Live => WebSearchModeRequirement::Live, } } @@ -687,6 +689,7 @@ impl From for WebSearchMode { match mode { WebSearchModeRequirement::Disabled => WebSearchMode::Disabled, WebSearchModeRequirement::Cached => WebSearchMode::Cached, + WebSearchModeRequirement::Indexed => WebSearchMode::Indexed, WebSearchModeRequirement::Live => WebSearchMode::Live, } } @@ -697,6 +700,7 @@ impl fmt::Display for WebSearchModeRequirement { match self { WebSearchModeRequirement::Disabled => write!(f, "disabled"), WebSearchModeRequirement::Cached => write!(f, "cached"), + WebSearchModeRequirement::Indexed => write!(f, "indexed"), WebSearchModeRequirement::Live => write!(f, "live"), } } @@ -1345,6 +1349,8 @@ impl TryFrom for ConfigRequirements { let initial_value = if accepted.contains(&WebSearchModeRequirement::Cached) { WebSearchMode::Cached + } else if accepted.contains(&WebSearchModeRequirement::Indexed) { + WebSearchMode::Indexed } else if accepted.contains(&WebSearchModeRequirement::Live) { WebSearchMode::Live } else { @@ -2927,6 +2933,34 @@ allowed_approvals_reviewers = ["user"] Ok(()) } + #[test] + fn allowed_web_search_modes_supports_indexed() -> Result<()> { + let config: ConfigRequirementsToml = from_str( + r#" + allowed_web_search_modes = ["indexed"] + "#, + )?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!(requirements.web_search_mode.value(), WebSearchMode::Indexed); + for mode in [WebSearchMode::Disabled, WebSearchMode::Indexed] { + assert!(requirements.web_search_mode.can_set(&mode).is_ok()); + } + for mode in [WebSearchMode::Cached, WebSearchMode::Live] { + assert_eq!( + requirements.web_search_mode.can_set(&mode), + Err(ConstraintError::InvalidValue { + field_name: "web_search_mode", + candidate: format!("{mode:?}"), + allowed: "[Disabled, Indexed]".into(), + requirement_source: RequirementSource::Unknown, + }) + ); + } + + Ok(()) + } + #[test] fn allowed_web_search_modes_allows_disabled() -> Result<()> { let toml_str = r#" diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index f575aa27d8..3e0ca82b35 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -409,7 +409,7 @@ pub struct ConfigToml { pub experimental_thread_store: Option, pub projects: Option>, - /// Controls the web search tool mode: disabled, cached, or live. + /// Controls the web search tool mode: disabled, cached, indexed, or live. pub web_search: Option, /// Nested tools section for feature toggles diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 7d21c90690..c507fc61b5 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -4419,6 +4419,7 @@ "enum": [ "disabled", "cached", + "indexed", "live" ], "type": "string" @@ -5380,7 +5381,7 @@ "$ref": "#/definitions/WebSearchMode" } ], - "description": "Controls the web search tool mode: disabled, cached, or live." + "description": "Controls the web search tool mode: disabled, cached, indexed, or live." }, "windows": { "allOf": [ diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index a6a9ab6365..7446194782 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -5186,6 +5186,14 @@ fn web_search_mode_disabled_overrides_legacy_request() { ); } +#[test] +fn web_search_mode_for_turn_preserves_indexed_for_disabled_permissions() { + let web_search_mode = Constrained::allow_any(WebSearchMode::Indexed); + let mode = resolve_web_search_mode_for_turn(&web_search_mode, &PermissionProfile::Disabled); + + assert_eq!(mode, WebSearchMode::Indexed); +} + #[test] fn web_search_mode_for_turn_uses_preference_for_read_only() { let web_search_mode = Constrained::allow_any(WebSearchMode::Cached); @@ -5232,6 +5240,31 @@ fn web_search_mode_for_turn_falls_back_when_live_is_disallowed() -> anyhow::Resu Ok(()) } +#[test] +fn web_search_mode_for_turn_does_not_implicitly_select_indexed() -> anyhow::Result<()> { + let allowed = [ + WebSearchMode::Disabled, + WebSearchMode::Cached, + WebSearchMode::Indexed, + ]; + let web_search_mode = Constrained::new(WebSearchMode::Cached, move |candidate| { + if allowed.contains(candidate) { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "web_search_mode", + candidate: format!("{candidate:?}"), + allowed: format!("{allowed:?}"), + requirement_source: RequirementSource::Unknown, + }) + } + })?; + let mode = resolve_web_search_mode_for_turn(&web_search_mode, &PermissionProfile::Disabled); + + assert_eq!(mode, WebSearchMode::Cached); + Ok(()) +} + #[tokio::test] async fn project_profiles_are_ignored() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 4e0cb77feb..a1ecd8f8a2 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2667,7 +2667,7 @@ pub(crate) fn resolve_web_search_mode_for_turn( let preferred = web_search_mode.value(); if matches!(permission_profile, PermissionProfile::Disabled) - && preferred != WebSearchMode::Disabled + && !matches!(preferred, WebSearchMode::Disabled | WebSearchMode::Indexed) { for mode in [ WebSearchMode::Live, diff --git a/codex-rs/core/src/tools/hosted_spec.rs b/codex-rs/core/src/tools/hosted_spec.rs index ba26ba6b2a..24a7905fa6 100644 --- a/codex-rs/core/src/tools/hosted_spec.rs +++ b/codex-rs/core/src/tools/hosted_spec.rs @@ -18,11 +18,12 @@ pub fn create_image_generation_tool(output_format: &str) -> ToolSpec { } pub fn create_web_search_tool(options: WebSearchToolOptions<'_>) -> Option { - let external_web_access = match options.web_search_mode { - Some(WebSearchMode::Cached) => Some(false), - Some(WebSearchMode::Live) => Some(true), - Some(WebSearchMode::Disabled) | None => None, - }?; + let (external_web_access, index_gated_web_access) = match options.web_search_mode { + Some(WebSearchMode::Cached) => (false, None), + Some(WebSearchMode::Indexed) => (true, Some(true)), + Some(WebSearchMode::Live) => (true, None), + Some(WebSearchMode::Disabled) | None => return None, + }; let search_content_types = match options.web_search_tool_type { WebSearchToolType::Text => None, @@ -36,6 +37,7 @@ pub fn create_web_search_tool(options: WebSearchToolOptions<'_>) -> Option Result<()> { + assert_code_mode_standalone_web_search(WebSearchMode::Live, serde_json::json!(true)).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_can_call_indexed_standalone_web_search() -> Result<()> { + assert_code_mode_standalone_web_search(WebSearchMode::Indexed, serde_json::json!("indexed")) + .await +} + +async fn assert_code_mode_standalone_web_search( + web_search_mode: WebSearchMode, + expected_external_web_access: Value, +) -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -268,7 +281,7 @@ text(result); .with_auth(auth) .with_extensions(Arc::new(extension_builder.build())) .with_model("test-gpt-5.1-codex") - .with_config(|config| { + .with_config(move |config| { config .features .enable(Feature::CodeMode) @@ -279,7 +292,7 @@ text(result); .expect("standalone web search should be enabled"); config .web_search_mode - .set(WebSearchMode::Live) + .set(web_search_mode) .expect("web search mode should be accepted"); }); let test = builder.build(&server).await?; @@ -310,7 +323,7 @@ text(result); search_body["settings"], serde_json::json!({ "allowed_callers": ["direct"], - "external_web_access": true, + "external_web_access": expected_external_web_access, }) ); assert_eq!( diff --git a/codex-rs/core/tests/suite/deprecation_notice.rs b/codex-rs/core/tests/suite/deprecation_notice.rs index c41ff47f2a..9248486f53 100644 --- a/codex-rs/core/tests/suite/deprecation_notice.rs +++ b/codex-rs/core/tests/suite/deprecation_notice.rs @@ -92,7 +92,7 @@ async fn emits_deprecation_notice_for_web_search_feature_flag_values() -> anyhow assert_eq!( details.as_deref(), Some( - "Set `web_search` to `\"live\"`, `\"cached\"`, or `\"disabled\"` at the top level (or under a profile) in config.toml if you want to override it." + "Set `web_search` to `\"live\"`, `\"indexed\"`, `\"cached\"`, or `\"disabled\"` at the top level (or under a profile) in config.toml if you want to override it." ), ); } diff --git a/codex-rs/core/tests/suite/web_search.rs b/codex-rs/core/tests/suite/web_search.rs index 529cee8b8b..5728b97a74 100644 --- a/codex-rs/core/tests/suite/web_search.rs +++ b/codex-rs/core/tests/suite/web_search.rs @@ -274,3 +274,42 @@ location = { country = "US", city = "New York", timezone = "America/New_York" } }) ); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn indexed_web_search_mode_sets_index_gate() { + skip_if_no_network!(); + + let server = start_mock_server().await; + let sse = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_completed("resp-1"), + ]); + let resp_mock = responses::mount_sse_once(&server, sse).await; + + let home = Arc::new(tempfile::TempDir::new().expect("create codex home")); + std::fs::write(home.path().join("config.toml"), r#"web_search = "indexed""#) + .expect("write config.toml"); + + let mut builder = test_codex().with_model("gpt-5.3-codex").with_home(home); + let test = builder + .build(&server) + .await + .expect("create test Codex conversation"); + + test.submit_turn_with_permission_profile( + "hello indexed web search", + PermissionProfile::Disabled, + ) + .await + .expect("submit turn"); + + let body = resp_mock.single_request().body_json(); + let tool = find_web_search_tool(&body); + assert_eq!( + ( + tool.get("external_web_access").and_then(Value::as_bool), + tool.get("index_gated_web_access").and_then(Value::as_bool), + ), + (Some(true), Some(true)) + ); +} diff --git a/codex-rs/ext/web-search/src/extension.rs b/codex-rs/ext/web-search/src/extension.rs index 688b504dec..7afdfe4137 100644 --- a/codex-rs/ext/web-search/src/extension.rs +++ b/codex-rs/ext/web-search/src/extension.rs @@ -2,6 +2,8 @@ use std::sync::Arc; use codex_api::AllowedCaller; use codex_api::ApproximateLocation; +use codex_api::ExternalWebAccess; +use codex_api::ExternalWebAccessMode; use codex_api::LocationType; use codex_api::SearchContextSize; use codex_api::SearchFilters; @@ -73,14 +75,19 @@ fn search_settings(config: &Config, web_search_mode: WebSearchMode) -> SearchSet blocked_domains: None, }), allowed_callers: Some(vec![AllowedCaller::Direct]), - external_web_access: Some(match web_search_mode { - WebSearchMode::Live => true, - WebSearchMode::Cached | WebSearchMode::Disabled => false, - }), + external_web_access: Some(external_web_access_for_mode(web_search_mode)), ..Default::default() } } +fn external_web_access_for_mode(web_search_mode: WebSearchMode) -> ExternalWebAccess { + match web_search_mode { + WebSearchMode::Disabled | WebSearchMode::Cached => ExternalWebAccess::Boolean(false), + WebSearchMode::Indexed => ExternalWebAccess::Mode(ExternalWebAccessMode::Indexed), + WebSearchMode::Live => ExternalWebAccess::Boolean(true), + } +} + impl ThreadLifecycleContributor for WebSearchExtension { fn on_thread_start<'a>( &'a self, @@ -149,9 +156,32 @@ mod tests { use super::AuthManager; use super::Config; use super::WebSearchExtensionConfig; + use super::external_web_access_for_mode; use super::install; use crate::tool::RUN_TOOL_NAME; use crate::tool::WEB_NAMESPACE; + use codex_api::ExternalWebAccess; + use codex_api::ExternalWebAccessMode; + use codex_protocol::config_types::WebSearchMode; + + #[test] + fn external_web_access_preserves_legacy_values_until_indexed() { + assert_eq!( + [ + WebSearchMode::Disabled, + WebSearchMode::Cached, + WebSearchMode::Indexed, + WebSearchMode::Live, + ] + .map(external_web_access_for_mode), + [ + ExternalWebAccess::Boolean(false), + ExternalWebAccess::Boolean(false), + ExternalWebAccess::Mode(ExternalWebAccessMode::Indexed), + ExternalWebAccess::Boolean(true), + ] + ); + } #[test] fn installed_extension_contributes_web_run_when_enabled() { diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 6dc8827fe0..3bba6faf98 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -599,7 +599,7 @@ fn legacy_usage_notice(alias: &str, feature: Feature) -> (String, Option } fn web_search_details() -> &'static str { - "Set `web_search` to `\"live\"`, `\"cached\"`, or `\"disabled\"` at the top level (or under a profile) in config.toml if you want to override it." + "Set `web_search` to `\"live\"`, `\"indexed\"`, `\"cached\"`, or `\"disabled\"` at the top level (or under a profile) in config.toml if you want to override it." } /// Keys accepted in `[features]` tables. diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 64cfefeb73..5c250648a9 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -313,12 +313,13 @@ pub enum MultiAgentMode { #[derive( Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS, Default, )] -#[serde(rename_all = "lowercase")] -#[strum(serialize_all = "lowercase")] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] pub enum WebSearchMode { Disabled, #[default] Cached, + Indexed, Live, } diff --git a/codex-rs/tools/src/tool_spec.rs b/codex-rs/tools/src/tool_spec.rs index 98f705363c..146cef3b68 100644 --- a/codex-rs/tools/src/tool_spec.rs +++ b/codex-rs/tools/src/tool_spec.rs @@ -38,6 +38,8 @@ pub enum ToolSpec { #[serde(skip_serializing_if = "Option::is_none")] external_web_access: Option, #[serde(skip_serializing_if = "Option::is_none")] + index_gated_web_access: Option, + #[serde(skip_serializing_if = "Option::is_none")] filters: Option, #[serde(skip_serializing_if = "Option::is_none")] user_location: Option, diff --git a/codex-rs/tools/src/tool_spec_tests.rs b/codex-rs/tools/src/tool_spec_tests.rs index a181cd247b..b6f599d5d5 100644 --- a/codex-rs/tools/src/tool_spec_tests.rs +++ b/codex-rs/tools/src/tool_spec_tests.rs @@ -67,6 +67,7 @@ fn tool_spec_name_covers_all_variants() { assert_eq!( ToolSpec::WebSearch { external_web_access: Some(true), + index_gated_web_access: None, filters: None, user_location: None, search_context_size: None, @@ -199,6 +200,7 @@ fn web_search_tool_spec_serializes_expected_wire_shape() { assert_eq!( serde_json::to_value(ToolSpec::WebSearch { external_web_access: Some(true), + index_gated_web_access: None, filters: Some(ResponsesApiWebSearchFilters { allowed_domains: Some(vec!["example.com".to_string()]), }), From 81b000421dc795062019f3737db6fac3fda16aa4 Mon Sep 17 00:00:00 2001 From: jif Date: Fri, 19 Jun 2026 13:42:26 +0100 Subject: [PATCH 075/112] Add config toggles for orchestrator skills and MCP (#28942) ## Why Orchestrator-provided skills and Codex Apps MCP tools add model-visible instructions, resources, and tools beyond the local workspace. Hosts need config-level switches to disable those orchestrator-owned surfaces independently, without disabling regular skills or regular MCP servers. ## What changed - Adds `[orchestrator.skills].enabled` and `[orchestrator.mcp].enabled` config entries, both defaulting to `true`. - Includes the new settings in `config.schema.json` and in the config lock so resolved thread configuration preserves the same orchestrator exposure decisions. - Threads `orchestrator.skills.enabled` through the app-server skills extension so disabled orchestrator skills do not expose the `skills` namespace or inject orchestrator skill context. - Gates Codex Apps MCP exposure, app instructions, and app auth eligibility on `orchestrator.mcp.enabled` while leaving non-Codex-Apps MCP tools available. - Updates the thread-manager sample config to disable both orchestrator-owned surfaces. ## Verification - Added config parsing, loading, defaulting, and schema coverage for the new settings. - Added MCP exposure coverage that `orchestrator.mcp.enabled = false` removes Codex Apps tools while preserving regular MCP tools. - Added app-server coverage that `orchestrator.skills.enabled = false` prevents orchestrator skill tools, prompts, and resource reads from reaching the model turn. --- codex-rs/app-server/src/extensions.rs | 1 + .../app-server/tests/suite/v2/mcp_resource.rs | 99 +++++++++++++++ codex-rs/codex-mcp/src/connection_manager.rs | 28 +++-- codex-rs/codex-mcp/src/mcp/mod.rs | 6 +- codex-rs/config/src/config_toml.rs | 18 +++ codex-rs/core/config.schema.json | 31 +++++ codex-rs/core/src/config/config_tests.rs | 33 +++++ codex-rs/core/src/config/mod.rs | 19 +++ codex-rs/core/src/session/config_lock.rs | 14 +++ codex-rs/core/src/session/turn_context.rs | 1 + .../core/src/tools/handlers/mcp_resource.rs | 18 +++ .../list_mcp_resource_templates.rs | 7 +- .../mcp_resource/list_mcp_resources.rs | 7 +- .../mcp_resource/read_mcp_resource.rs | 2 + codex-rs/core/tests/suite/client.rs | 118 ++++++++++++++++++ codex-rs/ext/skills/src/config.rs | 2 + codex-rs/ext/skills/src/extension.rs | 8 +- codex-rs/ext/skills/src/state.rs | 8 +- codex-rs/ext/skills/tests/skills_extension.rs | 3 + codex-rs/thread-manager-sample/src/main.rs | 2 + 20 files changed, 405 insertions(+), 20 deletions(-) diff --git a/codex-rs/app-server/src/extensions.rs b/codex-rs/app-server/src/extensions.rs index 8a6bc07f3d..d804e139bf 100644 --- a/codex-rs/app-server/src/extensions.rs +++ b/codex-rs/app-server/src/extensions.rs @@ -88,6 +88,7 @@ where |config: &Config| codex_skills_extension::SkillsExtensionConfig { include_instructions: config.include_skill_instructions, bundled_skills_enabled: config.bundled_skills_enabled(), + orchestrator_skills_enabled: config.orchestrator_skills_enabled, }, ); Arc::new(builder.build()) diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs index dc3b6d5770..98b0fa3512 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -436,6 +436,95 @@ async fn local_executor_does_not_expose_orchestrator_skills() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn disabled_orchestrator_skills_do_not_expose_skills_namespace() -> Result<()> { + let responses_server = responses::start_mock_server().await; + let (apps_server_url, apps_server_calls, apps_server_handle) = + start_resource_apps_mcp_server().await?; + let responses_server_uri = responses_server.uri(); + let (_codex_home, mut mcp) = start_resource_test_app_server_with_extra_config( + &apps_server_url, + &responses_server_uri, + r#" +[orchestrator.skills] +enabled = false +"#, + ) + .await?; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + environments: Some(Vec::new()), + ..Default::default() + }) + .await?; + let thread_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?; + + let response_mock = responses::mount_sse_once( + &responses_server, + responses::sse(vec![ + responses::ev_response_created("resp-disabled-orchestrator-skills"), + responses::ev_assistant_message("msg-disabled-orchestrator-skills", "Done"), + responses::ev_completed("resp-disabled-orchestrator-skills"), + ]), + ) + .await; + let turn_start_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![UserInput::Text { + text: format!("Use ${SKILL_NAME}"), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let request = response_mock.single_request(); + assert!(request.tool_by_name("skills", "list").is_none()); + assert!(request.tool_by_name("skills", "read").is_none()); + assert!( + request + .message_input_texts("developer") + .iter() + .all(|text| !text.contains(SKILL_NAME)) + ); + assert!( + request + .message_input_texts("user") + .iter() + .all(|text| !text.contains(SKILL_MARKER)) + ); + assert_eq!( + ResourceAppsMcpCallCounts { + list_resources: 0, + main_prompt_reads: 0, + reference_reads: 0, + }, + apps_server_calls.snapshot() + ); + + apps_server_handle.abort(); + let _ = apps_server_handle.await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn mcp_resource_read_returns_resource_contents_without_thread() -> Result<()> { let (apps_server_url, _apps_server_calls, apps_server_handle) = @@ -555,6 +644,15 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> { async fn start_resource_test_app_server( apps_server_url: &str, responses_server_uri: &str, +) -> Result<(TempDir, TestAppServer)> { + start_resource_test_app_server_with_extra_config(apps_server_url, responses_server_uri, "") + .await +} + +async fn start_resource_test_app_server_with_extra_config( + apps_server_url: &str, + responses_server_uri: &str, + extra_config: &str, ) -> Result<(TempDir, TestAppServer)> { let codex_home = TempDir::new()?; std::fs::write( @@ -574,6 +672,7 @@ apps = true [skills] include_instructions = true +{extra_config} [model_providers.mock_provider] name = "Mock provider for test" diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index e9c835e863..ea55d21379 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -547,14 +547,20 @@ impl McpConnectionManager { )) } - /// Returns a single map that contains all resources. Each key is the - /// server name and the value is a vector of resources. - pub async fn list_all_resources(&self) -> HashMap> { + /// Returns resources from servers selected by `include_server`. Each key + /// is the server name and the value is a vector of resources. + pub async fn list_all_resources( + &self, + include_server: impl Fn(&str) -> bool, + ) -> HashMap> { let mut join_set = JoinSet::new(); let clients_snapshot = &self.clients; - for (server_name, async_managed_client) in clients_snapshot { + for (server_name, async_managed_client) in clients_snapshot + .iter() + .filter(|(server_name, _)| include_server(server_name)) + { let server_name = server_name.clone(); let Ok(managed_client) = async_managed_client.client().await else { continue; @@ -612,14 +618,20 @@ impl McpConnectionManager { aggregated } - /// Returns a single map that contains all resource templates. Each key is the - /// server name and the value is a vector of resource templates. - pub async fn list_all_resource_templates(&self) -> HashMap> { + /// Returns resource templates from servers selected by `include_server`. + /// Each key is the server name and the value is a vector of templates. + pub async fn list_all_resource_templates( + &self, + include_server: impl Fn(&str) -> bool, + ) -> HashMap> { let mut join_set = JoinSet::new(); let clients_snapshot = &self.clients; - for (server_name, async_managed_client) in clients_snapshot { + for (server_name, async_managed_client) in clients_snapshot + .iter() + .filter(|(server_name, _)| include_server(server_name)) + { let server_name_cloned = server_name.clone(); let Ok(managed_client) = async_managed_client.client().await else { continue; diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index d87602438f..4076880c1d 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -619,14 +619,16 @@ async fn collect_mcp_server_status_snapshot_from_manager( mcp_connection_manager.list_all_tools(), async { if detail.include_resources() { - mcp_connection_manager.list_all_resources().await + mcp_connection_manager.list_all_resources(|_| true).await } else { HashMap::new() } }, async { if detail.include_resources() { - mcp_connection_manager.list_all_resource_templates().await + mcp_connection_manager + .list_all_resource_templates(|_| true) + .await } else { HashMap::new() } diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 3e0ca82b35..823d59c0bc 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -133,6 +133,21 @@ of strings; comma-separated strings are not supported. Use \ } } +/// Orchestrator-owned feature settings. +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct OrchestratorToml { + pub skills: Option, + pub mcp: Option, +} + +/// Settings for a feature owned by the orchestrator. +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct OrchestratorFeatureToml { + pub enabled: Option, +} + /// Base config deserialized from ~/.codex/config.toml. #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] #[schemars(deny_unknown_fields)] @@ -360,6 +375,9 @@ pub struct ConfigToml { /// Optional product SKU forwarded on host-owned Codex Apps MCP requests. pub apps_mcp_product_sku: Option, + /// Orchestrator-owned feature settings. + pub orchestrator: Option, + /// Base URL override for the built-in `openai` model provider. pub openai_base_url: Option, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index c507fc61b5..828ec69f01 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2090,6 +2090,29 @@ } ] }, + "OrchestratorFeatureToml": { + "additionalProperties": false, + "description": "Settings for a feature owned by the orchestrator.", + "properties": { + "enabled": { + "type": "boolean" + } + }, + "type": "object" + }, + "OrchestratorToml": { + "additionalProperties": false, + "description": "Orchestrator-owned feature settings.", + "properties": { + "mcp": { + "$ref": "#/definitions/OrchestratorFeatureToml" + }, + "skills": { + "$ref": "#/definitions/OrchestratorFeatureToml" + } + }, + "type": "object" + }, "OtelConfigToml": { "additionalProperties": false, "description": "OTEL settings loaded from config.toml. Fields are optional so we can apply defaults.", @@ -5192,6 +5215,14 @@ "description": "Base URL override for the built-in `openai` model provider.", "type": "string" }, + "orchestrator": { + "allOf": [ + { + "$ref": "#/definitions/OrchestratorToml" + } + ], + "description": "Orchestrator-owned feature settings." + }, "oss_provider": { "description": "Preferred OSS provider for local models, e.g. \"lmstudio\" or \"ollama\".", "type": "string" diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 7446194782..904018ebc6 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -9111,6 +9111,39 @@ apps_mcp_product_sku = "tpp" Ok(()) } +#[tokio::test] +async fn config_loads_orchestrator_settings_from_toml() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg: ConfigToml = toml::from_str( + r#" +model = "gpt-5.4" + +[orchestrator.skills] +enabled = false + +[orchestrator.mcp] +enabled = false +"#, + ) + .expect("TOML deserialization should succeed for orchestrator settings"); + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert_eq!( + ( + config.orchestrator_skills_enabled, + config.orchestrator_mcp_enabled + ), + (false, false) + ); + Ok(()) +} + #[tokio::test] async fn config_loads_mcp_oauth_callback_url_from_toml() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index a1ecd8f8a2..8e9ccc6c24 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -690,6 +690,12 @@ pub struct Config { /// Whether to inject the `` developer block. pub include_skill_instructions: bool, + /// Whether orchestrator-owned skills are exposed to the model. + pub orchestrator_skills_enabled: bool, + + /// Whether orchestrator-owned MCP tools are exposed to the model. + pub orchestrator_mcp_enabled: bool, + /// Whether to inject the `` user block. pub include_environment_context: bool, @@ -2425,6 +2431,12 @@ fn resolve_experimental_request_user_input_enabled(config_toml: &ConfigToml) -> .is_none_or(|config| config.enabled) } +fn resolve_orchestrator_feature_enabled( + feature: Option<&codex_config::config_toml::OrchestratorFeatureToml>, +) -> bool { + feature.and_then(|feature| feature.enabled).unwrap_or(true) +} + fn resolve_code_mode_config(config_toml: &ConfigToml) -> CodeModeConfig { let base = code_mode_toml_config(config_toml.features.as_ref()); @@ -2812,6 +2824,11 @@ impl Config { validate_model_providers(&cfg.model_providers) .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; + let orchestrator = cfg.orchestrator.as_ref(); + let orchestrator_skills_enabled = + resolve_orchestrator_feature_enabled(orchestrator.and_then(|value| value.skills.as_ref())); + let orchestrator_mcp_enabled = + resolve_orchestrator_feature_enabled(orchestrator.and_then(|value| value.mcp.as_ref())); // Ensure that every field of ConfigRequirements is applied to the final // Config. let ConfigRequirements { @@ -3688,6 +3705,8 @@ impl Config { include_apps_instructions, include_collaboration_mode_instructions, include_skill_instructions, + orchestrator_skills_enabled, + orchestrator_mcp_enabled, include_environment_context, // The config.toml omits "_mode" because it's a config file. However, "_mode" // is important in code to differentiate the mode from the store implementation. diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index 61c39a5dd3..79afd11515 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -1,6 +1,8 @@ use anyhow::Context; use codex_config::config_toml::ConfigLockfileToml; use codex_config::config_toml::ConfigToml; +use codex_config::config_toml::OrchestratorFeatureToml; +use codex_config::config_toml::OrchestratorToml; use codex_config::types::MemoriesToml; use codex_features::CurrentTimeReminderConfigToml; use codex_features::Feature; @@ -183,6 +185,18 @@ fn save_config_resolved_fields( .skills .get_or_insert_with(Default::default) .include_instructions = Some(config.include_skill_instructions); + lock_config + .orchestrator + .get_or_insert_with(OrchestratorToml::default) + .skills + .get_or_insert_with(OrchestratorFeatureToml::default) + .enabled = Some(config.orchestrator_skills_enabled); + lock_config + .orchestrator + .get_or_insert_with(OrchestratorToml::default) + .mcp + .get_or_insert_with(Default::default) + .enabled = Some(config.orchestrator_mcp_enabled); Ok(()) } diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 30d7d28e56..bb434fa2c9 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -204,6 +204,7 @@ impl TurnContext { self.config .features .apps_enabled_for_auth(uses_codex_backend) + && self.config.orchestrator_mcp_enabled } pub(crate) fn tool_environment_mode(&self) -> ToolEnvironmentMode { diff --git a/codex-rs/core/src/tools/handlers/mcp_resource.rs b/codex-rs/core/src/tools/handlers/mcp_resource.rs index 2f97c02a42..231bdf92a9 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; +use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_protocol::items::McpToolCallError; use codex_protocol::items::McpToolCallItem; use codex_protocol::items::McpToolCallStatus; @@ -33,6 +34,23 @@ pub use list_mcp_resource_templates::ListMcpResourceTemplatesHandler; pub use list_mcp_resources::ListMcpResourcesHandler; pub use read_mcp_resource::ReadMcpResourceHandler; +fn model_can_access_mcp_server(turn: &TurnContext, server: &str) -> bool { + turn.config.orchestrator_mcp_enabled || server != CODEX_APPS_MCP_SERVER_NAME +} + +fn ensure_model_can_access_mcp_server( + turn: &TurnContext, + server: &str, +) -> Result<(), FunctionCallError> { + if model_can_access_mcp_server(turn, server) { + Ok(()) + } else { + Err(FunctionCallError::RespondToModel(format!( + "MCP server '{server}' is disabled by `orchestrator.mcp.enabled`" + ))) + } +} + #[derive(Debug, Deserialize, Default)] struct ListResourcesArgs { /// Lists all resources from all servers if not specified. diff --git a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs index 2af409cf20..ce762a0f02 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs @@ -19,6 +19,8 @@ use super::ListResourceTemplatesPayload; use super::call_tool_result_from_content; use super::emit_tool_call_begin; use super::emit_tool_call_end; +use super::ensure_model_can_access_mcp_server; +use super::model_can_access_mcp_server; use super::normalize_optional_string; use super::parse_args_with_default; use super::parse_arguments; @@ -83,6 +85,7 @@ impl ListMcpResourceTemplatesHandler { let payload_result: Result = async { if let Some(server_name) = server.clone() { + ensure_model_can_access_mcp_server(turn.as_ref(), &server_name)?; let params = cursor .clone() .map(|value| PaginatedRequestParams::default().with_cursor(Some(value))); @@ -109,7 +112,9 @@ impl ListMcpResourceTemplatesHandler { .services .mcp_connection_manager .load_full() - .list_all_resource_templates() + .list_all_resource_templates(|server_name| { + model_can_access_mcp_server(turn.as_ref(), server_name) + }) .await; Ok(ListResourceTemplatesPayload::from_all_servers(templates)) } diff --git a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs index 1a6a6e2014..564fb440fd 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs @@ -19,6 +19,8 @@ use super::ListResourcesPayload; use super::call_tool_result_from_content; use super::emit_tool_call_begin; use super::emit_tool_call_end; +use super::ensure_model_can_access_mcp_server; +use super::model_can_access_mcp_server; use super::normalize_optional_string; use super::parse_args_with_default; use super::parse_arguments; @@ -83,6 +85,7 @@ impl ListMcpResourcesHandler { let payload_result: Result = async { if let Some(server_name) = server.clone() { + ensure_model_can_access_mcp_server(turn.as_ref(), &server_name)?; let params = cursor .clone() .map(|value| PaginatedRequestParams::default().with_cursor(Some(value))); @@ -107,7 +110,9 @@ impl ListMcpResourcesHandler { .services .mcp_connection_manager .load_full() - .list_all_resources() + .list_all_resources(|server_name| { + model_can_access_mcp_server(turn.as_ref(), server_name) + }) .await; Ok(ListResourcesPayload::from_all_servers(resources)) } diff --git a/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs b/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs index f1ee6b462c..3909011eb0 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs @@ -19,6 +19,7 @@ use super::ReadResourcePayload; use super::call_tool_result_from_content; use super::emit_tool_call_begin; use super::emit_tool_call_end; +use super::ensure_model_can_access_mcp_server; use super::normalize_required_string; use super::parse_args; use super::parse_arguments; @@ -82,6 +83,7 @@ impl ReadMcpResourceHandler { let start = Instant::now(); let payload_result: Result = async { + ensure_model_can_access_mcp_server(turn.as_ref(), &server)?; let result = session .read_resource(&server, ReadResourceRequestParams::new(uri.clone())) .await diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index b88960f95e..fe543a875b 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -57,6 +57,7 @@ use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_completed_with_tokens; +use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_message_item_added; use core_test_support::responses::ev_output_text_delta; use core_test_support::responses::ev_response_created; @@ -1678,6 +1679,123 @@ async fn omits_apps_guidance_when_configured_off() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn omits_apps_guidance_when_orchestrator_mcp_is_disabled() { + skip_if_no_network!(); + let server = MockServer::start().await; + let apps_server = AppsTestServer::mount(&server) + .await + .expect("mount apps MCP mock"); + let apps_base_url = apps_server.chatgpt_base_url.clone(); + + let list_call_id = "list-resources"; + let read_call_id = "read-resource"; + let resp_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp1"), + ev_function_call(list_call_id, "list_mcp_resources", "{}"), + ev_completed("resp1"), + ]), + sse(vec![ + ev_response_created("resp2"), + ev_function_call( + read_call_id, + "read_mcp_resource", + &json!({ + "server": "codex_apps", + "uri": "skill://demo/SKILL.md", + }) + .to_string(), + ), + ev_completed("resp2"), + ]), + sse(vec![ev_response_created("resp3"), ev_completed("resp3")]), + ], + ) + .await; + + let mut builder = test_codex() + .with_auth(create_dummy_codex_auth()) + .with_config(move |config| { + config + .features + .enable(Feature::Apps) + .expect("test config should allow feature update"); + config.chatgpt_base_url = apps_base_url; + config.orchestrator_mcp_enabled = false; + }); + let codex = builder + .build(&server) + .await + .expect("create new conversation") + .codex; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await + .unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let requests = resp_mock.requests(); + assert_eq!(requests.len(), 3); + let request = &requests[0]; + assert!( + !message_input_text_contains(request, "developer", ""), + "did not expect apps instructions when orchestrator MCP is disabled, got {:?}", + request.body_json()["input"] + ); + assert!( + !request.body_contains_text("mcp__codex_apps"), + "did not expect codex_apps MCP tools when orchestrator MCP is disabled, got {:?}", + request.body_json()["tools"] + ); + let list_output = requests[1] + .function_call_output_text(list_call_id) + .expect("resource list output should be sent to the model"); + assert_eq!( + serde_json::from_str::(&list_output) + .expect("parse resource list output"), + json!({"resources": []}) + ); + let read_output = requests[2] + .function_call_output_text(read_call_id) + .expect("resource read output should be sent to the model"); + assert!( + read_output.contains("disabled by `orchestrator.mcp.enabled`"), + "unexpected resource read output: {read_output}" + ); + + let resource_methods = server + .received_requests() + .await + .expect("read recorded requests") + .into_iter() + .filter_map(|request| serde_json::from_slice::(&request.body).ok()) + .filter_map(|body| { + body.get("method") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }) + .filter(|method| method.starts_with("resources/")) + .collect::>(); + assert!( + resource_methods.is_empty(), + "did not expect codex_apps resource calls: {resource_methods:?}" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn omits_environment_context_when_configured_off() { let server = MockServer::start().await; diff --git a/codex-rs/ext/skills/src/config.rs b/codex-rs/ext/skills/src/config.rs index e883f0c5f5..3d903cafb8 100644 --- a/codex-rs/ext/skills/src/config.rs +++ b/codex-rs/ext/skills/src/config.rs @@ -5,4 +5,6 @@ pub struct SkillsExtensionConfig { pub include_instructions: bool, /// Whether bundled skills are eligible for discovery. pub bundled_skills_enabled: bool, + /// Whether orchestrator-owned skills are eligible for discovery. + pub orchestrator_skills_enabled: bool, } diff --git a/codex-rs/ext/skills/src/extension.rs b/codex-rs/ext/skills/src/extension.rs index 0a3da0972a..6bd7036bfb 100644 --- a/codex-rs/ext/skills/src/extension.rs +++ b/codex-rs/ext/skills/src/extension.rs @@ -61,14 +61,14 @@ where .get::>() .map(|selected_roots| selected_roots.as_ref().clone()) .unwrap_or_default(); - let orchestrator_skills_enabled = !input + let orchestrator_skills_available = !input .environments .iter() .any(|environment| environment.environment_id == LOCAL_ENVIRONMENT_ID); input.thread_store.insert(SkillsThreadState::new( (self.config_from_host)(input.config), selected_roots, - orchestrator_skills_enabled, + orchestrator_skills_available, )); }) } @@ -89,11 +89,11 @@ where if let Some(state) = thread_store.get::() { state.set_config(next_config); } else { - let orchestrator_skills_enabled = true; + let orchestrator_skills_available = true; thread_store.insert(SkillsThreadState::new( next_config, Vec::new(), - orchestrator_skills_enabled, + orchestrator_skills_available, )); } } diff --git a/codex-rs/ext/skills/src/state.rs b/codex-rs/ext/skills/src/state.rs index f718f6e029..0b751ebdc2 100644 --- a/codex-rs/ext/skills/src/state.rs +++ b/codex-rs/ext/skills/src/state.rs @@ -27,7 +27,7 @@ const MAX_CACHED_ORCHESTRATOR_CONTENT_BYTES: usize = 8 * 1024 * 1024; pub(crate) struct SkillsThreadState { config: Mutex, selected_roots: Vec, - orchestrator_skills_enabled: bool, + orchestrator_skills_available: bool, orchestrator_cache: Mutex>>, } @@ -35,12 +35,12 @@ impl SkillsThreadState { pub(crate) fn new( config: SkillsExtensionConfig, selected_roots: Vec, - orchestrator_skills_enabled: bool, + orchestrator_skills_available: bool, ) -> Self { Self { config: Mutex::new(config), selected_roots, - orchestrator_skills_enabled, + orchestrator_skills_available, orchestrator_cache: Mutex::new(None), } } @@ -64,7 +64,7 @@ impl SkillsThreadState { } pub(crate) fn orchestrator_skills_enabled(&self) -> bool { - self.orchestrator_skills_enabled + self.orchestrator_skills_available && self.config().orchestrator_skills_enabled } pub(crate) async fn orchestrator_catalog_snapshot( diff --git a/codex-rs/ext/skills/tests/skills_extension.rs b/codex-rs/ext/skills/tests/skills_extension.rs index 16d9bdd1a3..2d06c33636 100644 --- a/codex-rs/ext/skills/tests/skills_extension.rs +++ b/codex-rs/ext/skills/tests/skills_extension.rs @@ -565,12 +565,14 @@ fn test_entry( struct TestConfig { include_instructions: bool, bundled_skills_enabled: bool, + orchestrator_skills_enabled: bool, } fn default_config() -> TestConfig { TestConfig { include_instructions: true, bundled_skills_enabled: true, + orchestrator_skills_enabled: true, } } @@ -578,6 +580,7 @@ fn skills_extension_config(config: &TestConfig) -> SkillsExtensionConfig { SkillsExtensionConfig { include_instructions: config.include_instructions, bundled_skills_enabled: config.bundled_skills_enabled, + orchestrator_skills_enabled: config.orchestrator_skills_enabled, } } diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 26ba71ae46..b9796a55cb 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -197,6 +197,8 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R include_apps_instructions: false, include_collaboration_mode_instructions: false, include_skill_instructions: false, + orchestrator_skills_enabled: false, + orchestrator_mcp_enabled: false, include_environment_context: false, compact_prompt: None, notify: None, From 04483f4ce5694d471e471583d4ca286908d7c8b7 Mon Sep 17 00:00:00 2001 From: jif Date: Fri, 19 Jun 2026 16:05:51 +0100 Subject: [PATCH 076/112] Keep remote exec commands native to the executor (#29099) ## Summary - Remote unified-exec now sends the original command argv to exec-server instead of materializing the orchestrator's sandbox wrapper first. - Local unified-exec keeps the existing sandbox path unchanged. - Add a focused regression test for a macOS-selected sandbox producing plain remote argv. Before: macOS orchestrator -> /usr/bin/sandbox-exec ... -> Linux exec-server After: macOS orchestrator -> /bin/bash -lc pwd -> Linux exec-server This is intentionally only the first cleanup step. Remote unified-exec commands are sent without a process sandbox until the targeted follow-ups below land. For the macOS-to-Linux path this is not a practical regression: the old sandboxed attempt failed before process launch because the Linux executor could not spawn macOS sandbox paths. ## Targeted follow-ups 1. Carry sandbox intent separately from argv. - Add an optional sandbox field to exec-server process params. - Reuse FileSystemSandboxContext rather than introducing a new sandbox model. - Carry managed-network enforcement as one explicit bit. - Keep argv plain. 2. Apply that intent inside exec-server. - Add a small process-start adapter before LocalProcess::exec. - Reuse the existing codex-sandboxing SandboxManager and exec-server runtime paths. - Follow the same shape already used by exec-server filesystem sandboxing. - Do not duplicate or move the sandbox implementations. 3. Report the sandbox actually used. - Return the executor-selected sandbox type from process/start. - Use that value in core for sandbox-denial detection and retry behavior. ## End state The orchestrator sends plain commands plus portable sandbox intent. The executor chooses and applies its own native sandbox: Linux executors use Linux sandboxing, macOS executors use Seatbelt, and Windows executors use Windows sandboxing. Concrete wrapper argv, helper paths, and sandbox env markers never cross the executor boundary. --- .../core/src/tools/runtimes/unified_exec.rs | 27 +++------ codex-rs/core/src/tools/sandboxing.rs | 31 ++++++++++ codex-rs/core/src/tools/sandboxing_tests.rs | 60 +++++++++++++++++++ codex-rs/core/src/unified_exec/mod_tests.rs | 8 +-- .../core/src/unified_exec/process_manager.rs | 44 ++++++++++++++ 5 files changed, 146 insertions(+), 24 deletions(-) diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 0d6c93b4d0..c1d5191204 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -421,7 +421,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt } return self .manager - .open_session_with_exec_env( + .open_session_with_prepared_exec_env( req.process_id, &prepared.exec_request, req.tty, @@ -459,33 +459,20 @@ impl<'a> ToolRuntime for UnifiedExecRunt error @ ToolError::Codex(_) => error, })?; let options = unified_exec_options(attempt.network_denial_cancellation_token.clone()); - let mut exec_env = attempt - .env_for( - command, - options, - managed_network, - Some(&req.turn_environment.environment_id), - ) - .map_err(ToolError::Codex)?; - exec_env.exec_server_env_config = req.exec_server_env_config.clone(); self.manager .open_session_with_exec_env( req.process_id, - &exec_env, + command, + options, + attempt, + managed_network, + /*environment_id*/ Some(&req.turn_environment.environment_id), + req.exec_server_env_config.clone(), req.tty, Box::new(NoopSpawnLifecycle), req.turn_environment.environment.as_ref(), ) .await - .map_err(|err| match err { - UnifiedExecError::SandboxDenied { output, .. } => { - ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { - output: Box::new(output), - network_policy_decision: None, - })) - } - other => ToolError::Rejected(other.to_string()), - }) } } diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 48c06413b1..847a3c7584 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -452,6 +452,37 @@ impl<'a> SandboxAttempt<'a> { self.workspace_roots.to_vec(), )) } + + pub fn env_for_exec_server( + &self, + command: SandboxCommand, + options: ExecOptions, + network: Option<&NetworkProxy>, + environment_id: Option<&str>, + ) -> Result { + let request = self + .manager + .transform(SandboxTransformRequest { + command, + permissions: self.permissions, + // The exec-server must receive the native command, not this host's wrapper. + sandbox: SandboxType::None, + enforce_managed_network: self.enforce_managed_network, + environment_id, + network, + sandbox_policy_cwd: self.sandbox_cwd, + codex_linux_sandbox_exe: None, + use_legacy_landlock: self.use_legacy_landlock, + windows_sandbox_level: self.windows_sandbox_level, + windows_sandbox_private_desktop: self.windows_sandbox_private_desktop, + }) + .map_err(CodexErr::from)?; + Ok(crate::sandboxing::ExecRequest::from_sandbox_exec_request( + request, + options, + self.workspace_roots.to_vec(), + )) + } } #[cfg(test)] diff --git a/codex-rs/core/src/tools/sandboxing_tests.rs b/codex-rs/core/src/tools/sandboxing_tests.rs index 21719e281f..f4d6e6ce42 100644 --- a/codex-rs/core/src/tools/sandboxing_tests.rs +++ b/codex-rs/core/src/tools/sandboxing_tests.rs @@ -4,9 +4,16 @@ use crate::tools::hook_names::HookToolName; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::GranularApprovalConfig; +use codex_sandboxing::SandboxCommand; +use codex_sandboxing::SandboxManager; +use codex_sandboxing::SandboxType; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; use serde_json::json; +use std::collections::HashMap; #[test] fn bash_permission_request_payload_omits_missing_description() { @@ -193,3 +200,56 @@ fn deny_read_blocks_explicit_escalation_and_policy_bypass() { "exec-policy allow rules would drop deny-read filesystem policy, so keep the first attempt sandboxed", ); } + +#[test] +fn exec_server_env_keeps_command_native() { + let cwd: AbsolutePathBuf = std::env::current_dir() + .expect("current dir") + .try_into() + .expect("absolute cwd"); + let cwd_uri = PathUri::from_abs_path(&cwd); + let permissions = codex_protocol::models::PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::default(), + NetworkSandboxPolicy::Restricted, + ); + let manager = SandboxManager::new(); + let attempt = SandboxAttempt { + sandbox: SandboxType::MacosSeatbelt, + permissions: &permissions, + enforce_managed_network: false, + manager: &manager, + sandbox_cwd: &cwd_uri, + workspace_roots: std::slice::from_ref(&cwd), + codex_linux_sandbox_exe: None, + use_legacy_landlock: false, + windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, + windows_sandbox_private_desktop: false, + network_denial_cancellation_token: None, + }; + let command = SandboxCommand { + program: "/bin/bash".into(), + args: vec!["-lc".to_string(), "pwd".to_string()], + cwd: cwd_uri.clone(), + env: HashMap::new(), + additional_permissions: None, + }; + let options = crate::sandboxing::ExecOptions { + expiration: crate::exec::ExecExpiration::DefaultTimeout, + capture_policy: crate::exec::ExecCapturePolicy::ShellTool, + }; + + let request = attempt + .env_for_exec_server(command, options, /*network*/ None, Some("remote")) + .expect("prepare remote exec request"); + + assert_eq!( + request.command, + vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "pwd".to_string() + ] + ); + assert_eq!(request.arg0, None); + assert_eq!(request.sandbox, SandboxType::None); +} diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index 8e1b03d118..d5b813cb42 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -108,7 +108,7 @@ async fn exec_command_with_tty( let process = Arc::new( manager - .open_session_with_exec_env( + .open_session_with_prepared_exec_env( process_id, &request, tty, @@ -811,7 +811,7 @@ async fn completed_pipe_commands_preserve_exit_code() -> anyhow::Result<()> { let environment = codex_exec_server::Environment::default_for_tests(); let process = UnifiedExecProcessManager::default() - .open_session_with_exec_env( + .open_session_with_prepared_exec_env( /*process_id*/ 1234, &request, /*tty*/ false, @@ -853,7 +853,7 @@ async fn unified_exec_uses_remote_exec_server_when_configured() -> anyhow::Resul let manager = UnifiedExecProcessManager::default(); let process = manager - .open_session_with_exec_env( + .open_session_with_prepared_exec_env( /*process_id*/ 1234, &request, /*tty*/ true, @@ -910,7 +910,7 @@ async fn remote_exec_server_rejects_inherited_fd_launches() -> anyhow::Result<() let manager = UnifiedExecProcessManager::default(); let err = manager - .open_session_with_exec_env( + .open_session_with_prepared_exec_env( /*process_id*/ 1234, &request, /*tty*/ true, diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 0aac491823..fc5508eb80 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -15,6 +15,7 @@ use crate::codex_thread::BackgroundTerminalInfo; use crate::exec_env::CODEX_THREAD_ID_ENV_VAR; use crate::exec_env::create_env; use crate::exec_policy::ExecApprovalRequest; +use crate::sandboxing::ExecOptions; use crate::sandboxing::ExecRequest; use crate::sandboxing::ExecServerEnvConfig; use crate::tools::context::ExecCommandToolOutput; @@ -26,6 +27,7 @@ use crate::tools::network_approval::finish_deferred_network_approval; use crate::tools::orchestrator::ToolOrchestrator; use crate::tools::runtimes::unified_exec::UnifiedExecRequest as UnifiedExecToolRequest; use crate::tools::runtimes::unified_exec::UnifiedExecRuntime; +use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::ToolCtx; use crate::tools::sandboxing::ToolError; use crate::unified_exec::ExecCommandRequest; @@ -50,10 +52,12 @@ use crate::unified_exec::process::OutputBuffer; use crate::unified_exec::process::OutputHandles; use crate::unified_exec::process::SpawnLifecycleHandle; use crate::unified_exec::process::UnifiedExecProcess; +use codex_network_proxy::NetworkProxy; use codex_protocol::config_types::ShellEnvironmentPolicy; use codex_protocol::error::CodexErr; use codex_protocol::error::SandboxErr; use codex_protocol::protocol::ExecCommandSource; +use codex_sandboxing::SandboxCommand; use codex_tools::ToolName; use codex_utils_output_truncation::approx_token_count; use codex_utils_path_uri::PathUri; @@ -886,7 +890,47 @@ impl UnifiedExecProcessManager { ); } + #[allow(clippy::too_many_arguments)] pub(crate) async fn open_session_with_exec_env( + &self, + process_id: i32, + command: SandboxCommand, + options: ExecOptions, + attempt: &SandboxAttempt<'_>, + network: Option<&NetworkProxy>, + environment_id: Option<&str>, + exec_server_env_config: Option, + tty: bool, + spawn_lifecycle: SpawnLifecycleHandle, + environment: &codex_exec_server::Environment, + ) -> Result { + let mut request = if environment.is_remote() { + attempt.env_for_exec_server(command, options, network, environment_id) + } else { + attempt.env_for(command, options, network, environment_id) + } + .map_err(ToolError::Codex)?; + request.exec_server_env_config = exec_server_env_config; + self.open_session_with_prepared_exec_env( + process_id, + &request, + tty, + spawn_lifecycle, + environment, + ) + .await + .map_err(|err| match err { + UnifiedExecError::SandboxDenied { output, .. } => { + ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { + output: Box::new(output), + network_policy_decision: None, + })) + } + other => ToolError::Rejected(other.to_string()), + }) + } + + pub(crate) async fn open_session_with_prepared_exec_env( &self, process_id: i32, request: &ExecRequest, From c83618ab2098525d343df2160d98b2449dca6d5d Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Fri, 19 Jun 2026 09:00:44 -0700 Subject: [PATCH 077/112] Use cached and live web access terminology (#29095) ## Summary - Rename the string-valued external web access enum variants from `Offline` / `Online` to `Cached` / `Live`. - Align the transport names with the existing `web_search = "cached"` / `"live"` configuration vocabulary. Existing behavior is unchanged: `WebSearchMode::Cached` and `WebSearchMode::Live` continue to send the backward-compatible boolean values `false` and `true`; `Indexed` remains the only mode currently sent as a string. ## Validation - `just fmt` - `just test -p codex-api` (127 passed) --- codex-rs/codex-api/src/search.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codex-rs/codex-api/src/search.rs b/codex-rs/codex-api/src/search.rs index b896186af8..7219684af1 100644 --- a/codex-rs/codex-api/src/search.rs +++ b/codex-rs/codex-api/src/search.rs @@ -214,9 +214,9 @@ pub enum SearchResponseLength { #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum ExternalWebAccessMode { - Offline, + Cached, Indexed, - Online, + Live, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] From a57b268d61ca7d4d20819587b999d8a15635440b Mon Sep 17 00:00:00 2001 From: rphilizaire-openai Date: Fri, 19 Jun 2026 10:13:27 -0700 Subject: [PATCH 078/112] [codex] trace pre-sampling skill and persistence latency (#29042) --- codex-rs/core-skills/src/injection.rs | 5 +++++ codex-rs/core/src/session/mod.rs | 9 +++++++++ codex-rs/core/src/session/turn.rs | 10 ++++++++++ codex-rs/ext/skills/src/extension.rs | 2 ++ codex-rs/ext/skills/src/render.rs | 5 +++++ codex-rs/ext/skills/src/selection.rs | 8 ++++++++ codex-rs/thread-store/src/live_thread.rs | 5 +++++ codex-rs/thread-store/src/local/live_writer.rs | 5 +++++ 8 files changed, 49 insertions(+) diff --git a/codex-rs/core-skills/src/injection.rs b/codex-rs/core-skills/src/injection.rs index e7d1fd3749..201e7959fb 100644 --- a/codex-rs/core-skills/src/injection.rs +++ b/codex-rs/core-skills/src/injection.rs @@ -55,6 +55,11 @@ impl InjectedHostSkillPrompts { } } +#[tracing::instrument( + level = "trace", + skip_all, + fields(mentioned_skill_count = mentioned_skills.len()) +)] pub async fn build_skill_injections( mentioned_skills: &[SkillMetadata], loaded_skills: Option<&SkillLoadOutcome>, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 3c1f06f6b6..0792334b30 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1233,6 +1233,11 @@ impl Session { } // Merges connector IDs into the session-level explicit connector selection. + #[tracing::instrument( + level = "trace", + skip_all, + fields(connector_count = connector_ids.len()) + )] pub(crate) async fn merge_connector_selection( &self, connector_ids: HashSet, @@ -1426,6 +1431,7 @@ impl Session { state.previous_turn_settings() } + #[tracing::instrument(level = "trace", skip_all)] pub(crate) async fn set_previous_turn_settings( &self, previous_turn_settings: Option, @@ -2736,6 +2742,7 @@ impl Session { )) } + #[tracing::instrument(level = "trace", skip_all, fields(item_count = items.len()))] pub(crate) async fn record_conversation_items( &self, turn_context: &TurnContext, @@ -2929,6 +2936,7 @@ impl Session { self.set_multi_agent_version_if_unset(selected) } + #[tracing::instrument(level = "trace", skip_all, fields(item_count = items.len()))] async fn send_raw_response_items(&self, turn_context: &TurnContext, items: &[ResponseItem]) { for item in items { self.send_event( @@ -3280,6 +3288,7 @@ impl Session { items } + #[tracing::instrument(level = "trace", skip_all, fields(item_count = items.len()))] pub(crate) async fn persist_rollout_items(&self, items: &[RolloutItem]) { if let Some(live_thread) = self.live_thread() && let Err(e) = live_thread.append_items(items).await diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 42fab7a506..28c0f65612 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -655,6 +655,11 @@ async fn build_skills_and_plugins( Some((injection_items, explicitly_enabled_connectors)) } +#[tracing::instrument( + level = "trace", + skip_all, + fields(user_input_count = user_input.len()) +)] async fn build_extension_turn_input_items( sess: &Arc, turn_context: &TurnContext, @@ -710,6 +715,11 @@ async fn build_extension_turn_input_items( Some(items) } +#[tracing::instrument( + level = "trace", + skip_all, + fields(input_count = input.len()) +)] async fn track_turn_resolved_config_analytics( sess: &Session, turn_context: &TurnContext, diff --git a/codex-rs/ext/skills/src/extension.rs b/codex-rs/ext/skills/src/extension.rs index 6bd7036bfb..de9facd8c2 100644 --- a/codex-rs/ext/skills/src/extension.rs +++ b/codex-rs/ext/skills/src/extension.rs @@ -287,6 +287,7 @@ where } impl SkillsExtension { + #[tracing::instrument(level = "trace", skip_all)] async fn list_skills( &self, mut query: SkillListQuery, @@ -311,6 +312,7 @@ impl SkillsExtension { catalog } + #[tracing::instrument(level = "trace", skip_all, fields(skill = %entry.name))] async fn read_main_prompt( &self, entry: &SkillCatalogEntry, diff --git a/codex-rs/ext/skills/src/render.rs b/codex-rs/ext/skills/src/render.rs index d913e4161c..89597aa9bb 100644 --- a/codex-rs/ext/skills/src/render.rs +++ b/codex-rs/ext/skills/src/render.rs @@ -10,6 +10,11 @@ const MAX_MAIN_PROMPT_BYTES: usize = 8_000; pub(crate) const MAX_SKILL_NAME_BYTES: usize = 256; pub(crate) const MAX_SKILL_PATH_BYTES: usize = 1_024; +#[tracing::instrument( + level = "trace", + skip_all, + fields(catalog_entry_count = catalog.entries.len()) +)] pub(crate) fn available_skills_fragment( catalog: &SkillCatalog, ) -> Option { diff --git a/codex-rs/ext/skills/src/selection.rs b/codex-rs/ext/skills/src/selection.rs index cfea2e3abd..a708b18d6c 100644 --- a/codex-rs/ext/skills/src/selection.rs +++ b/codex-rs/ext/skills/src/selection.rs @@ -10,6 +10,14 @@ use crate::catalog::SkillPackageId; const SKILL_PATH_PREFIX: &str = "skill://"; +#[tracing::instrument( + level = "trace", + skip_all, + fields( + input_count = inputs.len(), + catalog_entry_count = catalog.entries.len() + ) +)] pub(crate) fn collect_explicit_skill_mentions( inputs: &[UserInput], catalog: &SkillCatalog, diff --git a/codex-rs/thread-store/src/live_thread.rs b/codex-rs/thread-store/src/live_thread.rs index 88d347f57b..6b94807511 100644 --- a/codex-rs/thread-store/src/live_thread.rs +++ b/codex-rs/thread-store/src/live_thread.rs @@ -133,6 +133,11 @@ impl LiveThread { }) } + #[tracing::instrument( + level = "trace", + skip_all, + fields(item_count = items.len()) + )] pub async fn append_items(&self, items: &[RolloutItem]) -> ThreadStoreResult<()> { let canonical_items = persisted_rollout_items(items); if items.is_empty() { diff --git a/codex-rs/thread-store/src/local/live_writer.rs b/codex-rs/thread-store/src/local/live_writer.rs index 61c15a43d9..24c31869f1 100644 --- a/codex-rs/thread-store/src/local/live_writer.rs +++ b/codex-rs/thread-store/src/local/live_writer.rs @@ -74,6 +74,11 @@ pub(super) async fn resume_thread( store.insert_live_recorder(params.thread_id, recorder).await } +#[tracing::instrument( + level = "trace", + skip_all, + fields(item_count = params.items.len()) +)] pub(super) async fn append_items( store: &LocalThreadStore, params: AppendThreadItemsParams, From abd901770e17b26b41b7371ec93b56fdcab61d7d Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Fri, 19 Jun 2026 12:02:12 -0700 Subject: [PATCH 079/112] chore(deps): advance tokio-tungstenite (#29132) ## Why Responses websocket connections use `tokio-tungstenite`. When DNS returns an unusable native IPv6 address before a working IPv4 address, sequential dialing can consume Codex's outer websocket timeout before reaching IPv4. The merged fork change adds Happy Eyeballs-style alternate-family racing so websocket dialing matches the recovery behavior already present in the HTTP path. ## What Changed Advance the workspace `tokio-tungstenite` patch from `132f5b39` to merged commit `e5e64b86`, and update the matching lockfile source. The new revision comes from [openai-oss-forks/tokio-tungstenite#1](https://github.com/openai-oss-forks/tokio-tungstenite/pull/1). --- MODULE.bazel.lock | 2 +- codex-rs/Cargo.lock | 2 +- codex-rs/Cargo.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 834b1acadd..be8d988f5d 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -982,7 +982,7 @@ "git+https://github.com/juberti-oai/rust-sdks.git?rev=e2d1d1d230c6fc9df171ccb181423f957bb3c1f0#e2d1d1d230c6fc9df171ccb181423f957bb3c1f0_webrtc-sys-build": "{\"dependencies\":[{\"name\":\"anyhow\"},{\"name\":\"fs2\"},{\"name\":\"regex\"},{\"default_features\":false,\"features\":[\"rustls-tls-native-roots\",\"blocking\"],\"name\":\"reqwest\",\"optional\":false},{\"name\":\"scratch\"},{\"name\":\"semver\"},{\"name\":\"zip\"}],\"features\":{},\"strip_prefix\":\"webrtc-sys/build\"}", "git+https://github.com/nornagon/crossterm?rev=87db8bfa6dc99427fd3b071681b07fc31c6ce995#87db8bfa6dc99427fd3b071681b07fc31c6ce995_crossterm": "{\"dependencies\":[{\"default_features\":true,\"features\":[],\"name\":\"bitflags\",\"optional\":false},{\"default_features\":false,\"features\":[],\"name\":\"futures-core\",\"optional\":true},{\"name\":\"parking_lot\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"filedescriptor\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[],\"name\":\"libc\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"os-poll\"],\"name\":\"mio\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[\"std\",\"stdio\",\"termios\"],\"name\":\"rustix\",\"optional\":false,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"signal-hook\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"support-v1_0\"],\"name\":\"signal-hook-mio\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm_winapi\",\"optional\":true,\"target\":\"cfg(windows)\"},{\"default_features\":true,\"features\":[\"winuser\",\"winerror\"],\"name\":\"winapi\",\"optional\":true,\"target\":\"cfg(windows)\"}],\"features\":{\"bracketed-paste\":[],\"default\":[\"bracketed-paste\",\"windows\",\"events\"],\"event-stream\":[\"dep:futures-core\",\"events\"],\"events\":[\"dep:mio\",\"dep:signal-hook\",\"dep:signal-hook-mio\"],\"serde\":[\"dep:serde\",\"bitflags/serde\"],\"use-dev-tty\":[\"filedescriptor\",\"rustix/process\"],\"windows\":[\"dep:winapi\",\"dep:crossterm_winapi\"]},\"strip_prefix\":\"\"}", "git+https://github.com/nornagon/ratatui?rev=9b2ad1298408c45918ee9f8241a6f95498cdbed2#9b2ad1298408c45918ee9f8241a6f95498cdbed2_ratatui": "{\"dependencies\":[{\"name\":\"bitflags\"},{\"name\":\"cassowary\"},{\"name\":\"compact_str\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"document-features\",\"optional\":true},{\"name\":\"indoc\"},{\"name\":\"instability\"},{\"name\":\"itertools\"},{\"name\":\"lru\"},{\"default_features\":true,\"features\":[],\"name\":\"palette\",\"optional\":true},{\"name\":\"paste\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"strum\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"termwiz\",\"optional\":true},{\"default_features\":true,\"features\":[\"local-offset\"],\"name\":\"time\",\"optional\":true},{\"name\":\"unicode-segmentation\"},{\"name\":\"unicode-truncate\"},{\"name\":\"unicode-width\"},{\"default_features\":true,\"features\":[],\"name\":\"termion\",\"optional\":true,\"target\":\"cfg(not(windows))\"}],\"features\":{\"all-widgets\":[\"widget-calendar\"],\"crossterm\":[\"dep:crossterm\"],\"default\":[\"crossterm\",\"underline-color\"],\"macros\":[],\"palette\":[\"dep:palette\"],\"scrolling-regions\":[],\"serde\":[\"dep:serde\",\"bitflags/serde\",\"compact_str/serde\"],\"termion\":[\"dep:termion\"],\"termwiz\":[\"dep:termwiz\"],\"underline-color\":[\"dep:crossterm\"],\"unstable\":[\"unstable-rendered-line-info\",\"unstable-widget-ref\",\"unstable-backend-writer\"],\"unstable-backend-writer\":[],\"unstable-rendered-line-info\":[],\"unstable-widget-ref\":[],\"widget-calendar\":[\"dep:time\"]},\"strip_prefix\":\"\"}", - "git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=132f5b39c862e3a970f731d709608b3e6276d5f6#132f5b39c862e3a970f731d709608b3e6276d5f6_tokio-tungstenite": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"optional\":false},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"default_features\":false,\"features\":[],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"tokio-native-tls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tokio-rustls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tungstenite\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\",\"tokio-rustls\",\"stream\",\"tungstenite/__rustls-tls\",\"handshake\"],\"connect\":[\"stream\",\"tokio/net\",\"handshake\"],\"default\":[\"connect\",\"handshake\"],\"handshake\":[\"tungstenite/handshake\"],\"native-tls\":[\"native-tls-crate\",\"tokio-native-tls\",\"stream\",\"tungstenite/native-tls\",\"handshake\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\",\"tungstenite/native-tls-vendored\"],\"proxy\":[\"tungstenite/proxy\",\"tokio/net\",\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"stream\":[],\"url\":[\"tungstenite/url\"]},\"strip_prefix\":\"\"}", + "git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=e5e64b86722c5453c4038131c3193faa49a648ad#e5e64b86722c5453c4038131c3193faa49a648ad_tokio-tungstenite": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"optional\":false},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"default_features\":false,\"features\":[],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"tokio-native-tls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tokio-rustls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tungstenite\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\",\"tokio-rustls\",\"stream\",\"tungstenite/__rustls-tls\",\"handshake\"],\"connect\":[\"stream\",\"tokio/net\",\"tokio/time\",\"handshake\"],\"default\":[\"connect\",\"handshake\"],\"handshake\":[\"tungstenite/handshake\"],\"native-tls\":[\"native-tls-crate\",\"tokio-native-tls\",\"stream\",\"tungstenite/native-tls\",\"handshake\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\",\"tungstenite/native-tls-vendored\"],\"proxy\":[\"tungstenite/proxy\",\"tokio/net\",\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"stream\":[],\"url\":[\"tungstenite/url\"]},\"strip_prefix\":\"\"}", "git+https://github.com/openai-oss-forks/tungstenite-rs?rev=9200079d3b54a1ff51072e24d81fd354f085156f#9200079d3b54a1ff51072e24d81fd354f085156f_tungstenite": "{\"dependencies\":[{\"name\":\"bytes\"},{\"default_features\":true,\"features\":[],\"name\":\"data-encoding\",\"optional\":true},{\"default_features\":false,\"features\":[\"zlib\"],\"name\":\"flate2\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"headers\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"http\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"httparse\",\"optional\":true},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"name\":\"rand\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"sha1\",\"optional\":true},{\"name\":\"thiserror\"},{\"default_features\":true,\"features\":[],\"name\":\"url\",\"optional\":true},{\"name\":\"utf-8\"},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"deflate\":[\"headers\",\"flate2\"],\"handshake\":[\"data-encoding\",\"headers\",\"httparse\",\"sha1\"],\"headers\":[\"http\",\"dep:headers\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"proxy\":[\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"url\":[\"dep:url\"]},\"strip_prefix\":\"\"}", "git+https://github.com/rust-lang/rust-clippy?rev=20ce69b9a63bcd2756cd906fe0964d1e901e042a#20ce69b9a63bcd2756cd906fe0964d1e901e042a_clippy_utils": "{\"dependencies\":[{\"default_features\":false,\"features\":[],\"name\":\"arrayvec\",\"optional\":false},{\"name\":\"itertools\"},{\"name\":\"rustc_apfloat\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":false}],\"features\":{},\"strip_prefix\":\"clippy_utils\"}", "git2_0.20.4": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.4.13\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"libgit2-sys\",\"req\":\"^0.18.3\"},{\"name\":\"log\",\"req\":\"^0.4.8\"},{\"name\":\"openssl-probe\",\"optional\":true,\"req\":\"^0.1\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.45\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"},{\"features\":[\"formatting\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.37\"},{\"name\":\"url\",\"req\":\"^2.5.4\"}],\"features\":{\"default\":[\"ssh\",\"https\"],\"https\":[\"libgit2-sys/https\",\"openssl-sys\",\"openssl-probe\"],\"ssh\":[\"libgit2-sys/ssh\"],\"unstable\":[],\"vendored-libgit2\":[\"libgit2-sys/vendored\"],\"vendored-openssl\":[\"openssl-sys/vendored\",\"libgit2-sys/vendored-openssl\"],\"zlib-ng-compat\":[\"libgit2-sys/zlib-ng-compat\"]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 55d02cd8b1..f6f0e55831 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -13603,7 +13603,7 @@ dependencies = [ [[package]] name = "tokio-tungstenite" version = "0.28.0" -source = "git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=132f5b39c862e3a970f731d709608b3e6276d5f6#132f5b39c862e3a970f731d709608b3e6276d5f6" +source = "git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=e5e64b86722c5453c4038131c3193faa49a648ad#e5e64b86722c5453c4038131c3193faa49a648ad" dependencies = [ "futures-util", "log", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index e5f5025e91..667bb7dfdf 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -546,7 +546,7 @@ opt-level = 0 # ratatui = { path = "../../ratatui" } crossterm = { git = "https://github.com/nornagon/crossterm", rev = "87db8bfa6dc99427fd3b071681b07fc31c6ce995" } ratatui = { git = "https://github.com/nornagon/ratatui", rev = "9b2ad1298408c45918ee9f8241a6f95498cdbed2" } -tokio-tungstenite = { git = "https://github.com/openai-oss-forks/tokio-tungstenite", rev = "132f5b39c862e3a970f731d709608b3e6276d5f6" } +tokio-tungstenite = { git = "https://github.com/openai-oss-forks/tokio-tungstenite", rev = "e5e64b86722c5453c4038131c3193faa49a648ad" } tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "9200079d3b54a1ff51072e24d81fd354f085156f" } # Uncomment to debug local changes. From 64bdeed9f7adbe60c725153b3fb74ed044a36221 Mon Sep 17 00:00:00 2001 From: charlesgong-openai Date: Fri, 19 Jun 2026 12:47:53 -0700 Subject: [PATCH 080/112] [codex] Preserve skill descriptions outside model context (#29006) ## Why Skill descriptions are used in model-visible lists: the default available-skills catalog that supports implicit selection, and the on-demand `skills.list` tool response used to discover orchestrator skills. A single overlong description should not consume a disproportionate share of either list. Enforcing the 1024-character limit while loading or migrating skills is the wrong boundary: it rejects otherwise-valid skills and discards metadata that non-model consumers and full skill reads may need. Skill metadata and `SKILL.md` content should remain intact; the cap belongs at model-visible list rendering boundaries. ## What changed - Preserve full `description` and `metadata.short-description` values when loading skills. - Preserve full external-agent command descriptions during `source-command-*` migration instead of skipping commands solely because their descriptions exceed 1024 characters. - Preserve full normalized orchestrator descriptions in the underlying skills catalog. - Cap each description at 1024 Unicode characters when rendering the default available-skills context in `codex-core-skills` and `codex-skills-extension`. - Apply the same cap when serializing descriptions in the model-visible `skills.list` response. - Render truncated descriptions as 1021 original characters plus `...`. - Leave explicit `$skill` injection, `skills.read`, underlying metadata, and on-disk `SKILL.md` files unchanged and full-fidelity. ## Implicit skill selection Codex injects a bounded catalog containing each implicitly allowed skill's name, description, and source locator, together with instructions to use a skill when the task clearly matches its description. The model makes that semantic choice; after selecting a skill, it reads the full `SKILL.md` from its filesystem or provider resource. Explicit `$skill` mentions remain a separate path that injects the full skill instructions. For orchestrator skills, `skills.list` provides bounded discovery metadata before `skills.read` returns the full selected resource. ## Test plan - `just test -p codex-core-skills` - `just test -p codex-skills-extension` - `just test -p codex-external-agent-migration` The focused regressions verify that overlong metadata is preserved at load and migration boundaries while default available-skills rendering and `skills.list` output produce the 1021-character prefix plus `...`. --- codex-rs/core-skills/src/loader.rs | 9 +- codex-rs/core-skills/src/loader_tests.rs | 28 ++-- codex-rs/core-skills/src/render.rs | 54 +++++++- .../ext/skills/src/provider/orchestrator.rs | 12 +- codex-rs/ext/skills/src/render.rs | 27 +++- codex-rs/ext/skills/src/tools/list.rs | 3 +- codex-rs/ext/skills/tests/skills_extension.rs | 127 ++++++++++++++++++ codex-rs/external-agent-migration/src/lib.rs | 37 ++++- 8 files changed, 263 insertions(+), 34 deletions(-) diff --git a/codex-rs/core-skills/src/loader.rs b/codex-rs/core-skills/src/loader.rs index 2a3a3189be..aa9688f9a2 100644 --- a/codex-rs/core-skills/src/loader.rs +++ b/codex-rs/core-skills/src/loader.rs @@ -684,13 +684,8 @@ async fn parse_skill_file( validate_len(&base_name, MAX_NAME_LEN, "name")?; validate_len(&name, MAX_QUALIFIED_NAME_LEN, "qualified name")?; - validate_len(&description, MAX_DESCRIPTION_LEN, "description")?; - if let Some(short_description) = short_description.as_deref() { - validate_len( - short_description, - MAX_SHORT_DESCRIPTION_LEN, - "metadata.short-description", - )?; + if description.is_empty() { + return Err(SkillParseError::MissingField("description")); } let resolved_path = canonicalize_for_skill_identity(fs, path).await; diff --git a/codex-rs/core-skills/src/loader_tests.rs b/codex-rs/core-skills/src/loader_tests.rs index e22ed33f3d..8cd371c9a7 100644 --- a/codex-rs/core-skills/src/loader_tests.rs +++ b/codex-rs/core-skills/src/loader_tests.rs @@ -1573,7 +1573,7 @@ async fn preserves_block_scalar_body_while_repairing_other_fields() { } #[tokio::test] -async fn enforces_short_description_length_limits() { +async fn preserves_overlong_short_descriptions() { let codex_home = tempfile::tempdir().expect("tempdir"); let skill_dir = codex_home.path().join("skills/demo"); fs::create_dir_all(&skill_dir).unwrap(); @@ -1585,15 +1585,13 @@ async fn enforces_short_description_length_limits() { let cfg = make_config(&codex_home).await; let outcome = load_skills_for_test(&cfg).await; - assert_eq!(outcome.skills.len(), 0); - assert_eq!(outcome.errors.len(), 1); assert!( - outcome.errors[0] - .message - .contains("invalid metadata.short-description"), - "expected length error, got: {:?}", + outcome.errors.is_empty(), + "unexpected errors: {:?}", outcome.errors ); + assert_eq!(outcome.skills.len(), 1); + assert_eq!(outcome.skills[0].short_description, Some(too_long)); } #[tokio::test] @@ -1625,7 +1623,7 @@ async fn skips_hidden_and_invalid() { } #[tokio::test] -async fn enforces_length_limits() { +async fn preserves_overlong_descriptions() { let codex_home = tempfile::tempdir().expect("tempdir"); let max_desc = "\u{1F4A1}".repeat(MAX_DESCRIPTION_LEN); write_skill(&codex_home, "max-len", "max-len", &max_desc); @@ -1642,12 +1640,18 @@ async fn enforces_length_limits() { let too_long_desc = "\u{1F4A1}".repeat(MAX_DESCRIPTION_LEN + 1); write_skill(&codex_home, "too-long", "too-long", &too_long_desc); let outcome = load_skills_for_test(&cfg).await; - assert_eq!(outcome.skills.len(), 1); - assert_eq!(outcome.errors.len(), 1); assert!( - outcome.errors[0].message.contains("invalid description"), - "expected length error" + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors ); + assert_eq!(outcome.skills.len(), 2); + let too_long_skill = outcome + .skills + .iter() + .find(|skill| skill.name == "too-long") + .expect("too-long skill"); + assert_eq!(too_long_skill.description, too_long_desc); } #[tokio::test] diff --git a/codex-rs/core-skills/src/render.rs b/codex-rs/core-skills/src/render.rs index 1477a299b6..89ea1675d0 100644 --- a/codex-rs/core-skills/src/render.rs +++ b/codex-rs/core-skills/src/render.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::collections::HashMap; use std::collections::HashSet; use std::path::Component; @@ -16,6 +17,8 @@ use codex_utils_output_truncation::approx_token_count; const DEFAULT_SKILL_METADATA_CHAR_BUDGET: usize = 8_000; const SKILL_METADATA_CONTEXT_WINDOW_PERCENT: usize = 2; +const MAX_DEFAULT_CONTEXT_SKILL_DESCRIPTION_CHARS: usize = 1_024; +const TRUNCATED_SKILL_DESCRIPTION_SUFFIX: &str = "..."; const SKILL_DESCRIPTION_TRUNCATION_WARNING_THRESHOLD_CHARS: usize = 100; const APPROX_BYTES_PER_TOKEN: usize = 4; pub const SKILL_DESCRIPTION_TRUNCATED_WARNING: &str = "Skill descriptions were shortened to fit the skills context budget. Codex can still see every skill, but some descriptions are shorter. Disable unused skills or plugins to leave more room for the rest."; @@ -446,7 +449,7 @@ impl SkillRenderReport { struct SkillLine<'a> { name: &'a str, - description: &'a str, + description: Cow<'a, str>, path: String, } @@ -485,9 +488,10 @@ impl<'a> SkillLine<'a> { } fn with_path(skill: &'a SkillMetadata, path: String) -> Self { + let description = truncate_default_context_skill_description(skill.description.as_str()); Self { name: skill.name.as_str(), - description: skill.description.as_str(), + description, path, } } @@ -505,7 +509,7 @@ impl<'a> SkillLine<'a> { } fn render_full(&self) -> String { - self.render_with_description(self.description) + self.render_with_description(self.description.as_ref()) } fn render_minimum(&self) -> String { @@ -524,7 +528,7 @@ impl<'a> SkillLine<'a> { format!("- {}: (file: {})", self.name, self.path) } else { let end = self.rendered_description_prefix_len(description_chars); - let description = &self.description[..end]; + let description = &self.description.as_ref()[..end]; format!("- {}: {} (file: {})", self.name, description, self.path) } } @@ -538,6 +542,26 @@ impl<'a> SkillLine<'a> { } } +fn truncate_default_context_skill_description(description: &str) -> Cow<'_, str> { + if description + .char_indices() + .nth(MAX_DEFAULT_CONTEXT_SKILL_DESCRIPTION_CHARS) + .is_none() + { + return Cow::Borrowed(description); + } + + let prefix_chars = MAX_DEFAULT_CONTEXT_SKILL_DESCRIPTION_CHARS + .saturating_sub(TRUNCATED_SKILL_DESCRIPTION_SUFFIX.chars().count()); + let prefix_end = description + .char_indices() + .nth(prefix_chars) + .map_or(description.len(), |(index, _)| index); + let mut truncated = description[..prefix_end].to_string(); + truncated.push_str(TRUNCATED_SKILL_DESCRIPTION_SUFFIX); + Cow::Owned(truncated) +} + impl<'a> DescriptionBudgetLine<'a> { fn new(line: &'a SkillLine<'a>, budget: SkillMetadataBudget) -> Self { let minimum_line = line.render_minimum(); @@ -1030,6 +1054,28 @@ mod tests { ); } + #[test] + fn default_context_caps_descriptions_without_mutating_metadata() { + let description = "\u{1F4A1}".repeat(MAX_DEFAULT_CONTEXT_SKILL_DESCRIPTION_CHARS + 1); + let skill = make_skill_with_description("long-skill", SkillScope::Repo, &description); + let expected_description = "\u{1F4A1}".repeat( + MAX_DEFAULT_CONTEXT_SKILL_DESCRIPTION_CHARS + - TRUNCATED_SKILL_DESCRIPTION_SUFFIX.chars().count(), + ) + TRUNCATED_SKILL_DESCRIPTION_SUFFIX; + + let rendered = build_available_skills_from_metadata( + std::slice::from_ref(&skill), + SkillMetadataBudget::Characters(usize::MAX), + ) + .expect("skill should render"); + + assert_eq!(skill.description, description); + assert_eq!( + rendered.skill_lines, + vec![expected_skill_line(&skill, &expected_description)] + ); + } + #[test] fn budgeted_rendering_truncates_descriptions_equally_before_omitting_skills() { let alpha = make_skill_with_description("alpha-skill", SkillScope::Repo, "abcdef"); diff --git a/codex-rs/ext/skills/src/provider/orchestrator.rs b/codex-rs/ext/skills/src/provider/orchestrator.rs index 045b67de62..8d97bf32a1 100644 --- a/codex-rs/ext/skills/src/provider/orchestrator.rs +++ b/codex-rs/ext/skills/src/provider/orchestrator.rs @@ -28,7 +28,6 @@ const MAX_RESOURCE_PAGES: usize = 10; const MAX_ORCHESTRATOR_SKILLS: usize = 100; const MAX_SKILL_NAME_CHARS: usize = 64; const MAX_QUALIFIED_SKILL_NAME_CHARS: usize = 128; -const MAX_SKILL_DESCRIPTION_CHARS: usize = 1_024; const MAX_SKILL_PACKAGE_URI_CHARS: usize = 1_024; const MAX_SKILL_RESOURCE_URI_CHARS: usize = 2_048; const MAX_SKILL_RESOURCE_CONTENT_BYTES: usize = 1024 * 1024; @@ -308,12 +307,17 @@ fn normalized_label(value: &str, max_chars: usize) -> Option { } fn normalized_description(value: &str) -> Option { - normalized_single_line(value, MAX_SKILL_DESCRIPTION_CHARS).map(|value| { + let value = value.split_whitespace().collect::>().join(" "); + if value.chars().any(char::is_control) { + return None; + } + + Some( value .replace('&', "&") .replace('<', "<") - .replace('>', ">") - }) + .replace('>', ">"), + ) } fn normalized_single_line(value: &str, max_chars: usize) -> Option { diff --git a/codex-rs/ext/skills/src/render.rs b/codex-rs/ext/skills/src/render.rs index 89597aa9bb..dda32b4864 100644 --- a/codex-rs/ext/skills/src/render.rs +++ b/codex-rs/ext/skills/src/render.rs @@ -1,3 +1,5 @@ +use std::borrow::Cow; + use codex_utils_string::take_bytes_at_char_boundary; use crate::catalog::SkillCatalog; @@ -7,6 +9,8 @@ use crate::fragments::AvailableSkillsInstructions; const MAX_AVAILABLE_SKILLS_BYTES: usize = 8_000; const MAX_MAIN_PROMPT_BYTES: usize = 8_000; +const MAX_CATALOG_SKILL_DESCRIPTION_CHARS: usize = 1_024; +const TRUNCATED_SKILL_DESCRIPTION_SUFFIX: &str = "..."; pub(crate) const MAX_SKILL_NAME_BYTES: usize = 256; pub(crate) const MAX_SKILL_PATH_BYTES: usize = 1_024; @@ -31,7 +35,8 @@ pub(crate) fn available_skills_fragment( .short_description .as_deref() .unwrap_or(entry.description.as_str()); - let line = render_skill_line(entry, description); + let description = truncate_catalog_skill_description(description); + let line = render_skill_line(entry, description.as_ref()); let next_bytes = total_bytes.saturating_add(line.len()); if next_bytes > MAX_AVAILABLE_SKILLS_BYTES { omitted = omitted.saturating_add(1); @@ -54,6 +59,26 @@ pub(crate) fn available_skills_fragment( Some(AvailableSkillsInstructions::from_skill_lines(skill_lines)) } +pub(crate) fn truncate_catalog_skill_description(description: &str) -> Cow<'_, str> { + if description + .char_indices() + .nth(MAX_CATALOG_SKILL_DESCRIPTION_CHARS) + .is_none() + { + return Cow::Borrowed(description); + } + + let prefix_chars = MAX_CATALOG_SKILL_DESCRIPTION_CHARS + .saturating_sub(TRUNCATED_SKILL_DESCRIPTION_SUFFIX.chars().count()); + let prefix_end = description + .char_indices() + .nth(prefix_chars) + .map_or(description.len(), |(index, _)| index); + let mut truncated = description[..prefix_end].to_string(); + truncated.push_str(TRUNCATED_SKILL_DESCRIPTION_SUFFIX); + Cow::Owned(truncated) +} + fn render_skill_line(entry: &SkillCatalogEntry, description: &str) -> String { let locator_kind = match &entry.authority.kind { SkillSourceKind::Host => "file", diff --git a/codex-rs/ext/skills/src/tools/list.rs b/codex-rs/ext/skills/src/tools/list.rs index 31cb68003d..e556f9783f 100644 --- a/codex-rs/ext/skills/src/tools/list.rs +++ b/codex-rs/ext/skills/src/tools/list.rs @@ -8,6 +8,7 @@ use serde::Deserialize; use serde::Serialize; use crate::catalog::SkillCatalogEntry; +use crate::render::truncate_catalog_skill_description; use crate::render::truncate_utf8_to_bytes; use super::MAX_HANDLE_BYTES; @@ -95,7 +96,7 @@ fn listed_skill(entry: SkillCatalogEntry) -> Option { authority, package: entry.id.0, name: entry.name, - description: entry.description, + description: truncate_catalog_skill_description(&entry.description).into_owned(), main_resource: entry.main_prompt.as_str().to_string(), }) } diff --git a/codex-rs/ext/skills/tests/skills_extension.rs b/codex-rs/ext/skills/tests/skills_extension.rs index 2d06c33636..8b02558347 100644 --- a/codex-rs/ext/skills/tests/skills_extension.rs +++ b/codex-rs/ext/skills/tests/skills_extension.rs @@ -10,10 +10,14 @@ use codex_core_skills::SKILLS_INTRO_WITH_ABSOLUTE_PATHS; use codex_core_skills::SkillLoadOutcome; use codex_core_skills::SkillMetadata; use codex_core_skills::injection::InjectedHostSkillPrompts; +use codex_extension_api::ConversationHistory; use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionEventSink; use codex_extension_api::ExtensionRegistryBuilder; +use codex_extension_api::NoopTurnItemEmitter; use codex_extension_api::ThreadStartInput; +use codex_extension_api::ToolCall; +use codex_extension_api::ToolPayload; use codex_extension_api::TurnInputContext; use codex_protocol::capabilities::CapabilityRootLocation; use codex_protocol::capabilities::SelectedCapabilityRoot; @@ -23,6 +27,7 @@ use codex_protocol::protocol::SKILLS_INSTRUCTIONS_CLOSE_TAG; use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::TruncationPolicy; use codex_protocol::user_input::UserInput; use codex_skills_extension::SkillProviders; use codex_skills_extension::SkillsExtensionConfig; @@ -255,6 +260,128 @@ async fn selected_executor_catalog_is_context_and_selected_entrypoint_is_turn_in Ok(()) } +#[tokio::test] +async fn default_context_truncates_catalog_descriptions() -> TestResult { + let description = "x".repeat(1_025); + let mut entry = test_entry( + SkillSourceKind::Orchestrator, + "codex_apps", + "orchestrator/long-description", + "skill://orchestrator/long-description/SKILL.md", + ); + entry.description = description.clone(); + let providers = + SkillProviders::new().with_orchestrator_provider(Arc::new(StaticSkillProvider { + catalog: SkillCatalog { + entries: vec![entry], + warnings: Vec::new(), + }, + read_requests: Arc::new(Mutex::new(Vec::new())), + list_calls: None, + fail_first_list: false, + })); + let mut builder = ExtensionRegistryBuilder::new(); + install_with_providers(&mut builder, providers, skills_extension_config); + let registry = builder.build(); + let session_store = ExtensionData::new("session"); + let thread_store = ExtensionData::new("thread"); + let session_source = SessionSource::Cli; + let config = default_config(); + registry.thread_lifecycle_contributors()[0] + .on_thread_start(ThreadStartInput { + config: &config, + session_source: &session_source, + persistent_thread_state_available: true, + environments: &[], + session_store: &session_store, + thread_store: &thread_store, + }) + .await; + + let fragments = registry.context_contributors()[0] + .contribute_thread_context(&session_store, &thread_store) + .await; + assert_eq!(1, fragments.len()); + let rendered = fragments[0].text(); + assert!(rendered.contains(&("x".repeat(1_021) + "..."))); + assert!(!rendered.contains(&"x".repeat(1_024))); + assert!(!rendered.contains(&description)); + + Ok(()) +} + +#[tokio::test] +async fn skills_list_truncates_catalog_descriptions_in_tool_output() -> TestResult { + let description = "x".repeat(1_025); + let mut entry = test_entry( + SkillSourceKind::Orchestrator, + "codex_apps", + "orchestrator/long-description", + "skill://orchestrator/long-description/SKILL.md", + ); + entry.description = description.clone(); + let providers = + SkillProviders::new().with_orchestrator_provider(Arc::new(StaticSkillProvider { + catalog: SkillCatalog { + entries: vec![entry], + warnings: Vec::new(), + }, + read_requests: Arc::new(Mutex::new(Vec::new())), + list_calls: None, + fail_first_list: false, + })); + let mut builder = ExtensionRegistryBuilder::new(); + install_with_providers(&mut builder, providers, skills_extension_config); + let registry = builder.build(); + let session_store = ExtensionData::new("session"); + let thread_store = ExtensionData::new("thread"); + let session_source = SessionSource::Cli; + let config = default_config(); + registry.thread_lifecycle_contributors()[0] + .on_thread_start(ThreadStartInput { + config: &config, + session_source: &session_source, + persistent_thread_state_available: true, + environments: &[], + session_store: &session_store, + thread_store: &thread_store, + }) + .await; + + let tools = registry.tool_contributors()[0].tools(&session_store, &thread_store); + let list_tool = tools + .iter() + .find(|tool| tool.tool_name().name == "list") + .ok_or("skills.list tool should be registered")?; + let payload = ToolPayload::Function { + arguments: serde_json::json!({"authority": {"kind": "orchestrator"}}).to_string(), + }; + let output = list_tool + .handle(ToolCall { + turn_id: "turn-1".to_string(), + call_id: "call-1".to_string(), + tool_name: list_tool.tool_name(), + model: "gpt-test".to_string(), + truncation_policy: TruncationPolicy::Bytes(1_024), + conversation_history: ConversationHistory::default(), + turn_item_emitter: Arc::new(NoopTurnItemEmitter), + environments: Vec::new(), + payload: payload.clone(), + }) + .await?; + let response = output + .post_tool_use_response("call-1", &payload) + .ok_or("skills.list should expose structured output")?; + let rendered_description = response["skills"][0]["description"] + .as_str() + .ok_or("skills.list response should include a description")?; + + assert_eq!(rendered_description, "x".repeat(1_021) + "..."); + assert_ne!(rendered_description, description); + + Ok(()) +} + #[tokio::test] async fn orchestrator_catalog_snapshot_caches_failure() -> TestResult { let list_calls = Arc::new(AtomicUsize::new(0)); diff --git a/codex-rs/external-agent-migration/src/lib.rs b/codex-rs/external-agent-migration/src/lib.rs index 13a6bc63a3..7e5f626d8d 100644 --- a/codex-rs/external-agent-migration/src/lib.rs +++ b/codex-rs/external-agent-migration/src/lib.rs @@ -18,7 +18,6 @@ const EXTERNAL_AGENT_HOOKS_SUBDIR: &str = "hooks"; const EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR: &str = "hooks"; const COMMAND_SKILL_PREFIX: &str = "source-command"; const MAX_SKILL_NAME_LEN: usize = 64; -const MAX_SKILL_DESCRIPTION_LEN: usize = 1024; #[derive(Debug)] struct ParsedDocument { @@ -1130,14 +1129,11 @@ fn command_skill_name_if_supported( return None; } let source_name = command_source_name(source_commands, source_file); - let description = command_skill_description(document, &source_name)?; + command_skill_description(document, &source_name)?; let name = command_skill_name(source_commands, source_file); if name.chars().count() > MAX_SKILL_NAME_LEN { return None; } - if description.chars().count() > MAX_SKILL_DESCRIPTION_LEN { - return None; - } if has_unsupported_command_template_features(&document.body) { return None; } @@ -1388,6 +1384,8 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + const MAX_SKILL_DESCRIPTION_LEN: usize = 1024; + fn source_path(relative_path: &str) -> PathBuf { Path::new("/repo") .join(external_agent_config_dir()) @@ -1722,6 +1720,35 @@ command = "enabled-server" assert!(command_skill_name_if_supported(&root, &file, &document).is_none()); } + #[test] + fn commands_with_overlong_descriptions_are_preserved() { + let root = source_path("commands"); + let file = source_path("commands/review.md"); + let description = "x".repeat(MAX_SKILL_DESCRIPTION_LEN + 1); + let document = + parse_document_content(&format!("---\ndescription: {description}\n---\nReview\n")); + + assert_eq!( + command_skill_name_if_supported(&root, &file, &document), + Some("source-command-review".to_string()) + ); + + let rendered = render_command_skill( + &document.body, + "source-command-review", + &description, + "review", + ); + let rendered_document = parse_document_content(&rendered); + assert_eq!( + rendered_document + .frontmatter + .get("description") + .and_then(FrontmatterValue::as_scalar), + Some(description.as_str()) + ); + } + #[test] fn commands_with_provider_runtime_expansion_are_skipped() { let root = source_path("commands"); From d66708232299bdbf373ec55b0d6b938c246cfa60 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 19 Jun 2026 21:37:01 -0700 Subject: [PATCH 081/112] Allow resume and settings commands during tasks and MCP startup (#29154) ## Why The TUI treats both an active turn and MCP startup as a running task. That currently blocks `/resume` and several settings commands even though they do not compete with turn execution, which is especially frustrating when MCP startup is slow. Model, permissions, personality, and service-tier selections already update thread settings independently of the running turn. Other clients can send those updates mid-turn, while the current turn continues with its captured settings. Allowing the same updates from local slash commands makes the TUI consistent with that existing behavior. ## What changed - Allow `/resume` while a task is running. - Allow `/model`, `/permissions`, `/personality`, and service-tier commands such as `/fast` while a task is running. - Keep the existing behavior where the active turn uses its captured settings and updates apply to subsequent turns. - Exercise the commands under the busy state in the existing TUI tests and retain coverage for commands that should remain blocked. ## Behavior note Turn settings such as model selection and reasoning effort are captured when a turn starts. Changing them during an active turn affects the next turn, not the turn already in progress. The status bar updates immediately, so it may temporarily display the newly selected setting before that setting is actually in effect. ## Verification - Focused `codex-tui` tests for resume dispatch, settings popups, `/fast`, and disabled-command behavior. ## Related issues Closes #19015. Addresses the next-turn-safe model/reasoning switching portion of #14356; dedicated shortcuts and the proposed depth meter remain out of scope. --- codex-rs/tui/src/app/event_dispatch.rs | 10 +++ codex-rs/tui/src/app_event.rs | 5 ++ .../tui/src/bottom_pane/slash_commands.rs | 2 +- codex-rs/tui/src/chatwidget/input_flow.rs | 24 ++++- codex-rs/tui/src/chatwidget/interaction.rs | 5 +- codex-rs/tui/src/chatwidget/slash_dispatch.rs | 14 ++- ...h_command_while_task_running_snapshot.snap | 4 +- .../tui/src/chatwidget/tests/exec_flow.rs | 6 +- .../src/chatwidget/tests/slash_commands.rs | 88 ++++++++++++++----- codex-rs/tui/src/slash_command.rs | 8 +- 10 files changed, 126 insertions(+), 40 deletions(-) diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 16e4aaa627..0ab569b0eb 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -892,6 +892,16 @@ impl App { self.sync_active_thread_personality_setting(app_server, personality) .await; } + AppEvent::SettingsSelectionClosed => { + self.app_event_tx.send(AppEvent::SettingsSelectionSettled); + } + AppEvent::SettingsSelectionSettled => { + if self.chat_widget.no_modal_or_popup_active() { + self.chat_widget + .set_queue_autosend_suppressed(/*suppressed*/ false); + self.chat_widget.maybe_send_next_queued_input(); + } + } AppEvent::OpenReasoningPopup { model } => { self.chat_widget.open_reasoning_popup(model); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 1143848d13..fc018aad87 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -694,6 +694,11 @@ pub(crate) enum AppEvent { /// Update the current personality in the running app and widget. UpdatePersonality(Personality), + /// Finish a settings selection after its preceding update events have been applied. + SettingsSelectionClosed, + /// Run after any nested settings events emitted while handling the close event. + SettingsSelectionSettled, + /// Persist the selected model and reasoning effort to the appropriate config. PersistModelSelection { model: String, diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index e011eb838c..6ca1ba93d1 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -48,7 +48,7 @@ impl SlashCommandItem { pub(crate) fn available_during_task(&self) -> bool { match self { Self::Builtin(cmd) => cmd.available_during_task(), - Self::ServiceTier(_) => false, + Self::ServiceTier(_) => true, } } } diff --git a/codex-rs/tui/src/chatwidget/input_flow.rs b/codex-rs/tui/src/chatwidget/input_flow.rs index b24a4fde27..916c54cd5e 100644 --- a/codex-rs/tui/src/chatwidget/input_flow.rs +++ b/codex-rs/tui/src/chatwidget/input_flow.rs @@ -24,8 +24,9 @@ impl ChatWidget { { return; } - let should_submit_now = - self.is_session_configured() && !self.is_plan_streaming_in_tui(); + let should_submit_now = self.is_session_configured() + && !self.is_plan_streaming_in_tui() + && !self.input_queue.suppress_queue_autosend; if should_submit_now { if self.only_user_shell_commands_running() && !user_message.text.starts_with('!') @@ -69,6 +70,20 @@ impl ChatWidget { self.refresh_plan_mode_nudge(); } + pub(super) fn defer_input_until_settings_applied(&mut self) { + if !self.bottom_pane.no_modal_or_popup_active() { + self.input_queue.suppress_queue_autosend = true; + } + } + + pub(super) fn on_modal_or_popup_closed(&mut self) { + if self.input_queue.suppress_queue_autosend { + self.app_event_tx.send(AppEvent::SettingsSelectionClosed); + } else { + self.maybe_send_next_queued_input(); + } + } + pub(super) fn queue_user_message(&mut self, user_message: UserMessage) { self.queue_user_message_with_options(user_message, QueuedInputAction::Plain, Vec::new()); } @@ -84,7 +99,10 @@ impl ChatWidget { action: QueuedInputAction, pending_pastes: Vec<(String, String)>, ) { - if !self.is_session_configured() || self.is_user_turn_pending_or_running() { + if !self.is_session_configured() + || self.is_user_turn_pending_or_running() + || self.input_queue.suppress_queue_autosend + { self.input_queue .queued_user_messages .push_back(QueuedUserMessage { diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 74fc3d5e52..ba0aeb0a91 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -25,7 +25,7 @@ impl ChatWidget { self.pause_active_goal_for_interrupt(); } if self.bottom_pane.no_modal_or_popup_active() { - self.maybe_send_next_queued_input(); + self.on_modal_or_popup_closed(); } return; } @@ -392,6 +392,9 @@ impl ChatWidget { if should_pause_active_goal { self.pause_active_goal_for_interrupt(); } + if modal_or_popup_active && self.bottom_pane.no_modal_or_popup_active() { + self.on_modal_or_popup_closed(); + } return; } diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 7c1179681e..df9c65f8ce 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -131,6 +131,13 @@ impl ChatWidget { .send(AppEvent::RawOutputModeChanged { enabled }); } + fn slash_command_blocked_by_active_task(&self, cmd: SlashCommand) -> bool { + (!cmd.available_during_task() && self.bottom_pane.is_task_running()) + || (cmd == SlashCommand::Resume + && (self.input_queue.user_turn_pending_start + || self.turn_lifecycle.agent_turn_running)) + } + pub(super) fn dispatch_command(&mut self, cmd: SlashCommand) { if !self.ensure_slash_command_allowed_in_side_conversation(cmd) { return; @@ -138,7 +145,7 @@ impl ChatWidget { if !self.ensure_side_command_allowed_outside_review(cmd) { return; } - if !cmd.available_during_task() && self.bottom_pane.is_task_running() { + if self.slash_command_blocked_by_active_task(cmd) { let message = format!( "'/{}' is disabled while a task is in progress.", cmd.command() @@ -263,9 +270,11 @@ impl ChatWidget { } SlashCommand::Model => { self.open_model_popup(); + self.defer_input_until_settings_applied(); } SlashCommand::Personality => { self.open_personality_popup(); + self.defer_input_until_settings_applied(); } SlashCommand::Plan => { self.apply_plan_slash_command(); @@ -293,6 +302,7 @@ impl ChatWidget { } SlashCommand::Permissions => { self.open_permissions_popup(); + self.defer_input_until_settings_applied(); } SlashCommand::Vim => { self.toggle_vim_mode_and_notify(); @@ -545,7 +555,7 @@ impl ChatWidget { self.dispatch_command(cmd); return; } - if !cmd.available_during_task() && self.bottom_pane.is_task_running() { + if self.slash_command_blocked_by_active_task(cmd) { let message = format!( "'/{}' is disabled while a task is in progress.", cmd.command() diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap index e8f08a437a..4b4a02e0be 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/exec_flow.rs expression: blob --- -■ '/model' is disabled while a task is in progress. +■ '/resume' is disabled while a task is in progress. diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 1b8a7beddb..4f7d225dd8 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -1069,10 +1069,10 @@ async fn user_message_during_user_shell_command_is_queued_not_steered() { async fn disabled_slash_command_while_task_running_snapshot() { // Build a chat widget and simulate an active task let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.bottom_pane.set_task_running(/*running*/ true); + handle_turn_started(&mut chat, "turn-1"); - // Dispatch a command that is unavailable while a task runs (e.g., /model) - chat.dispatch_command(SlashCommand::Model); + // Resume remains available during MCP startup, but not while an agent turn is active. + chat.dispatch_command(SlashCommand::Resume); // Drain history and snapshot the rendered error line(s) let cells = drain_insert_history(&mut rx); diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 73d033de03..d8b3f8a0cd 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -337,8 +337,12 @@ async fn queued_bang_shell_waits_for_user_shell_completion_before_next_input() { assert!(chat.input_queue.queued_user_messages.is_empty()); } -async fn assert_cancelled_queued_menu_drains_next_input(command: &str, expected_popup_text: &str) { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; +async fn assert_cancelled_queued_menu_drains_next_input( + command: &str, + expected_popup_text: &str, + cancel_key: KeyEvent, +) { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; chat.thread_id = Some(ThreadId::new()); handle_turn_started(&mut chat, "turn-1"); @@ -355,7 +359,14 @@ async fn assert_cancelled_queued_menu_drains_next_input(command: &str, expected_ ); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); - chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + chat.handle_key_event(cancel_key); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + assert!( + std::iter::from_fn(|| rx.try_recv().ok()) + .any(|event| matches!(event, AppEvent::SettingsSelectionClosed)) + ); + chat.set_queue_autosend_suppressed(/*suppressed*/ false); + chat.maybe_send_next_queued_input(); match next_submit_op(&mut op_rx) { Op::UserTurn { items, .. } => assert_eq!( @@ -372,39 +383,65 @@ async fn assert_cancelled_queued_menu_drains_next_input(command: &str, expected_ #[tokio::test] async fn queued_slash_menu_cancel_drains_next_input() { - assert_cancelled_queued_menu_drains_next_input("/model", "Select Model").await; - assert_cancelled_queued_menu_drains_next_input("/permissions", "Update Model Permissions") - .await; + assert_cancelled_queued_menu_drains_next_input( + "/model", + "Select Model", + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + ) + .await; + assert_cancelled_queued_menu_drains_next_input( + "/permissions", + "Update Model Permissions", + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ) + .await; } #[tokio::test] -async fn queued_slash_menu_selection_drains_next_input() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; +async fn queued_settings_selection_applies_before_next_input() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; chat.thread_id = Some(ThreadId::new()); + let mut preset = get_available_model(&chat, "gpt-5.4"); + preset.supported_reasoning_efforts.truncate(1); + let selected_effort = preset.supported_reasoning_efforts[0].effort.clone(); + chat.model_catalog = std::sync::Arc::new(ModelCatalog::new(vec![preset])); handle_turn_started(&mut chat, "turn-1"); - queue_composer_text_with_tab(&mut chat, "/permissions"); + queue_composer_text_with_tab(&mut chat, "/model"); queue_composer_text_with_tab(&mut chat, "hello after selection"); complete_turn_with_message(&mut chat, "turn-1", Some("done")); let popup = render_bottom_popup(&chat, /*width*/ 80); assert!( - popup.contains("Update Model Permissions"), - "expected permissions menu to open; popup:\n{popup}" + popup.contains("Select Model and Effort"), + "expected model menu to open; popup:\n{popup}" ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + while let Ok(event) = rx.try_recv() { + match event { + AppEvent::OpenReasoningPopup { model } => chat.open_reasoning_popup(model), + AppEvent::UpdateModel(model) => chat.set_model(&model), + AppEvent::UpdateReasoningEffort(effort) => chat.set_reasoning_effort(effort), + AppEvent::SettingsSelectionClosed => { + chat.app_event_tx.send(AppEvent::SettingsSelectionSettled); + } + AppEvent::SettingsSelectionSettled if chat.no_modal_or_popup_active() => { + chat.set_queue_autosend_suppressed(/*suppressed*/ false); + chat.maybe_send_next_queued_input(); + } + _ => {} + } + } match next_submit_op(&mut op_rx) { - Op::UserTurn { items, .. } => assert_eq!( - items, - vec![UserInput::Text { - text: "hello after selection".to_string(), - text_elements: Vec::new(), - }] + Op::UserTurn { model, effort, .. } => assert_eq!( + (model, effort), + ("gpt-5.4".to_string(), Some(selected_effort)) ), - other => panic!("expected queued message after permissions selection, got {other:?}"), + other => panic!("expected queued message with updated model, got {other:?}"), } assert!(chat.input_queue.queued_user_messages.is_empty()); } @@ -1595,7 +1632,7 @@ async fn unavailable_slash_command_is_available_from_local_recall() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.bottom_pane.set_task_running(/*running*/ true); - submit_composer_text(&mut chat, "/model"); + submit_composer_text(&mut chat, "/review"); let cells = drain_insert_history(&mut rx); let rendered = cells @@ -1604,10 +1641,10 @@ async fn unavailable_slash_command_is_available_from_local_recall() { .collect::>() .join("\n"); assert!( - rendered.contains("'/model' is disabled while a task is in progress."), + rendered.contains("'/review' is disabled while a task is in progress."), "expected disabled-command message, got: {rendered:?}" ); - assert_eq!(recall_latest_after_clearing(&mut chat), "/model"); + assert_eq!(recall_latest_after_clearing(&mut chat), "/review"); } #[tokio::test] @@ -2293,8 +2330,9 @@ async fn slash_memory_update_reports_stubbed_feature() { } #[tokio::test] -async fn slash_resume_opens_picker() { +async fn slash_resume_opens_picker_while_mcp_startup_is_running() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.bottom_pane.set_task_running(/*running*/ true); chat.dispatch_command(SlashCommand::Resume); @@ -2350,8 +2388,9 @@ async fn slash_delete_confirmation_requests_current_thread_delete() { } #[tokio::test] -async fn slash_resume_with_arg_requests_named_session() { +async fn slash_resume_with_arg_requests_named_session_while_mcp_startup_is_running() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.bottom_pane.set_task_running(/*running*/ true); chat.bottom_pane.set_composer_text( "/resume my-saved-thread".to_string(), @@ -2587,8 +2626,9 @@ async fn fast_slash_command_updates_and_persists_local_service_tier() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; set_fast_mode_test_catalog(&mut chat); chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true); + chat.bottom_pane.set_task_running(/*running*/ true); - chat.handle_service_tier_command_dispatch(fast_tier_command()); + submit_composer_text(&mut chat, "/fast"); let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); assert!( diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index e68b3e1cfa..5e3e906bae 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -190,13 +190,9 @@ impl SlashCommand { SlashCommand::New | SlashCommand::Archive | SlashCommand::Delete - | SlashCommand::Resume | SlashCommand::Fork | SlashCommand::Init | SlashCommand::Compact - | SlashCommand::Model - | SlashCommand::Personality - | SlashCommand::Permissions | SlashCommand::Keymap | SlashCommand::Vim | SlashCommand::ElevateSandbox @@ -211,6 +207,10 @@ impl SlashCommand { | SlashCommand::MemoryDrop | SlashCommand::MemoryUpdate => false, SlashCommand::Diff + | SlashCommand::Resume + | SlashCommand::Model + | SlashCommand::Personality + | SlashCommand::Permissions | SlashCommand::Copy | SlashCommand::Raw | SlashCommand::Rename From d1209bddfce79e8f6cafd7f9212a2df6915aec5d Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Sat, 20 Jun 2026 13:15:49 -0700 Subject: [PATCH 082/112] core: add context window lineage IDs (#29256) ## Why The rendered `` fragment identifies the thread and current context window, but it does not expose enough lineage to identify the first window in the thread or the immediately preceding window. Those IDs also need to remain stable across compaction, resume, and rollback. ## What changed - Track first, previous, and current UUIDv7 context-window IDs in auto-compaction state. - Render `thread_id`, `first_window_id`, `previous_window_id`, and the current window ID in the full `` fragment. - Persist the first and previous window IDs in compacted rollout checkpoints and restore them during rollout reconstruction. - Preserve compatibility with older compacted records that do not contain the new optional fields. - Update focused state, rendering, reconstruction, rollback, and serialization coverage. ## Validation - `just test -p codex-core token_budget` - `just test -p codex-protocol compacted_item::tests` - `just test -p codex-core tracks_prefill_and_window_boundaries` - `just test -p codex-core reconstruct_history_uses_replacement_history_verbatim` - `just test -p codex-core thread_rollback_restores_cleared_reference_context_item_after_compaction` --- .../src/protocol/thread_history.rs | 2 + codex-rs/core/src/agent/control_tests.rs | 2 + codex-rs/core/src/compact.rs | 6 +- codex-rs/core/src/compact_remote.rs | 6 +- codex-rs/core/src/compact_remote_v2.rs | 6 +- .../core/src/context/token_budget_context.rs | 18 ++- codex-rs/core/src/session/mod.rs | 34 +++-- .../src/session/rollout_reconstruction.rs | 13 ++ .../session/rollout_reconstruction_tests.rs | 16 +++ codex-rs/core/src/session/tests.rs | 39 +++++- .../core/src/state/auto_compact_window.rs | 65 ++++++--- codex-rs/core/src/state/mod.rs | 1 + codex-rs/core/src/state/session.rs | 20 ++- ..._new_context_window_tool_full_context.snap | 3 +- codex-rs/core/tests/suite/token_budget.rs | 128 ++++++++++-------- codex-rs/protocol/src/compacted_item.rs | 12 ++ codex-rs/protocol/src/protocol.rs | 6 + codex-rs/rollout/src/metadata_tests.rs | 2 + .../thread-store/src/thread_metadata_sync.rs | 2 + 19 files changed, 277 insertions(+), 104 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 30221d9442..0157101cf0 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -3341,6 +3341,8 @@ mod tests { message: String::new(), replacement_history: None, window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 66b5b5c4cb..8dbcab3380 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -1160,6 +1160,8 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() { message: String::new(), replacement_history: Some(replacement_history), window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), RolloutItem::TurnContext(turn_context.to_turn_context_item()), diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 7a2eaf935f..8a5efd5947 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -302,7 +302,7 @@ async fn run_compact_task_inner_impl( let user_messages = collect_user_messages(history_items); let mut new_history = build_compacted_history(Vec::new(), &user_messages, &summary_text); - let (window_number, window_id) = sess.advance_auto_compact_window().await; + let (window_number, window_ids) = sess.advance_auto_compact_window().await; if matches!( initial_context_injection, @@ -320,7 +320,9 @@ async fn run_compact_task_inner_impl( message: summary_text.clone(), replacement_history: Some(new_history.clone()), window_number: Some(window_number), - window_id: Some(window_id), + first_window_id: Some(window_ids.first_window_id.to_string()), + previous_window_id: window_ids.previous_window_id.map(|id| id.to_string()), + window_id: Some(window_ids.window_id.to_string()), }; sess.replace_compacted_history( turn_context.as_ref(), diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 26469c201a..7a47881e47 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -258,7 +258,7 @@ async fn run_remote_compact_task_inner_impl( &responses_metadata, ) .await?; - let (new_window_number, new_window_id) = sess.advance_auto_compact_window().await; + let (new_window_number, new_window_ids) = sess.advance_auto_compact_window().await; new_history = process_compacted_history( sess.as_ref(), turn_context.as_ref(), @@ -275,7 +275,9 @@ async fn run_remote_compact_task_inner_impl( message: String::new(), replacement_history: Some(new_history.clone()), window_number: Some(new_window_number), - window_id: Some(new_window_id), + first_window_id: Some(new_window_ids.first_window_id.to_string()), + previous_window_id: new_window_ids.previous_window_id.map(|id| id.to_string()), + window_id: Some(new_window_ids.window_id.to_string()), }; // Install is the semantic boundary where the compact endpoint's output becomes live // thread history. Keep it distinct from the later inference request so the reducer can diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 6f10a474a1..0b382700ac 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -292,7 +292,7 @@ async fn run_remote_compact_task_inner_impl( let (compacted_history, retained_images) = build_v2_compacted_history(&prompt_input, compaction_output); analytics_details.retained_image_count = Some(retained_images); - let (new_window_number, new_window_id) = sess.advance_auto_compact_window().await; + let (new_window_number, new_window_ids) = sess.advance_auto_compact_window().await; let new_history = process_compacted_history( sess.as_ref(), turn_context.as_ref(), @@ -309,7 +309,9 @@ async fn run_remote_compact_task_inner_impl( message: String::new(), replacement_history: Some(new_history.clone()), window_number: Some(new_window_number), - window_id: Some(new_window_id), + first_window_id: Some(new_window_ids.first_window_id.to_string()), + previous_window_id: new_window_ids.previous_window_id.map(|id| id.to_string()), + window_id: Some(new_window_ids.window_id.to_string()), }; compaction_trace.record_installed(&CompactionCheckpointTracePayload { input_history: &trace_input_history, diff --git a/codex-rs/core/src/context/token_budget_context.rs b/codex-rs/core/src/context/token_budget_context.rs index b21b617355..bac1caafd0 100644 --- a/codex-rs/core/src/context/token_budget_context.rs +++ b/codex-rs/core/src/context/token_budget_context.rs @@ -5,14 +5,24 @@ use uuid::Uuid; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct TokenBudgetContext { thread_id: ThreadId, + first_window_id: Uuid, + previous_window_id: Option, window_id: Uuid, tokens_left: i64, } impl TokenBudgetContext { - pub(crate) fn new(thread_id: ThreadId, window_id: Uuid, tokens_left: i64) -> Self { + pub(crate) fn new( + thread_id: ThreadId, + first_window_id: Uuid, + previous_window_id: Option, + window_id: Uuid, + tokens_left: i64, + ) -> Self { Self { thread_id, + first_window_id, + previous_window_id, window_id, tokens_left, } @@ -34,10 +44,14 @@ impl ContextualUserFragment for TokenBudgetContext { fn body(&self) -> String { let thread_id = self.thread_id; + let first_window_id = self.first_window_id; + let previous_window_id = self + .previous_window_id + .map_or_else(|| "none".to_string(), |window_id| window_id.to_string()); let window_id = self.window_id; let tokens_left = self.tokens_left; format!( - "Thread id {thread_id}.\nCurrent context window id {window_id}.\nYou have {tokens_left} tokens left in this context window." + "Thread id {thread_id}.\nFirst context window id {first_window_id}.\nPrevious context window id {previous_window_id}.\nCurrent context window id {window_id}.\nYou have {tokens_left} tokens left in this context window." ) } } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 0792334b30..3cc746912a 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -311,6 +311,7 @@ use crate::session_startup_prewarm::SessionStartupPrewarmHandle; use crate::shell; #[cfg(test)] use crate::skills::SkillLoadOutcome; +use crate::state::AutoCompactWindowIds; use crate::state::AutoCompactWindowSnapshot; use crate::state::PendingRequestPermissions; use crate::state::SessionServices; @@ -1364,6 +1365,8 @@ impl Session { previous_turn_settings, reference_context_item, window_number, + first_window_id, + previous_window_id, window_id, } = self .reconstruct_history_from_rollout(turn_context, rollout_items) @@ -1382,8 +1385,16 @@ impl Session { { let mut state = self.state.lock().await; state.replace_history(history, reference_context_item); - let window_id = window_id.unwrap_or_else(|| state.auto_compact_window_id()); - state.restore_auto_compact_window(window_number, window_id); + let fallback_ids = state.auto_compact_window_ids(); + let window_id = window_id.unwrap_or(fallback_ids.window_id); + state.restore_auto_compact_window( + window_number, + AutoCompactWindowIds { + first_window_id: first_window_id.unwrap_or(window_id), + previous_window_id, + window_id, + }, + ); state.set_previous_turn_settings(previous_turn_settings.clone()); } let prefix_tokens = if matches!( @@ -3011,7 +3022,7 @@ impl Session { collaboration_mode, base_instructions, session_source, - auto_compact_window_id, + auto_compact_window_ids, ) = { let state = self.state.lock().await; ( @@ -3020,7 +3031,7 @@ impl Session { state.session_configuration.collaboration_mode.clone(), state.session_configuration.base_instructions.clone(), state.session_configuration.session_source.clone(), - state.auto_compact_window_id(), + state.auto_compact_window_ids(), ) }; if let Some(model_switch_message) = @@ -3210,7 +3221,9 @@ impl Session { developer_sections.push( crate::context::TokenBudgetContext::new( self.thread_id(), - auto_compact_window_id, + auto_compact_window_ids.first_window_id, + auto_compact_window_ids.previous_window_id, + auto_compact_window_ids.window_id, model_context_window, ) .render(), @@ -3309,10 +3322,9 @@ impl Session { format!("{thread_id}:{window_number}") } - pub(crate) async fn advance_auto_compact_window(&self) -> (u64, String) { + pub(crate) async fn advance_auto_compact_window(&self) -> (u64, AutoCompactWindowIds) { let mut state = self.state.lock().await; - let (window_number, window_id) = state.advance_auto_compact_window(); - (window_number, window_id.to_string()) + state.advance_auto_compact_window() } pub(crate) async fn request_new_context_window(&self) { @@ -3328,7 +3340,7 @@ impl Session { let mut state = self.state.lock().await; state.start_new_context_window_if_requested() }; - let (window_number, window_id) = window?; + let (window_number, window_ids) = window?; let context_items = self.build_initial_context(turn_context).await; let turn_context_item = turn_context.to_turn_context_item(); let replacement_history = context_items; @@ -3341,7 +3353,9 @@ impl Session { message: String::new(), replacement_history: Some(replacement_history), window_number: Some(window_number), - window_id: Some(window_id.to_string()), + first_window_id: Some(window_ids.first_window_id.to_string()), + previous_window_id: window_ids.previous_window_id.map(|id| id.to_string()), + window_id: Some(window_ids.window_id.to_string()), }), RolloutItem::TurnContext(turn_context_item), ]) diff --git a/codex-rs/core/src/session/rollout_reconstruction.rs b/codex-rs/core/src/session/rollout_reconstruction.rs index 22d0990e32..bf534c1e8a 100644 --- a/codex-rs/core/src/session/rollout_reconstruction.rs +++ b/codex-rs/core/src/session/rollout_reconstruction.rs @@ -10,12 +10,16 @@ pub(super) struct RolloutReconstruction { pub(super) previous_turn_settings: Option, pub(super) reference_context_item: Option, pub(super) window_number: u64, + pub(super) first_window_id: Option, + pub(super) previous_window_id: Option, pub(super) window_id: Option, } #[derive(Debug, Clone, Copy)] struct ReconstructedWindow { number: u64, + first_id: Option, + previous_id: Option, id: Option, } @@ -133,6 +137,11 @@ impl Session { { active_segment.window = Some(ReconstructedWindow { number: window_number, + first_id: compacted.first_window_id.as_deref().and_then(parse_uuid_v7), + previous_id: compacted + .previous_window_id + .as_deref() + .and_then(parse_uuid_v7), id: compacted.window_id.as_deref().and_then(parse_uuid_v7), }); } @@ -341,6 +350,8 @@ impl Session { let window = window.unwrap_or(ReconstructedWindow { number: fallback_window_number, + first_id: None, + previous_id: None, id: None, }); RolloutReconstruction { @@ -348,6 +359,8 @@ impl Session { previous_turn_settings, reference_context_item, window_number: window.number, + first_window_id: window.first_id, + previous_window_id: window.previous_id, window_id: window.id, } } diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 43563d2339..1823b28fdb 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -829,6 +829,8 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp message: String::new(), replacement_history: Some(Vec::new()), window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -887,6 +889,8 @@ async fn record_initial_history_resumed_does_not_seed_reference_context_item_aft message: String::new(), replacement_history: Some(Vec::new()), window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), ]; @@ -914,6 +918,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_does_ message: "legacy summary".to_string(), replacement_history: None, window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), ]; @@ -947,6 +953,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear message: "legacy summary".to_string(), replacement_history: None, window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1043,6 +1051,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis message: String::new(), replacement_history: Some(Vec::new()), window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), RolloutItem::TurnContext(previous_context_item), @@ -1196,6 +1206,8 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu message: String::new(), replacement_history: Some(Vec::new()), window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), ]; @@ -1433,6 +1445,8 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea message: String::new(), replacement_history: Some(Vec::new()), window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), ]; @@ -1599,6 +1613,8 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear message: String::new(), replacement_history: Some(Vec::new()), window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), // A newer TurnStarted replaces the incomplete compacted turn without a matching diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index bca6be6a74..6f84da3c79 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1653,11 +1653,15 @@ async fn reconstruct_history_uses_replacement_history_verbatim() { metadata: None, }, ]; + let first_window_id = Uuid::now_v7(); + let previous_window_id = Uuid::now_v7(); let window_id = Uuid::now_v7(); let rollout_items = vec![RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(replacement_history.clone()), window_number: Some(42), + first_window_id: Some(first_window_id.to_string()), + previous_window_id: Some(previous_window_id.to_string()), window_id: Some(window_id.to_string()), })]; @@ -1667,6 +1671,8 @@ async fn reconstruct_history_uses_replacement_history_verbatim() { assert_eq!(reconstructed.history, replacement_history); assert_eq!(42, reconstructed.window_number); + assert_eq!(Some(first_window_id), reconstructed.first_window_id); + assert_eq!(Some(previous_window_id), reconstructed.previous_window_id); assert_eq!(Some(window_id), reconstructed.window_id); } @@ -3059,6 +3065,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio user_message("turn 1 user"), user_message("summary after compaction"), ]; + let first_window_id = Uuid::now_v7(); + let previous_window_id = Uuid::now_v7(); let compacted_window_id = Uuid::now_v7(); sess.persist_rollout_items(&[ @@ -3102,6 +3110,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio message: "summary after compaction".to_string(), replacement_history: Some(compacted_history.clone()), window_number: Some(7), + first_window_id: Some(first_window_id.to_string()), + previous_window_id: Some(previous_window_id.to_string()), window_id: Some(compacted_window_id.to_string()), }), RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { @@ -3152,7 +3162,14 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio .await; { let mut state = sess.state.lock().await; - state.restore_auto_compact_window(/*window_number*/ 99, Uuid::now_v7()); + state.restore_auto_compact_window( + /*window_number*/ 99, + AutoCompactWindowIds { + first_window_id: Uuid::now_v7(), + previous_window_id: Some(Uuid::now_v7()), + window_id: Uuid::now_v7(), + }, + ); } handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await; @@ -3162,8 +3179,12 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio assert_eq!(sess.clone_history().await.raw_items(), compacted_history); assert!(sess.reference_context_item().await.is_none()); assert_eq!( - sess.state.lock().await.auto_compact_window_id(), - compacted_window_id + sess.state.lock().await.auto_compact_window_ids(), + AutoCompactWindowIds { + first_window_id, + previous_window_id: Some(previous_window_id), + window_id: compacted_window_id, + } ); assert!(sess.current_window_id().await.ends_with(":7")); } @@ -9910,12 +9931,14 @@ async fn sample_rollout( let user_messages1 = collect_user_messages(&snapshot1); let rebuilt1 = compact::build_compacted_history(Vec::new(), &user_messages1, summary1); live_history.replace(rebuilt1); - let (window_number, window_id) = session.advance_auto_compact_window().await; + let (window_number, window_ids) = session.advance_auto_compact_window().await; rollout_items.push(RolloutItem::Compacted(CompactedItem { message: summary1.to_string(), replacement_history: None, window_number: Some(window_number), - window_id: Some(window_id), + first_window_id: Some(window_ids.first_window_id.to_string()), + previous_window_id: window_ids.previous_window_id.map(|id| id.to_string()), + window_id: Some(window_ids.window_id.to_string()), })); let user2 = ResponseItem::Message { @@ -9955,12 +9978,14 @@ async fn sample_rollout( let user_messages2 = collect_user_messages(&snapshot2); let rebuilt2 = compact::build_compacted_history(Vec::new(), &user_messages2, summary2); live_history.replace(rebuilt2); - let (window_number, window_id) = session.advance_auto_compact_window().await; + let (window_number, window_ids) = session.advance_auto_compact_window().await; rollout_items.push(RolloutItem::Compacted(CompactedItem { message: summary2.to_string(), replacement_history: None, window_number: Some(window_number), - window_id: Some(window_id), + first_window_id: Some(window_ids.first_window_id.to_string()), + previous_window_id: window_ids.previous_window_id.map(|id| id.to_string()), + window_id: Some(window_ids.window_id.to_string()), })); let user3 = ResponseItem::Message { diff --git a/codex-rs/core/src/state/auto_compact_window.rs b/codex-rs/core/src/state/auto_compact_window.rs index e1fde27504..ff5d7a2a2f 100644 --- a/codex-rs/core/src/state/auto_compact_window.rs +++ b/codex-rs/core/src/state/auto_compact_window.rs @@ -1,6 +1,13 @@ use codex_protocol::protocol::TokenUsage; use uuid::Uuid; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct AutoCompactWindowIds { + pub(crate) first_window_id: Uuid, + pub(crate) previous_window_id: Option, + pub(crate) window_id: Uuid, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct AutoCompactWindowSnapshot { pub(crate) prefill_input_tokens: Option, @@ -15,7 +22,7 @@ enum AutoCompactWindowPrefill { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) struct AutoCompactWindow { window_number: u64, - window_id: Uuid, + ids: AutoCompactWindowIds, new_context_window_requested: bool, /// Absolute input-token baseline for the current compaction window. /// @@ -27,9 +34,14 @@ pub(super) struct AutoCompactWindow { impl AutoCompactWindow { pub(super) fn new() -> Self { + let window_id = Uuid::now_v7(); Self { window_number: 0, - window_id: Uuid::now_v7(), + ids: AutoCompactWindowIds { + first_window_id: window_id, + previous_window_id: None, + window_id, + }, new_context_window_requested: false, prefill_input_tokens: None, } @@ -43,20 +55,21 @@ impl AutoCompactWindow { self.window_number } - pub(super) fn window_id(&self) -> Uuid { - self.window_id + pub(super) fn ids(&self) -> AutoCompactWindowIds { + self.ids } - pub(super) fn restore(&mut self, window_number: u64, window_id: Uuid) { + pub(super) fn restore(&mut self, window_number: u64, ids: AutoCompactWindowIds) { self.window_number = window_number; - self.window_id = window_id; + self.ids = ids; } - pub(super) fn advance(&mut self) -> (u64, Uuid) { + pub(super) fn advance(&mut self) -> (u64, AutoCompactWindowIds) { self.window_number = self.window_number.saturating_add(1); - self.window_id = Uuid::now_v7(); + self.ids.previous_window_id = Some(self.ids.window_id); + self.ids.window_id = Uuid::now_v7(); self.new_context_window_requested = false; - (self.window_number, self.window_id) + (self.window_number, self.ids) } pub(super) fn request_new_context_window(&mut self) { @@ -118,21 +131,41 @@ mod tests { let mut window = AutoCompactWindow::new(); assert_eq!(window.window_number(), 0); - assert_eq!(window.window_id().get_version_num(), 7); + let initial_window_id = window.ids().window_id; + assert_eq!(initial_window_id.get_version_num(), 7); + assert_eq!( + window.ids(), + AutoCompactWindowIds { + first_window_id: initial_window_id, + previous_window_id: None, + window_id: initial_window_id, + } + ); + let first_window_id = initial_window_id; let restored_window_id = Uuid::now_v7(); - window.restore(/*window_number*/ 3, restored_window_id); + let restored_previous_window_id = Uuid::now_v7(); + window.restore( + /*window_number*/ 3, + AutoCompactWindowIds { + first_window_id, + previous_window_id: Some(restored_previous_window_id), + window_id: restored_window_id, + }, + ); assert_eq!(window.window_number(), 3); - assert_eq!(window.window_id(), restored_window_id); + assert_eq!(window.ids().window_id, restored_window_id); window.request_new_context_window(); assert!(window.take_new_context_window_request()); assert!(!window.take_new_context_window_request()); window.request_new_context_window(); - let (window_number, window_id) = window.advance(); + let (window_number, ids) = window.advance(); assert_eq!(window_number, 4); assert_eq!(window.window_number(), 4); - assert_eq!(window.window_id(), window_id); - assert_eq!(window_id.get_version_num(), 7); - assert_ne!(window_id, restored_window_id); + assert_eq!(window.ids(), ids); + assert_eq!(ids.first_window_id, first_window_id); + assert_eq!(ids.previous_window_id, Some(restored_window_id)); + assert_eq!(ids.window_id.get_version_num(), 7); + assert_ne!(ids.window_id, restored_window_id); assert!(!window.take_new_context_window_request()); assert_eq!( diff --git a/codex-rs/core/src/state/mod.rs b/codex-rs/core/src/state/mod.rs index c693221bed..20ff78b54f 100644 --- a/codex-rs/core/src/state/mod.rs +++ b/codex-rs/core/src/state/mod.rs @@ -5,6 +5,7 @@ mod session; mod turn; pub(crate) use additional_context::AdditionalContextStore; +pub(crate) use auto_compact_window::AutoCompactWindowIds; pub(crate) use auto_compact_window::AutoCompactWindowSnapshot; pub(crate) use service::SessionServices; pub(crate) use session::SessionState; diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 83cef227f8..824f5f6366 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -6,10 +6,10 @@ use codex_sandboxing::policy_transforms::merge_permission_profiles; use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; -use uuid::Uuid; use super::AdditionalContextStore; use super::auto_compact_window::AutoCompactWindow; +use super::auto_compact_window::AutoCompactWindowIds; use super::auto_compact_window::AutoCompactWindowSnapshot; use crate::context_manager::ContextManager; use crate::session::PreviousTurnSettings; @@ -152,15 +152,19 @@ impl SessionState { self.auto_compact_window.window_number() } - pub(crate) fn auto_compact_window_id(&self) -> Uuid { - self.auto_compact_window.window_id() + pub(crate) fn auto_compact_window_ids(&self) -> AutoCompactWindowIds { + self.auto_compact_window.ids() } - pub(crate) fn restore_auto_compact_window(&mut self, window_number: u64, window_id: Uuid) { - self.auto_compact_window.restore(window_number, window_id); + pub(crate) fn restore_auto_compact_window( + &mut self, + window_number: u64, + ids: AutoCompactWindowIds, + ) { + self.auto_compact_window.restore(window_number, ids); } - pub(crate) fn advance_auto_compact_window(&mut self) -> (u64, Uuid) { + pub(crate) fn advance_auto_compact_window(&mut self) -> (u64, AutoCompactWindowIds) { self.auto_compact_window.advance() } @@ -168,7 +172,9 @@ impl SessionState { self.auto_compact_window.request_new_context_window(); } - pub(crate) fn start_new_context_window_if_requested(&mut self) -> Option<(u64, Uuid)> { + pub(crate) fn start_new_context_window_if_requested( + &mut self, + ) -> Option<(u64, AutoCompactWindowIds)> { if !self.auto_compact_window.take_new_context_window_request() { return None; } diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap b/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap index 12bab10ec8..3b933b4ed7 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap @@ -1,5 +1,6 @@ --- source: core/tests/suite/token_budget.rs +assertion_line: 539 expression: snapshot --- Scenario: New context window tool installs fresh full context before the next follow-up request. @@ -8,7 +9,7 @@ Scenario: New context window tool installs fresh full context before the next fo 00:message/developer[3]: [01] [02] - [03] \nThread id .\nCurrent context window id .\nYou have 121600 tokens left in this context window.\n + [03] \nThread id .\nFirst context window id .\nPrevious context window id .\nCurrent context window id .\nYou have 121600 tokens left in this context window.\n 01:message/user:> 02:function_call/update_plan 03:function_call_output:Plan updated diff --git a/codex-rs/core/tests/suite/token_budget.rs b/codex-rs/core/tests/suite/token_budget.rs index db70d3f33c..7c4dfa1bed 100644 --- a/codex-rs/core/tests/suite/token_budget.rs +++ b/codex-rs/core/tests/suite/token_budget.rs @@ -35,6 +35,35 @@ fn token_budget_texts(request: &ResponsesRequest) -> Vec { .collect() } +fn token_budget_window_ids( + text: &str, + thread_id: codex_protocol::ThreadId, + tokens_left: i64, +) -> (String, Option, String) { + let captures = assert_regex_match( + &format!( + r"^\nThread id {thread_id}\.\nFirst context window id ([0-9a-f-]{{36}})\.\nPrevious context window id (none|[0-9a-f-]{{36}})\.\nCurrent context window id ([0-9a-f-]{{36}})\.\nYou have {tokens_left} tokens left in this context window\.\n$" + ), + text, + ); + let first_window_id = captures + .get(1) + .expect("first window id capture") + .as_str() + .to_string(); + let previous_window_id = captures + .get(2) + .expect("previous window id capture") + .as_str(); + let previous_window_id = (previous_window_id != "none").then(|| previous_window_id.to_string()); + let window_id = captures + .get(3) + .expect("window id capture") + .as_str() + .to_string(); + (first_window_id, previous_window_id, window_id) +} + fn tool_names(request: &ResponsesRequest) -> Vec { request .body_json() @@ -83,12 +112,13 @@ async fn token_budget_context_is_only_emitted_with_full_context() -> Result<()> let thread_id = test.session_configured.thread_id; let initial_token_budget = token_budget_texts(&requests[0]); assert_eq!(initial_token_budget.len(), 1); - assert_regex_match( - &format!( - r"^\nThread id {thread_id}\.\nCurrent context window id [0-9a-f-]{{36}}\.\nYou have {EFFECTIVE_CONTEXT_WINDOW} tokens left in this context window\.\n$" - ), + let (first_window_id, previous_window_id, window_id) = token_budget_window_ids( &initial_token_budget[0], + thread_id, + EFFECTIVE_CONTEXT_WINDOW, ); + assert_eq!(previous_window_id, None); + assert_eq!(first_window_id, window_id); assert_eq!( token_budget_texts(&requests[1]), initial_token_budget, @@ -147,12 +177,7 @@ async fn token_budget_remaining_context_emits_on_first_threshold_crossing() -> R let thread_id = test.session_configured.thread_id; let full_context = token_budget_texts(&requests[0]); assert_eq!(full_context.len(), 1); - assert_regex_match( - &format!( - r"^\nThread id {thread_id}\.\nCurrent context window id [0-9a-f-]{{36}}\.\nYou have 9500 tokens left in this context window\.\n$" - ), - &full_context[0], - ); + token_budget_window_ids(&full_context[0], thread_id, /*tokens_left*/ 9_500); let full_context = full_context[0].clone(); let threshold_25 = "\nYou have 7000 tokens left in this context window.\n" @@ -245,12 +270,7 @@ async fn get_context_remaining_returns_token_budget_remaining_fragment() -> Resu .to_string(); let token_budgets = token_budget_texts(&requests[1]); assert_eq!(token_budgets.len(), 2); - assert_regex_match( - &format!( - r"^\nThread id {thread_id}\.\nCurrent context window id [0-9a-f-]{{36}}\.\nYou have 9500 tokens left in this context window\.\n$" - ), - &token_budgets[0], - ); + token_budget_window_ids(&token_budgets[0], thread_id, /*tokens_left*/ 9_500); assert_eq!(token_budgets[1], remaining_context); assert_eq!( requests[2].function_call_output_content_and_success(call_id), @@ -373,28 +393,30 @@ async fn token_budget_context_uses_new_window_after_compaction() -> Result<()> { let thread_id = test.session_configured.thread_id; let initial_token_budget = token_budget_texts(&requests[0]); assert_eq!(initial_token_budget.len(), 1); - let initial_window_id = assert_regex_match( - &format!( - r"^\nThread id {thread_id}\.\nCurrent context window id ([0-9a-f-]{{36}})\.\nYou have {EFFECTIVE_CONTEXT_WINDOW} tokens left in this context window\.\n$" - ), - &initial_token_budget[0], - ) - .get(1) - .expect("window id capture") - .as_str() - .to_string(); + let (initial_first_window_id, initial_previous_window_id, initial_window_id) = + token_budget_window_ids( + &initial_token_budget[0], + thread_id, + EFFECTIVE_CONTEXT_WINDOW, + ); let post_compaction_token_budget = token_budget_texts(&requests[2]); assert_eq!(post_compaction_token_budget.len(), 1); - let post_compaction_window_id = assert_regex_match( - &format!( - r"^\nThread id {thread_id}\.\nCurrent context window id ([0-9a-f-]{{36}})\.\nYou have {EFFECTIVE_CONTEXT_WINDOW} tokens left in this context window\.\n$" - ), + let ( + post_compaction_first_window_id, + post_compaction_previous_window_id, + post_compaction_window_id, + ) = token_budget_window_ids( &post_compaction_token_budget[0], - ) - .get(1) - .expect("window id capture") - .as_str() - .to_string(); + thread_id, + EFFECTIVE_CONTEXT_WINDOW, + ); + assert_eq!(initial_previous_window_id, None); + assert_eq!(initial_first_window_id, initial_window_id); + assert_eq!(post_compaction_first_window_id, initial_first_window_id); + assert_eq!( + post_compaction_previous_window_id.as_deref(), + Some(initial_window_id.as_str()) + ); assert_ne!(post_compaction_window_id, initial_window_id); Ok(()) @@ -458,29 +480,24 @@ async fn new_context_tool_starts_new_window_before_follow_up() -> Result<()> { let thread_id = test.session_configured.thread_id; let initial_token_budget = token_budget_texts(&requests[0]); assert_eq!(initial_token_budget.len(), 1); - let initial_window_id = assert_regex_match( - &format!( - r"^\nThread id {thread_id}\.\nCurrent context window id ([0-9a-f-]{{36}})\.\nYou have {EFFECTIVE_CONTEXT_WINDOW} tokens left in this context window\.\n$" - ), + let (initial_first_window_id, _, initial_window_id) = token_budget_window_ids( &initial_token_budget[0], - ) - .get(1) - .expect("window id capture") - .as_str() - .to_string(); + thread_id, + EFFECTIVE_CONTEXT_WINDOW, + ); let new_window_token_budget = token_budget_texts(&requests[2]); assert_eq!(new_window_token_budget.len(), 1); - let window_id = assert_regex_match( - &format!( - r"^\nThread id {thread_id}\.\nCurrent context window id ([0-9a-f-]{{36}})\.\nYou have {EFFECTIVE_CONTEXT_WINDOW} tokens left in this context window\.\n$" - ), + let (new_first_window_id, new_previous_window_id, new_window_id) = token_budget_window_ids( &new_window_token_budget[0], - ) - .get(1) - .expect("window id capture") - .as_str() - .to_string(); - assert_ne!(window_id, initial_window_id); + thread_id, + EFFECTIVE_CONTEXT_WINDOW, + ); + assert_eq!(new_first_window_id, initial_first_window_id); + assert_eq!( + new_previous_window_id.as_deref(), + Some(initial_window_id.as_str()) + ); + assert_ne!(new_window_id, initial_window_id); assert!( !requests[2].body_contains_text("request new context window"), "new_context should drop the prior window history before continuing the turn" @@ -496,7 +513,8 @@ async fn new_context_tool_starts_new_window_before_follow_up() -> Result<()> { ); let snapshot = snapshot .replace(&thread_id.to_string(), "") - .replace(&window_id, ""); + .replace(&new_first_window_id, "") + .replace(&new_window_id, ""); insta::assert_snapshot!( "token_budget_new_context_window_tool_full_context", snapshot diff --git a/codex-rs/protocol/src/compacted_item.rs b/codex-rs/protocol/src/compacted_item.rs index 3415373898..0f437e0bb6 100644 --- a/codex-rs/protocol/src/compacted_item.rs +++ b/codex-rs/protocol/src/compacted_item.rs @@ -23,6 +23,8 @@ impl<'de> Deserialize<'de> for CompactedItem { message: serialized.message, replacement_history: serialized.replacement_history, window_number, + first_window_id: serialized.first_window_id, + previous_window_id: serialized.previous_window_id, window_id, }) } @@ -36,6 +38,10 @@ struct SerializedCompactedItem { #[serde(default)] window_number: Option, #[serde(default)] + first_window_id: Option, + #[serde(default)] + previous_window_id: Option, + #[serde(default)] window_id: Option, } @@ -59,6 +65,8 @@ mod tests { message: "summary".to_string(), replacement_history: None, window_number: Some(3), + first_window_id: Some("019b3f6e-0000-7000-8000-000000000001".to_string()), + previous_window_id: Some("019b3f6e-0000-7000-8000-000000000002".to_string()), window_id: Some("019b3f6e-7a10-7cc3-8b6e-1d09e2f7a001".to_string()), }; @@ -67,6 +75,8 @@ mod tests { json!({ "message": "summary", "window_number": 3, + "first_window_id": "019b3f6e-0000-7000-8000-000000000001", + "previous_window_id": "019b3f6e-0000-7000-8000-000000000002", "window_id": "019b3f6e-7a10-7cc3-8b6e-1d09e2f7a001", }) ); @@ -86,6 +96,8 @@ mod tests { message: "summary".to_string(), replacement_history: None, window_number: Some(3), + first_window_id: None, + previous_window_id: None, window_id: None, } ); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 1fe732c4ff..081649b7a4 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2996,6 +2996,12 @@ pub struct CompactedItem { /// Monotonic position of this context window within the thread. #[serde(default, skip_serializing_if = "Option::is_none")] pub window_number: Option, + /// UUIDv7 identity of the first context window in this thread's window chain. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub first_window_id: Option, + /// UUIDv7 identity of the context window immediately before this one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub previous_window_id: Option, /// UUIDv7 identity of this context window. #[serde(default, skip_serializing_if = "Option::is_none")] pub window_id: Option, diff --git a/codex-rs/rollout/src/metadata_tests.rs b/codex-rs/rollout/src/metadata_tests.rs index 4467439552..e6771e37c3 100644 --- a/codex-rs/rollout/src/metadata_tests.rs +++ b/codex-rs/rollout/src/metadata_tests.rs @@ -155,6 +155,8 @@ fn builder_from_items_falls_back_to_filename() { message: "noop".to_string(), replacement_history: None, window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, })]; diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index b45bb2d635..0336006fb8 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -484,6 +484,8 @@ mod tests { message: "compacted".to_string(), replacement_history: None, window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }); From b6d6be2a8474836856871e5c58af05653238278a Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Sat, 20 Jun 2026 18:02:02 -0700 Subject: [PATCH 083/112] [codex] prototype mcp_history thread hint injection (#29259) ## Why Prototype whether the harness can invoke the `mcp_history` MCP while constructing full initial context and expose its thread hint to the model without requiring a model-issued tool call. The prototype builds on the context-window lineage added by #29256 and is now based directly on `main`. ## What changed - Call `mcp_history/thread_hint` with no arguments while building the full `` context. - Pass the current `threadId` through MCP request metadata, matching the normal MCP tool-call path. - Serialize only the unstructured `content` result and append it inside `` when the call succeeds. - Omit the additional context when the MCP call or content serialization fails. ## Prototype limitations - The direct call bypasses the normal model-initiated MCP approval, lifecycle-event, telemetry, and result-sanitization path. - The call has no prototype-specific timeout, result-size cap, or per-window cache. - MCP latency is added to full-context construction, including applicable compaction paths. ## Validation - `just test -p codex-core token_budget` --- .../core/src/context/token_budget_context.rs | 14 +- codex-rs/core/src/session/mod.rs | 27 +++- ..._new_context_window_tool_full_context.snap | 4 +- codex-rs/core/tests/suite/token_budget.rs | 130 ++++++++++++++---- .../rmcp-client/src/bin/test_stdio_server.rs | 35 +++++ 5 files changed, 171 insertions(+), 39 deletions(-) diff --git a/codex-rs/core/src/context/token_budget_context.rs b/codex-rs/core/src/context/token_budget_context.rs index bac1caafd0..24e1869e8a 100644 --- a/codex-rs/core/src/context/token_budget_context.rs +++ b/codex-rs/core/src/context/token_budget_context.rs @@ -8,7 +8,7 @@ pub(crate) struct TokenBudgetContext { first_window_id: Uuid, previous_window_id: Option, window_id: Uuid, - tokens_left: i64, + mcp_result: Option, } impl TokenBudgetContext { @@ -17,14 +17,14 @@ impl TokenBudgetContext { first_window_id: Uuid, previous_window_id: Option, window_id: Uuid, - tokens_left: i64, + mcp_result: Option, ) -> Self { Self { thread_id, first_window_id, previous_window_id, window_id, - tokens_left, + mcp_result, } } } @@ -49,9 +49,13 @@ impl ContextualUserFragment for TokenBudgetContext { .previous_window_id .map_or_else(|| "none".to_string(), |window_id| window_id.to_string()); let window_id = self.window_id; - let tokens_left = self.tokens_left; + let mcp_result = self + .mcp_result + .as_deref() + .map(|result| format!("\n{result}")) + .unwrap_or_default(); format!( - "Thread id {thread_id}.\nFirst context window id {first_window_id}.\nPrevious context window id {previous_window_id}.\nCurrent context window id {window_id}.\nYou have {tokens_left} tokens left in this context window." + "Thread id {thread_id}.\nFirst context window id {first_window_id}.\nPrevious context window id {previous_window_id}.\nCurrent context window id {window_id}.{mcp_result}" ) } } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 3cc746912a..c85898555b 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -3216,15 +3216,38 @@ impl Session { } // This is full-context metadata. Steady-state context diffs should not re-emit it. if turn_context.config.features.enabled(Feature::TokenBudget) - && let Some(model_context_window) = turn_context.model_context_window() + && turn_context.model_context_window().is_some() { + let mcp_result = self + .call_tool( + "notes", + "thread_hint", + /*arguments*/ None, + Some(serde_json::json!({ + "threadId": self.thread_id().to_string(), + })), + ) + .await + .ok() + .and_then(|result| { + let text = result + .content + .iter() + .filter_map(|content| { + content.get("text").and_then(serde_json::Value::as_str) + }) + .filter(|text| !text.is_empty()) + .collect::>() + .join("\n"); + (!text.is_empty()).then_some(text) + }); developer_sections.push( crate::context::TokenBudgetContext::new( self.thread_id(), auto_compact_window_ids.first_window_id, auto_compact_window_ids.previous_window_id, auto_compact_window_ids.window_id, - model_context_window, + mcp_result, ) .render(), ); diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap b/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap index 3b933b4ed7..e5dd607170 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap @@ -1,6 +1,6 @@ --- source: core/tests/suite/token_budget.rs -assertion_line: 539 +assertion_line: 588 expression: snapshot --- Scenario: New context window tool installs fresh full context before the next follow-up request. @@ -9,7 +9,7 @@ Scenario: New context window tool installs fresh full context before the next fo 00:message/developer[3]: [01] [02] - [03] \nThread id .\nFirst context window id .\nPrevious context window id .\nCurrent context window id .\nYou have 121600 tokens left in this context window.\n + [03] \nThread id .\nFirst context window id .\nPrevious context window id .\nCurrent context window id .\n 01:message/user:> 02:function_call/update_plan 03:function_call_output:Plan updated diff --git a/codex-rs/core/tests/suite/token_budget.rs b/codex-rs/core/tests/suite/token_budget.rs index 7c4dfa1bed..80f852a33e 100644 --- a/codex-rs/core/tests/suite/token_budget.rs +++ b/codex-rs/core/tests/suite/token_budget.rs @@ -1,4 +1,6 @@ use anyhow::Result; +use codex_config::types::McpServerConfig; +use codex_config::types::McpServerTransportConfig; use codex_features::Feature; use codex_model_provider_info::built_in_model_providers; use codex_protocol::protocol::EventMsg; @@ -17,15 +19,18 @@ use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; +use core_test_support::stdio_server_bin; use core_test_support::test_codex::local; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; +use core_test_support::wait_for_mcp_server; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; +use std::collections::HashMap; +use std::time::Duration; const CONFIGURED_CONTEXT_WINDOW: i64 = 128_000; -const EFFECTIVE_CONTEXT_WINDOW: i64 = CONFIGURED_CONTEXT_WINDOW * 95 / 100; fn token_budget_texts(request: &ResponsesRequest) -> Vec { request @@ -38,11 +43,10 @@ fn token_budget_texts(request: &ResponsesRequest) -> Vec { fn token_budget_window_ids( text: &str, thread_id: codex_protocol::ThreadId, - tokens_left: i64, ) -> (String, Option, String) { let captures = assert_regex_match( &format!( - r"^\nThread id {thread_id}\.\nFirst context window id ([0-9a-f-]{{36}})\.\nPrevious context window id (none|[0-9a-f-]{{36}})\.\nCurrent context window id ([0-9a-f-]{{36}})\.\nYou have {tokens_left} tokens left in this context window\.\n$" + r"^\nThread id {thread_id}\.\nFirst context window id ([0-9a-f-]{{36}})\.\nPrevious context window id (none|[0-9a-f-]{{36}})\.\nCurrent context window id ([0-9a-f-]{{36}})\.\n$" ), text, ); @@ -112,11 +116,8 @@ async fn token_budget_context_is_only_emitted_with_full_context() -> Result<()> let thread_id = test.session_configured.thread_id; let initial_token_budget = token_budget_texts(&requests[0]); assert_eq!(initial_token_budget.len(), 1); - let (first_window_id, previous_window_id, window_id) = token_budget_window_ids( - &initial_token_budget[0], - thread_id, - EFFECTIVE_CONTEXT_WINDOW, - ); + let (first_window_id, previous_window_id, window_id) = + token_budget_window_ids(&initial_token_budget[0], thread_id); assert_eq!(previous_window_id, None); assert_eq!(first_window_id, window_id); assert_eq!( @@ -128,6 +129,89 @@ async fn token_budget_context_is_only_emitted_with_full_context() -> Result<()> Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn token_budget_context_injects_plain_thread_hint_text() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let rmcp_test_server_bin = stdio_server_bin()?; + let test = test_codex() + .with_config(move |config| { + config.model_context_window = Some(CONFIGURED_CONTEXT_WINDOW); + config + .features + .enable(Feature::TokenBudget) + .expect("test config should allow token budget"); + let mut servers = config.mcp_servers.get().clone(); + servers.insert( + "notes".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: rmcp_test_server_bin, + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }, + environment_id: "local".to_string(), + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: Some(Duration::from_secs(10)), + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth: None, + oauth_resource: None, + tools: HashMap::new(), + }, + ); + config + .mcp_servers + .set(servers) + .expect("test mcp servers should accept any configuration"); + }) + .build(&server) + .await?; + wait_for_mcp_server(&test.codex, "notes").await?; + let responses = mount_sse_sequence( + &server, + vec![sse(vec![ + ev_response_created("resp-1"), + ev_completed("resp-1"), + ])], + ) + .await; + + test.submit_turn("inject the history hint").await?; + + let request = responses.single_request(); + let thread_id = test.session_configured.thread_id; + let token_budgets = token_budget_texts(&request); + assert_eq!(token_budgets.len(), 1); + let captures = assert_regex_match( + &format!( + r"^\nThread id {thread_id}\.\nFirst context window id ([0-9a-f-]{{36}})\.\nPrevious context window id none\.\nCurrent context window id ([0-9a-f-]{{36}})\.\nmanual history hint for thread {thread_id}\nunstructured notes/thread_hint fixture result\n$" + ), + &token_budgets[0], + ); + assert_eq!( + captures.get(1).expect("first window id capture").as_str(), + captures.get(2).expect("current window id capture").as_str() + ); + assert!( + !tool_names(&request) + .iter() + .any(|name| name == "mcp__notes__thread_hint"), + "thread_hint should be hidden from model tool exposure" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn token_budget_remaining_context_emits_on_first_threshold_crossing() -> Result<()> { skip_if_no_network!(Ok(())); @@ -177,7 +261,7 @@ async fn token_budget_remaining_context_emits_on_first_threshold_crossing() -> R let thread_id = test.session_configured.thread_id; let full_context = token_budget_texts(&requests[0]); assert_eq!(full_context.len(), 1); - token_budget_window_ids(&full_context[0], thread_id, /*tokens_left*/ 9_500); + token_budget_window_ids(&full_context[0], thread_id); let full_context = full_context[0].clone(); let threshold_25 = "\nYou have 7000 tokens left in this context window.\n" @@ -270,7 +354,7 @@ async fn get_context_remaining_returns_token_budget_remaining_fragment() -> Resu .to_string(); let token_budgets = token_budget_texts(&requests[1]); assert_eq!(token_budgets.len(), 2); - token_budget_window_ids(&token_budgets[0], thread_id, /*tokens_left*/ 9_500); + token_budget_window_ids(&token_budgets[0], thread_id); assert_eq!(token_budgets[1], remaining_context); assert_eq!( requests[2].function_call_output_content_and_success(call_id), @@ -394,22 +478,14 @@ async fn token_budget_context_uses_new_window_after_compaction() -> Result<()> { let initial_token_budget = token_budget_texts(&requests[0]); assert_eq!(initial_token_budget.len(), 1); let (initial_first_window_id, initial_previous_window_id, initial_window_id) = - token_budget_window_ids( - &initial_token_budget[0], - thread_id, - EFFECTIVE_CONTEXT_WINDOW, - ); + token_budget_window_ids(&initial_token_budget[0], thread_id); let post_compaction_token_budget = token_budget_texts(&requests[2]); assert_eq!(post_compaction_token_budget.len(), 1); let ( post_compaction_first_window_id, post_compaction_previous_window_id, post_compaction_window_id, - ) = token_budget_window_ids( - &post_compaction_token_budget[0], - thread_id, - EFFECTIVE_CONTEXT_WINDOW, - ); + ) = token_budget_window_ids(&post_compaction_token_budget[0], thread_id); assert_eq!(initial_previous_window_id, None); assert_eq!(initial_first_window_id, initial_window_id); assert_eq!(post_compaction_first_window_id, initial_first_window_id); @@ -480,18 +556,12 @@ async fn new_context_tool_starts_new_window_before_follow_up() -> Result<()> { let thread_id = test.session_configured.thread_id; let initial_token_budget = token_budget_texts(&requests[0]); assert_eq!(initial_token_budget.len(), 1); - let (initial_first_window_id, _, initial_window_id) = token_budget_window_ids( - &initial_token_budget[0], - thread_id, - EFFECTIVE_CONTEXT_WINDOW, - ); + let (initial_first_window_id, _, initial_window_id) = + token_budget_window_ids(&initial_token_budget[0], thread_id); let new_window_token_budget = token_budget_texts(&requests[2]); assert_eq!(new_window_token_budget.len(), 1); - let (new_first_window_id, new_previous_window_id, new_window_id) = token_budget_window_ids( - &new_window_token_budget[0], - thread_id, - EFFECTIVE_CONTEXT_WINDOW, - ); + let (new_first_window_id, new_previous_window_id, new_window_id) = + token_budget_window_ids(&new_window_token_budget[0], thread_id); assert_eq!(new_first_window_id, initial_first_window_id); assert_eq!( new_previous_window_id.as_deref(), diff --git a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs index 9ff36e349d..722f5f7d78 100644 --- a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs @@ -19,6 +19,7 @@ use rmcp::model::JsonObject; use rmcp::model::ListResourceTemplatesResult; use rmcp::model::ListResourcesResult; use rmcp::model::ListToolsResult; +use rmcp::model::Meta; use rmcp::model::PaginatedRequestParams; use rmcp::model::RawResource; use rmcp::model::RawResourceTemplate; @@ -70,9 +71,27 @@ impl TestToolServer { ); sandbox_meta_tool.annotations = Some(ToolAnnotations::new().read_only(true)); + #[expect(clippy::expect_used)] + let thread_hint_schema: JsonObject = serde_json::from_value(json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + })) + .expect("thread_hint tool schema should deserialize"); + let mut thread_hint_tool = Tool::new( + Cow::Borrowed("thread_hint"), + Cow::Borrowed("Return an unstructured history hint for a thread."), + Arc::new(thread_hint_schema), + ); + thread_hint_tool.annotations = Some(ToolAnnotations::new().read_only(true)); + let mut thread_hint_meta = Meta::new(); + thread_hint_meta.insert("ui".to_string(), json!({ "visibility": [] })); + thread_hint_tool.meta = Some(thread_hint_meta); + let tools = vec![ Self::echo_tool(), Self::echo_dash_tool(), + thread_hint_tool, Self::client_capabilities_tool(), Self::cwd_tool(), Self::sync_tool(), @@ -537,6 +556,22 @@ impl ServerHandler for TestToolServer { .map_err(|err| McpError::internal_error(err.to_string(), None))?; Ok(Self::structured_result(json!({ "cwd": cwd }))) } + "thread_hint" => { + let thread_id = context + .meta + .0 + .get("threadId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + McpError::invalid_params("missing threadId metadata".to_string(), None) + })?; + Ok(CallToolResult::success(vec![ + rmcp::model::Content::text(format!( + "manual history hint for thread {thread_id}" + )), + rmcp::model::Content::text("unstructured notes/thread_hint fixture result"), + ])) + } "echo" | "echo-tool" => { let args: EchoArgs = match request.arguments { Some(arguments) => serde_json::from_value(serde_json::Value::Object( From 6df037d47ff9359dbe1e471f5eb3f7e6ff1fe421 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Sat, 20 Jun 2026 19:13:42 -0700 Subject: [PATCH 084/112] [codex] add configurable token budget compaction reminder (#29255) ## Why The token-budget feature reports coarse remaining-context milestones, but it does not give the model a configurable wrap-up prompt before automatic compaction. A strict threshold-crossing check can also miss resumed or reconfigured windows that are already inside the threshold. ## What changed - Add structured `[features.token_budget]` configuration for an absolute `reminder_threshold_tokens` and bounded `reminder_message_template`; `{n_remaining}` is expanded when the reminder is delivered. - Compute remaining tokens against the next effective auto-compaction boundary, including scoped `body_after_prefix` accounting and the full context-window limit. - Make reminder delivery level-triggered before and after sampling, with one-shot state owned by `AutoCompactWindow` and re-armed on compaction, `new_context`, restore, or history replacement. - Leave the existing initial full-window token-budget context, 25/50/75% notices, and token-budget tools unchanged. - Persist the resolved feature configuration in the session config lock and regenerate the config schema. ## Validation - `just test -p codex-core token_budget` - `just test -p codex-core token_budget_reminder_emits_after_crossing_compaction_threshold` - `just test -p codex-core auto_compact_window` - `just test -p codex-core lock_contains_prompts_and_materializes_features` - `just test -p codex-features` - `just test -p codex-config` --- codex-rs/config/src/schema.rs | 9 ++ codex-rs/core/config.schema.json | 35 +++++++- codex-rs/core/src/config/config_tests.rs | 84 +++++++++++++++++++ codex-rs/core/src/config/mod.rs | 75 +++++++++++++++++ codex-rs/core/src/context/mod.rs | 1 + .../core/src/context/token_budget_context.rs | 31 +++++++ codex-rs/core/src/session/config_lock.rs | 26 ++++++ codex-rs/core/src/session/token_budget.rs | 69 +++++++++------ codex-rs/core/src/session/turn.rs | 13 ++- .../core/src/state/auto_compact_window.rs | 10 +++ codex-rs/core/src/state/session.rs | 4 + codex-rs/core/tests/suite/token_budget.rs | 52 ++++++++++++ codex-rs/features/src/feature_configs.rs | 26 ++++++ codex-rs/features/src/lib.rs | 9 ++ codex-rs/thread-manager-sample/src/main.rs | 1 + 15 files changed, 418 insertions(+), 27 deletions(-) diff --git a/codex-rs/config/src/schema.rs b/codex-rs/config/src/schema.rs index fad074e9c3..8547269ce2 100644 --- a/codex-rs/config/src/schema.rs +++ b/codex-rs/config/src/schema.rs @@ -44,6 +44,15 @@ pub fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema { ); continue; } + if feature.id == codex_features::Feature::TokenBudget { + validation.properties.insert( + feature.key.to_string(), + schema_gen.subschema_for::>(), + ); + continue; + } if feature.id == codex_features::Feature::RolloutBudget { validation.properties.insert( feature.key.to_string(), diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 828ec69f01..2655f2e743 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -669,7 +669,7 @@ "type": "boolean" }, "token_budget": { - "type": "boolean" + "$ref": "#/definitions/FeatureToml_for_TokenBudgetConfigToml" }, "tool_call_mcp_elicitation": { "type": "boolean" @@ -967,6 +967,16 @@ } ] }, + "FeatureToml_for_TokenBudgetConfigToml": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/TokenBudgetConfigToml" + } + ] + }, "FeedbackConfigToml": { "additionalProperties": false, "properties": { @@ -2836,6 +2846,27 @@ } ] }, + "TokenBudgetConfigToml": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "reminder_message_template": { + "description": "Reminder template. `{n_remaining}` is replaced with the tokens remaining before auto-compaction.", + "maxLength": 1000, + "minLength": 1, + "type": "string" + }, + "reminder_threshold_tokens": { + "description": "Number of tokens remaining before auto-compaction when the wrap-up reminder is emitted.", + "format": "int64", + "minimum": 1.0, + "type": "integer" + } + }, + "type": "object" + }, "ToolSuggestConfig": { "additionalProperties": false, "properties": { @@ -4932,7 +4963,7 @@ "type": "boolean" }, "token_budget": { - "type": "boolean" + "$ref": "#/definitions/FeatureToml_for_TokenBudgetConfigToml" }, "tool_call_mcp_elicitation": { "type": "boolean" diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 904018ebc6..ce93740166 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -460,6 +460,90 @@ direct_only_tool_namespaces = ["mcp__history", "mcp__notes"] Ok(()) } +#[tokio::test] +async fn load_config_resolves_token_budget_config() -> std::io::Result<()> { + for (config_toml, expected) in [ + ( + "[features]\ntoken_budget = true\n", + TokenBudgetConfig::default(), + ), + ( + r#" +[features.token_budget] +enabled = true +reminder_threshold_tokens = 16000 +reminder_message_template = "Custom reminder: {n_remaining} tokens." +"#, + TokenBudgetConfig { + reminder_threshold_tokens: Some(16_000), + reminder_message_template: "Custom reminder: {n_remaining} tokens.".to_string(), + }, + ), + ] { + let codex_home = tempdir()?; + let config_toml = toml::from_str(config_toml).expect("TOML should deserialize"); + let config = Config::load_from_base_config_with_overrides( + config_toml, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert!(config.features.enabled(Feature::TokenBudget)); + assert_eq!(config.token_budget, Some(expected)); + } + Ok(()) +} + +#[tokio::test] +async fn load_config_rejects_invalid_token_budget_reminder_template() -> std::io::Result<()> { + for reminder_message_template in [ + String::new(), + "x".repeat(TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE_MAX_BYTES + 1), + ] { + let codex_home = tempdir()?; + let config_toml = toml::from_str(&format!( + "[features.token_budget]\nenabled = true\nreminder_message_template = {reminder_message_template:?}\n" + )) + .expect("TOML should deserialize"); + let error = Config::load_from_base_config_with_overrides( + config_toml, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await + .expect_err("invalid reminder template should be rejected"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + } + Ok(()) +} + +#[tokio::test] +async fn load_config_rejects_non_positive_token_budget_reminder_threshold() -> std::io::Result<()> { + for reminder_threshold_tokens in [-1, 0] { + let codex_home = tempdir()?; + let config_toml = toml::from_str(&format!( + "[features.token_budget]\nenabled = true\nreminder_threshold_tokens = {reminder_threshold_tokens}\n" + )) + .expect("TOML should deserialize"); + let error = Config::load_from_base_config_with_overrides( + config_toml, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await + .expect_err("non-positive reminder threshold should be rejected"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + error.to_string(), + "features.token_budget.reminder_threshold_tokens must be positive" + ); + } + Ok(()) +} + #[tokio::test] async fn load_config_resolves_rollout_budget() -> std::io::Result<()> { let codex_home = tempdir()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 8e9ccc6c24..d5986501a4 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -68,6 +68,7 @@ use codex_features::Features; use codex_features::FeaturesToml; use codex_features::MultiAgentV2ConfigToml; use codex_features::NetworkProxyConfigToml; +use codex_features::TokenBudgetConfigToml; use codex_git_utils::resolve_root_git_project_for_trust; use codex_install_context::InstallContext; use codex_login::AuthManagerConfig; @@ -1026,6 +1027,8 @@ pub struct Config { /// Settings specific to the task-path-based multi-agent tool surface. pub multi_agent_v2: MultiAgentV2Config, + /// Context-window token budget configuration, when enabled. + pub token_budget: Option, /// Shared token budget for the root thread and its sub-agents. pub rollout_budget: Option, /// Current-time reminder configuration, when enabled. @@ -1075,6 +1078,27 @@ pub struct CodeModeConfig { pub direct_only_tool_namespaces: Vec, } +pub(crate) const DEFAULT_TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE: &str = concat!( + "Your context window is nearly exhausted (only {n_remaining} tokens remaining) and will be automatically reset for you soon. ", + "Once reset, message items in current context window will be cleared in the new window, but notes and history items will be persistent across windows." +); +const TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE_MAX_BYTES: usize = 1000; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TokenBudgetConfig { + pub reminder_threshold_tokens: Option, + pub reminder_message_template: String, +} + +impl Default for TokenBudgetConfig { + fn default() -> Self { + Self { + reminder_threshold_tokens: None, + reminder_message_template: DEFAULT_TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE.to_string(), + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Serialize)] pub struct RolloutBudgetConfig { pub limit_tokens: i64, @@ -2509,6 +2533,48 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config } } +fn resolve_token_budget_config( + config_toml: &ConfigToml, + features: &ManagedFeatures, +) -> std::io::Result> { + if !features.enabled(Feature::TokenBudget) { + return Ok(None); + } + + let token_budget_config = token_budget_toml_config(config_toml.features.as_ref()); + let reminder_threshold_tokens = + token_budget_config.and_then(|config| config.reminder_threshold_tokens); + if reminder_threshold_tokens.is_some_and(|tokens| tokens <= 0) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "features.token_budget.reminder_threshold_tokens must be positive", + )); + } + + let reminder_message_template = token_budget_config + .and_then(|config| config.reminder_message_template.clone()) + .unwrap_or_else(|| DEFAULT_TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE.to_string()); + if reminder_message_template.trim().is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "features.token_budget.reminder_message_template must not be empty", + )); + } + if reminder_message_template.len() > TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE_MAX_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "features.token_budget.reminder_message_template must not exceed {TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE_MAX_BYTES} bytes" + ), + )); + } + + Ok(Some(TokenBudgetConfig { + reminder_threshold_tokens, + reminder_message_template, + })) +} + fn resolve_rollout_budget_config( config_toml: &ConfigToml, features: &ManagedFeatures, @@ -2635,6 +2701,13 @@ fn multi_agent_v2_toml_config(features: Option<&FeaturesToml>) -> Option<&MultiA } } +fn token_budget_toml_config(features: Option<&FeaturesToml>) -> Option<&TokenBudgetConfigToml> { + match features?.token_budget.as_ref()? { + FeatureToml::Enabled(_) => None, + FeatureToml::Config(config) => Some(config), + } +} + fn current_time_reminder_toml_config( features: Option<&FeaturesToml>, ) -> Option<&CurrentTimeReminderConfigToml> { @@ -3287,6 +3360,7 @@ impl Config { resolve_experimental_request_user_input_enabled(&cfg); let code_mode = resolve_code_mode_config(&cfg); let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg); + let token_budget = resolve_token_budget_config(&cfg, &features)?; let rollout_budget = resolve_rollout_budget_config(&cfg, &features)?; let current_time_reminder = resolve_current_time_reminder_config(&cfg, &features)?; let terminal_resize_reflow = resolve_terminal_resize_reflow_config(&cfg); @@ -3830,6 +3904,7 @@ impl Config { background_terminal_max_timeout, ghost_snapshot, multi_agent_v2, + token_budget, rollout_budget, current_time_reminder, features, diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index 3347358bb5..c9319ebef0 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -73,6 +73,7 @@ pub(crate) use rollout_budget::RolloutBudgetContext; pub(crate) use subagent_notification::SubagentNotification; pub(crate) use token_budget_context::TokenBudgetContext; pub(crate) use token_budget_context::TokenBudgetRemainingContext; +pub(crate) use token_budget_context::TokenBudgetReminder; pub(crate) use turn_aborted::TurnAborted; pub(crate) use user_instructions::UserInstructions; pub(crate) use user_shell_command::UserShellCommand; diff --git a/codex-rs/core/src/context/token_budget_context.rs b/codex-rs/core/src/context/token_budget_context.rs index 24e1869e8a..93d5e1e6ae 100644 --- a/codex-rs/core/src/context/token_budget_context.rs +++ b/codex-rs/core/src/context/token_budget_context.rs @@ -99,3 +99,34 @@ impl ContextualUserFragment for TokenBudgetRemainingContext { } } } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TokenBudgetReminder { + message: String, +} + +impl TokenBudgetReminder { + pub(crate) fn new(message_template: &str, n_remaining: i64) -> Self { + Self { + message: message_template.replace("{n_remaining}", &n_remaining.to_string()), + } + } +} + +impl ContextualUserFragment for TokenBudgetReminder { + fn role(&self) -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("\n", "\n") + } + + fn body(&self) -> String { + self.message.clone() + } +} diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index 79afd11515..2553f71504 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -10,6 +10,7 @@ use codex_features::FeatureToml; use codex_features::FeaturesToml; use codex_features::MultiAgentV2ConfigToml; use codex_features::RolloutBudgetConfigToml; +use codex_features::TokenBudgetConfigToml; use codex_protocol::ThreadId; use crate::config::Config; @@ -152,6 +153,12 @@ fn save_config_resolved_fields( resolved_config_to_toml(&config.multi_agent_v2, "features.multi_agent_v2")?; multi_agent_v2.enabled = Some(config.features.enabled(Feature::MultiAgentV2)); features.multi_agent_v2 = Some(FeatureToml::Config(multi_agent_v2)); + if let Some(token_budget) = config.token_budget.as_ref() { + let mut token_budget: TokenBudgetConfigToml = + resolved_config_to_toml(token_budget, "features.token_budget")?; + token_budget.enabled = Some(config.features.enabled(Feature::TokenBudget)); + features.token_budget = Some(FeatureToml::Config(token_budget)); + } if let Some(rollout_budget) = config.rollout_budget.as_ref() { let mut rollout_budget: RolloutBudgetConfigToml = resolved_config_to_toml(rollout_budget, "features.rollout_budget")?; @@ -238,6 +245,14 @@ mod tests { async fn lock_contains_prompts_and_materializes_features() { let mut sc = crate::session::tests::make_session_configuration_for_tests().await; let mut config = (*sc.original_config_do_not_use).clone(); + config.token_budget = Some(crate::config::TokenBudgetConfig { + reminder_threshold_tokens: Some(16_000), + reminder_message_template: "Locked reminder: {n_remaining} tokens.".to_string(), + }); + config + .features + .enable(Feature::TokenBudget) + .expect("token_budget should be enableable in tests"); config.rollout_budget = Some(crate::config::RolloutBudgetConfig { limit_tokens: 100_000, reminder_interval_tokens: 10_000, @@ -318,6 +333,17 @@ mod tests { }) )); + assert_eq!( + features.token_budget, + Some(FeatureToml::Config(TokenBudgetConfigToml { + enabled: Some(true), + reminder_threshold_tokens: Some(16_000), + reminder_message_template: Some( + "Locked reminder: {n_remaining} tokens.".to_string() + ), + })) + ); + assert_eq!( features.rollout_budget, Some(FeatureToml::Config(RolloutBudgetConfigToml { diff --git a/codex-rs/core/src/session/token_budget.rs b/codex-rs/core/src/session/token_budget.rs index 4548ed3198..017b7d542f 100644 --- a/codex-rs/core/src/session/token_budget.rs +++ b/codex-rs/core/src/session/token_budget.rs @@ -5,40 +5,61 @@ use codex_features::Feature; const TOKEN_BUDGET_USAGE_THRESHOLDS: [i64; 3] = [25, 50, 75]; -pub(super) async fn maybe_record_token_budget_remaining_context( +pub(super) async fn maybe_record( sess: &Session, turn_context: &TurnContext, tokens_before_sampling: i64, tokens_after_sampling: i64, + tokens_until_compaction: i64, ) { if !turn_context.config.features.enabled(Feature::TokenBudget) { return; } - let Some(model_context_window) = turn_context.model_context_window() else { - return; - }; - if model_context_window <= 0 || tokens_after_sampling <= tokens_before_sampling { - return; + + let mut response_items = Vec::new(); + if let Some(model_context_window) = turn_context.model_context_window() + && model_context_window > 0 + && tokens_after_sampling > tokens_before_sampling + { + let tokens_before_sampling = tokens_before_sampling.max(0); + let tokens_after_sampling = tokens_after_sampling.max(0); + let crossed_threshold = TOKEN_BUDGET_USAGE_THRESHOLDS.iter().any(|threshold| { + tokens_before_sampling.saturating_mul(100) + < model_context_window.saturating_mul(*threshold) + && tokens_after_sampling.saturating_mul(100) + >= model_context_window.saturating_mul(*threshold) + }); + if crossed_threshold { + let tokens_left = model_context_window + .saturating_sub(tokens_after_sampling) + .max(0); + response_items.push(ContextualUserFragment::into( + crate::context::TokenBudgetRemainingContext::new(tokens_left), + )); + } } - let tokens_before_sampling = tokens_before_sampling.max(0); - let tokens_after_sampling = tokens_after_sampling.max(0); - let crossed_threshold = TOKEN_BUDGET_USAGE_THRESHOLDS.iter().any(|threshold| { - tokens_before_sampling.saturating_mul(100) < model_context_window.saturating_mul(*threshold) - && tokens_after_sampling.saturating_mul(100) - >= model_context_window.saturating_mul(*threshold) - }); - if !crossed_threshold { - return; + if let Some(config) = turn_context.config.token_budget.as_ref().filter(|config| { + config + .reminder_threshold_tokens + .is_some_and(|threshold| tokens_until_compaction <= threshold) + }) { + let reminder_due = { + let mut state = sess.state.lock().await; + state.claim_token_budget_reminder() + }; + if reminder_due { + response_items.push(ContextualUserFragment::into( + crate::context::TokenBudgetReminder::new( + &config.reminder_message_template, + tokens_until_compaction, + ), + )); + } } - let tokens_left = model_context_window - .saturating_sub(tokens_after_sampling) - .max(0); - - let response_item = ContextualUserFragment::into( - crate::context::TokenBudgetRemainingContext::new(tokens_left), - ); - sess.record_conversation_items(turn_context, std::slice::from_ref(&response_item)) - .await; + if !response_items.is_empty() { + sess.record_conversation_items(turn_context, &response_items) + .await; + } } diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 28c0f65612..df74841b19 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -313,11 +313,22 @@ pub(crate) async fn run_turn( ); let tokens_after_sampling = token_status.active_context_tokens; - super::token_budget::maybe_record_token_budget_remaining_context( + let full_context_remaining = token_status + .full_context_window_limit + .map_or(i64::MAX, |limit| { + limit.saturating_sub(tokens_after_sampling) + }); + let tokens_until_compaction = token_status + .auto_compact_scope_limit + .saturating_sub(token_status.auto_compact_scope_tokens) + .min(full_context_remaining) + .max(0); + super::token_budget::maybe_record( sess.as_ref(), turn_context.as_ref(), tokens_before_sampling, tokens_after_sampling, + tokens_until_compaction, ) .await; diff --git a/codex-rs/core/src/state/auto_compact_window.rs b/codex-rs/core/src/state/auto_compact_window.rs index ff5d7a2a2f..419e85be04 100644 --- a/codex-rs/core/src/state/auto_compact_window.rs +++ b/codex-rs/core/src/state/auto_compact_window.rs @@ -30,6 +30,7 @@ pub(super) struct AutoCompactWindow { /// not the growth itself; server-observed usage replaces estimated /// resume/recompute baselines when available. prefill_input_tokens: Option, + token_budget_reminder_delivered: bool, } impl AutoCompactWindow { @@ -44,6 +45,7 @@ impl AutoCompactWindow { }, new_context_window_requested: false, prefill_input_tokens: None, + token_budget_reminder_delivered: false, } } @@ -69,9 +71,14 @@ impl AutoCompactWindow { self.ids.previous_window_id = Some(self.ids.window_id); self.ids.window_id = Uuid::now_v7(); self.new_context_window_requested = false; + self.token_budget_reminder_delivered = false; (self.window_number, self.ids) } + pub(super) fn claim_token_budget_reminder(&mut self) -> bool { + !std::mem::replace(&mut self.token_budget_reminder_delivered, true) + } + pub(super) fn request_new_context_window(&mut self) { self.new_context_window_requested = true; } @@ -154,6 +161,8 @@ mod tests { ); assert_eq!(window.window_number(), 3); assert_eq!(window.ids().window_id, restored_window_id); + assert!(window.claim_token_budget_reminder()); + assert!(!window.claim_token_budget_reminder()); window.request_new_context_window(); assert!(window.take_new_context_window_request()); assert!(!window.take_new_context_window_request()); @@ -167,6 +176,7 @@ mod tests { assert_eq!(ids.window_id.get_version_num(), 7); assert_ne!(ids.window_id, restored_window_id); assert!(!window.take_new_context_window_request()); + assert!(window.claim_token_budget_reminder()); assert_eq!( window.snapshot(), diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 824f5f6366..6444dc8cf3 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -148,6 +148,10 @@ impl SessionState { self.auto_compact_window.snapshot() } + pub(crate) fn claim_token_budget_reminder(&mut self) -> bool { + self.auto_compact_window.claim_token_budget_reminder() + } + pub(crate) fn auto_compact_window_number(&self) -> u64 { self.auto_compact_window.window_number() } diff --git a/codex-rs/core/tests/suite/token_budget.rs b/codex-rs/core/tests/suite/token_budget.rs index 80f852a33e..a1f8fb5de2 100644 --- a/codex-rs/core/tests/suite/token_budget.rs +++ b/codex-rs/core/tests/suite/token_budget.rs @@ -1,6 +1,7 @@ use anyhow::Result; use codex_config::types::McpServerConfig; use codex_config::types::McpServerTransportConfig; +use codex_core::config::TokenBudgetConfig; use codex_features::Feature; use codex_model_provider_info::built_in_model_providers; use codex_protocol::protocol::EventMsg; @@ -298,6 +299,57 @@ async fn token_budget_remaining_context_emits_on_first_threshold_crossing() -> R Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn token_budget_reminder_emits_after_crossing_compaction_threshold() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_completed_with_tokens("resp-1", /*total_tokens*/ 8_000), + ]), + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ], + ) + .await; + let test = test_codex() + .with_config(|config| { + config.model_context_window = Some(10_000); + config.token_budget = Some(TokenBudgetConfig { + reminder_threshold_tokens: Some(2_000), + ..TokenBudgetConfig::default() + }); + config + .features + .enable(Feature::TokenBudget) + .expect("test config should allow token budget"); + }) + .build(&server) + .await?; + + test.submit_turn("cross threshold").await?; + test.submit_turn("observe reminder").await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + let initial_context = token_budget_texts(&requests[0]); + assert_eq!(initial_context.len(), 1); + let remaining_context = + "\nYou have 1500 tokens left in this context window.\n" + .to_string(); + let reminder = "\nYour context window is nearly exhausted (only 1000 tokens remaining) and will be automatically reset for you soon. Once reset, message items in current context window will be cleared in the new window, but notes and history items will be persistent across windows.\n" + .to_string(); + assert_eq!( + token_budget_texts(&requests[1]), + vec![initial_context[0].clone(), remaining_context, reminder] + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_context_remaining_returns_token_budget_remaining_fragment() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index dc78d8cd43..15a37fe36a 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -73,6 +73,32 @@ impl FeatureConfig for MultiAgentV2ConfigToml { } } +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TokenBudgetConfigToml { + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + /// Number of tokens remaining before auto-compaction when the wrap-up reminder is emitted. + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1))] + pub reminder_threshold_tokens: Option, + /// Reminder template. `{n_remaining}` is replaced with the tokens remaining before + /// auto-compaction. + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1, max = 1000))] + pub reminder_message_template: Option, +} + +impl FeatureConfig for TokenBudgetConfigToml { + fn enabled(&self) -> Option { + self.enabled + } + + fn set_enabled(&mut self, enabled: bool) { + self.enabled = Some(enabled); + } +} + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct RolloutBudgetConfigToml { diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 3bba6faf98..f2a30b07b6 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -26,6 +26,7 @@ pub use feature_configs::NetworkProxyModeToml; pub use feature_configs::NetworkProxyUnixSocketPermissionToml; use feature_configs::RemovedAppsMcpPathOverrideConfigToml; pub use feature_configs::RolloutBudgetConfigToml; +pub use feature_configs::TokenBudgetConfigToml; use legacy::LegacyFeatureToggles; pub use legacy::legacy_feature_keys; @@ -632,6 +633,8 @@ pub struct FeaturesToml { #[serde(default, skip_serializing_if = "Option::is_none")] pub multi_agent_v2: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] + pub token_budget: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] pub rollout_budget: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub current_time_reminder: Option>, @@ -667,6 +670,9 @@ impl FeaturesToml { if let Some(enabled) = self.multi_agent_v2.as_ref().and_then(FeatureToml::enabled) { entries.insert(Feature::MultiAgentV2.key().to_string(), enabled); } + if let Some(enabled) = self.token_budget.as_ref().and_then(FeatureToml::enabled) { + entries.insert(Feature::TokenBudget.key().to_string(), enabled); + } if let Some(enabled) = self.rollout_budget.as_ref().and_then(FeatureToml::enabled) { entries.insert(Feature::RolloutBudget.key().to_string(), enabled); } @@ -688,6 +694,7 @@ impl FeaturesToml { let Self { code_mode, multi_agent_v2, + token_budget, rollout_budget, current_time_reminder, removed_apps_mcp_path_override: _, @@ -703,6 +710,8 @@ impl FeaturesToml { materialize_resolved_feature_enabled(code_mode, enabled); } else if spec.id == Feature::MultiAgentV2 { materialize_resolved_feature_enabled(multi_agent_v2, enabled); + } else if spec.id == Feature::TokenBudget { + materialize_resolved_feature_enabled(token_budget, enabled); } else if spec.id == Feature::RolloutBudget { materialize_resolved_feature_enabled(rollout_budget, enabled); } else if spec.id == Feature::CurrentTimeReminder { diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index b9796a55cb..3d8561a176 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -280,6 +280,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R background_terminal_max_timeout: 300_000, ghost_snapshot: GhostSnapshotConfig::default(), multi_agent_v2: MultiAgentV2Config::default(), + token_budget: None, rollout_budget: None, current_time_reminder: None, features: Default::default(), From aaf737fa59c4fc76984ec4cd705e8a7f07f0bd79 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Sat, 20 Jun 2026 21:50:09 -0700 Subject: [PATCH 085/112] [codex] simplify token budget context (#29295) ## Why The token-budget feature currently adds remaining-token messages whenever usage crosses the 25%, 50%, and 75% thresholds. Those periodic inserts create prompt churn without requiring action, while the near-compaction reminder and explicit `get_context_remaining` tool already cover actionable and on-demand budget information. The context-window lineage block is also easier to scan as plain labeled text than as a ``-wrapped fragment. ## What changed - Stop recording automatic remaining-token messages at percentage thresholds. - Render context-window lineage in `First`, `Current`, `Previous` order with colon-separated labels. - Omit the `Previous` line for the first context window. - Remove `` wrappers from newly rendered lineage, near-compaction reminders, and `get_context_remaining` output. - Keep recognizing legacy wrapped fragments so existing rollouts remain compatible. - Remove the post-sampling token snapshot that was only needed by the periodic threshold path. ## Testing - `just test -p codex-core token_budget` (11 tests passed) --- .../core/src/context/token_budget_context.rs | 29 ++-- codex-rs/core/src/event_mapping.rs | 1 + codex-rs/core/src/event_mapping_tests.rs | 2 +- codex-rs/core/src/session/token_budget.rs | 62 ++------ codex-rs/core/src/session/turn.rs | 15 +- ..._new_context_window_tool_full_context.snap | 2 +- codex-rs/core/tests/suite/token_budget.rs | 149 ++++-------------- 7 files changed, 65 insertions(+), 195 deletions(-) diff --git a/codex-rs/core/src/context/token_budget_context.rs b/codex-rs/core/src/context/token_budget_context.rs index 93d5e1e6ae..55a94b2b11 100644 --- a/codex-rs/core/src/context/token_budget_context.rs +++ b/codex-rs/core/src/context/token_budget_context.rs @@ -39,24 +39,25 @@ impl ContextualUserFragment for TokenBudgetContext { } fn type_markers() -> (&'static str, &'static str) { - ("\n", "\n") + ("", "") } fn body(&self) -> String { let thread_id = self.thread_id; let first_window_id = self.first_window_id; - let previous_window_id = self - .previous_window_id - .map_or_else(|| "none".to_string(), |window_id| window_id.to_string()); let window_id = self.window_id; - let mcp_result = self - .mcp_result - .as_deref() - .map(|result| format!("\n{result}")) - .unwrap_or_default(); - format!( - "Thread id {thread_id}.\nFirst context window id {first_window_id}.\nPrevious context window id {previous_window_id}.\nCurrent context window id {window_id}.{mcp_result}" - ) + let mut lines = vec![ + format!("Thread id {thread_id}."), + format!("First context window id: {first_window_id}"), + format!("Current context window id: {window_id}"), + ]; + if let Some(previous_window_id) = self.previous_window_id { + lines.push(format!("Previous context window id: {previous_window_id}")); + } + if let Some(mcp_result) = &self.mcp_result { + lines.push(mcp_result.clone()); + } + lines.join("\n") } } @@ -87,7 +88,7 @@ impl ContextualUserFragment for TokenBudgetRemainingContext { } fn type_markers() -> (&'static str, &'static str) { - ("\n", "\n") + ("", "") } fn body(&self) -> String { @@ -123,7 +124,7 @@ impl ContextualUserFragment for TokenBudgetReminder { } fn type_markers() -> (&'static str, &'static str) { - ("\n", "\n") + ("", "") } fn body(&self) -> String { diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 1ca097e783..4c57c5cadc 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -34,6 +34,7 @@ const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[ REALTIME_CONVERSATION_OPEN_TAG, SKILLS_INSTRUCTIONS_OPEN_TAG, "", + // Keep recognizing token-budget wrappers persisted by older versions. "", "", ]; diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs index b7453c902f..322fe7d005 100644 --- a/codex-rs/core/src/event_mapping_tests.rs +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -29,7 +29,7 @@ fn recognizes_skills_instructions_as_contextual_developer_content() { } #[test] -fn recognizes_token_budget_as_contextual_developer_content() { +fn recognizes_legacy_token_budget_as_contextual_developer_content() { let content = vec![ContentItem::InputText { text: "\nYou have 710 tokens left in this context window.\n" .to_string(), diff --git a/codex-rs/core/src/session/token_budget.rs b/codex-rs/core/src/session/token_budget.rs index 017b7d542f..17f27c106b 100644 --- a/codex-rs/core/src/session/token_budget.rs +++ b/codex-rs/core/src/session/token_budget.rs @@ -3,63 +3,35 @@ use super::turn_context::TurnContext; use crate::context::ContextualUserFragment; use codex_features::Feature; -const TOKEN_BUDGET_USAGE_THRESHOLDS: [i64; 3] = [25, 50, 75]; - pub(super) async fn maybe_record( sess: &Session, turn_context: &TurnContext, - tokens_before_sampling: i64, - tokens_after_sampling: i64, tokens_until_compaction: i64, ) { if !turn_context.config.features.enabled(Feature::TokenBudget) { return; } - let mut response_items = Vec::new(); - if let Some(model_context_window) = turn_context.model_context_window() - && model_context_window > 0 - && tokens_after_sampling > tokens_before_sampling - { - let tokens_before_sampling = tokens_before_sampling.max(0); - let tokens_after_sampling = tokens_after_sampling.max(0); - let crossed_threshold = TOKEN_BUDGET_USAGE_THRESHOLDS.iter().any(|threshold| { - tokens_before_sampling.saturating_mul(100) - < model_context_window.saturating_mul(*threshold) - && tokens_after_sampling.saturating_mul(100) - >= model_context_window.saturating_mul(*threshold) - }); - if crossed_threshold { - let tokens_left = model_context_window - .saturating_sub(tokens_after_sampling) - .max(0); - response_items.push(ContextualUserFragment::into( - crate::context::TokenBudgetRemainingContext::new(tokens_left), - )); - } - } - - if let Some(config) = turn_context.config.token_budget.as_ref().filter(|config| { + let Some(config) = turn_context.config.token_budget.as_ref().filter(|config| { config .reminder_threshold_tokens .is_some_and(|threshold| tokens_until_compaction <= threshold) - }) { - let reminder_due = { - let mut state = sess.state.lock().await; - state.claim_token_budget_reminder() - }; - if reminder_due { - response_items.push(ContextualUserFragment::into( - crate::context::TokenBudgetReminder::new( - &config.reminder_message_template, - tokens_until_compaction, - ), - )); - } + }) else { + return; + }; + + let reminder_due = { + let mut state = sess.state.lock().await; + state.claim_token_budget_reminder() + }; + if !reminder_due { + return; } - if !response_items.is_empty() { - sess.record_conversation_items(turn_context, &response_items) - .await; - } + let response_item = ContextualUserFragment::into(crate::context::TokenBudgetReminder::new( + &config.reminder_message_template, + tokens_until_compaction, + )); + sess.record_conversation_items(turn_context, std::slice::from_ref(&response_item)) + .await; } diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index df74841b19..a35d9b60ca 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -254,8 +254,7 @@ pub(crate) async fn run_turn( window_id, CodexResponsesRequestKind::Turn, ); - let tokens_before_sampling = sess.get_total_token_usage().await; - let (sampling_request_output, sampling_request_input) = run_sampling_request( + run_sampling_request( Arc::clone(&sess), Arc::clone(&turn_context), Arc::clone(&turn_extension_data), @@ -265,17 +264,11 @@ pub(crate) async fn run_turn( sampling_request_input, cancellation_token.child_token(), ) - .await?; - - Ok(( - tokens_before_sampling, - sampling_request_output, - sampling_request_input, - )) + .await } .await; match sampling_request_result { - Ok((tokens_before_sampling, sampling_request_output, sampling_request_input)) => { + Ok((sampling_request_output, sampling_request_input)) => { let SamplingRequestResult { needs_follow_up: model_needs_follow_up, last_agent_message: sampling_request_last_agent_message, @@ -326,8 +319,6 @@ pub(crate) async fn run_turn( super::token_budget::maybe_record( sess.as_ref(), turn_context.as_ref(), - tokens_before_sampling, - tokens_after_sampling, tokens_until_compaction, ) .await; diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap b/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap index e5dd607170..c0c905625f 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap @@ -9,7 +9,7 @@ Scenario: New context window tool installs fresh full context before the next fo 00:message/developer[3]: [01] [02] - [03] \nThread id .\nFirst context window id .\nPrevious context window id .\nCurrent context window id .\n + [03] Thread id .\nFirst context window id: \nCurrent context window id: \nPrevious context window id: 01:message/user:> 02:function_call/update_plan 03:function_call_output:Plan updated diff --git a/codex-rs/core/tests/suite/token_budget.rs b/codex-rs/core/tests/suite/token_budget.rs index a1f8fb5de2..4d448f9e7e 100644 --- a/codex-rs/core/tests/suite/token_budget.rs +++ b/codex-rs/core/tests/suite/token_budget.rs @@ -33,11 +33,11 @@ use std::time::Duration; const CONFIGURED_CONTEXT_WINDOW: i64 = 128_000; -fn token_budget_texts(request: &ResponsesRequest) -> Vec { +fn token_budget_contexts(request: &ResponsesRequest) -> Vec { request .message_input_texts("developer") .into_iter() - .filter(|text| text.starts_with("")) + .filter(|text| text.starts_with("Thread id ")) .collect() } @@ -47,7 +47,7 @@ fn token_budget_window_ids( ) -> (String, Option, String) { let captures = assert_regex_match( &format!( - r"^\nThread id {thread_id}\.\nFirst context window id ([0-9a-f-]{{36}})\.\nPrevious context window id (none|[0-9a-f-]{{36}})\.\nCurrent context window id ([0-9a-f-]{{36}})\.\n$" + r"^Thread id {thread_id}\.\nFirst context window id: ([0-9a-f-]{{36}})\nCurrent context window id: ([0-9a-f-]{{36}})(?:\nPrevious context window id: ([0-9a-f-]{{36}}))?$" ), text, ); @@ -56,16 +56,12 @@ fn token_budget_window_ids( .expect("first window id capture") .as_str() .to_string(); - let previous_window_id = captures - .get(2) - .expect("previous window id capture") - .as_str(); - let previous_window_id = (previous_window_id != "none").then(|| previous_window_id.to_string()); let window_id = captures - .get(3) + .get(2) .expect("window id capture") .as_str() .to_string(); + let previous_window_id = captures.get(3).map(|capture| capture.as_str().to_string()); (first_window_id, previous_window_id, window_id) } @@ -115,14 +111,14 @@ async fn token_budget_context_is_only_emitted_with_full_context() -> Result<()> assert_eq!(requests.len(), 2); let thread_id = test.session_configured.thread_id; - let initial_token_budget = token_budget_texts(&requests[0]); + let initial_token_budget = token_budget_contexts(&requests[0]); assert_eq!(initial_token_budget.len(), 1); let (first_window_id, previous_window_id, window_id) = token_budget_window_ids(&initial_token_budget[0], thread_id); assert_eq!(previous_window_id, None); assert_eq!(first_window_id, window_id); assert_eq!( - token_budget_texts(&requests[1]), + token_budget_contexts(&requests[1]), initial_token_budget, "steady-state context update should not advance the context window" ); @@ -191,11 +187,11 @@ async fn token_budget_context_injects_plain_thread_hint_text() -> Result<()> { let request = responses.single_request(); let thread_id = test.session_configured.thread_id; - let token_budgets = token_budget_texts(&request); + let token_budgets = token_budget_contexts(&request); assert_eq!(token_budgets.len(), 1); let captures = assert_regex_match( &format!( - r"^\nThread id {thread_id}\.\nFirst context window id ([0-9a-f-]{{36}})\.\nPrevious context window id none\.\nCurrent context window id ([0-9a-f-]{{36}})\.\nmanual history hint for thread {thread_id}\nunstructured notes/thread_hint fixture result\n$" + r"^Thread id {thread_id}\.\nFirst context window id: ([0-9a-f-]{{36}})\nCurrent context window id: ([0-9a-f-]{{36}})\nmanual history hint for thread {thread_id}\nunstructured notes/thread_hint fixture result$" ), &token_budgets[0], ); @@ -213,92 +209,6 @@ async fn token_budget_context_injects_plain_thread_hint_text() -> Result<()> { Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn token_budget_remaining_context_emits_on_first_threshold_crossing() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let responses = mount_sse_sequence( - &server, - vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_completed_with_tokens("resp-1", /*total_tokens*/ 2_500), - ]), - sse(vec![ - ev_response_created("resp-2"), - ev_completed_with_tokens("resp-2", /*total_tokens*/ 3_000), - ]), - sse(vec![ - ev_response_created("resp-3"), - ev_completed_with_tokens("resp-3", /*total_tokens*/ 5_000), - ]), - sse(vec![ - ev_response_created("resp-4"), - ev_completed_with_tokens("resp-4", /*total_tokens*/ 8_000), - ]), - sse(vec![ev_response_created("resp-5"), ev_completed("resp-5")]), - ], - ) - .await; - let test = test_codex() - .with_config(|config| { - config.model_context_window = Some(10_000); - config - .features - .enable(Feature::TokenBudget) - .expect("test config should allow token budget"); - }) - .build(&server) - .await?; - - for turn in 1..=5 { - test.submit_turn(&format!("turn {turn}")).await?; - } - - let requests = responses.requests(); - assert_eq!(requests.len(), 5); - - let thread_id = test.session_configured.thread_id; - let full_context = token_budget_texts(&requests[0]); - assert_eq!(full_context.len(), 1); - token_budget_window_ids(&full_context[0], thread_id); - let full_context = full_context[0].clone(); - let threshold_25 = - "\nYou have 7000 tokens left in this context window.\n" - .to_string(); - let threshold_50 = - "\nYou have 4500 tokens left in this context window.\n" - .to_string(); - let threshold_75 = - "\nYou have 1500 tokens left in this context window.\n" - .to_string(); - - assert_eq!(token_budget_texts(&requests[0]), vec![full_context.clone()]); - assert_eq!( - token_budget_texts(&requests[1]), - vec![full_context.clone(), threshold_25.clone()] - ); - assert_eq!( - token_budget_texts(&requests[2]), - vec![full_context.clone(), threshold_25.clone()] - ); - assert_eq!( - token_budget_texts(&requests[3]), - vec![ - full_context.clone(), - threshold_25.clone(), - threshold_50.clone() - ] - ); - assert_eq!( - token_budget_texts(&requests[4]), - vec![full_context, threshold_25, threshold_50, threshold_75] - ); - - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn token_budget_reminder_emits_after_crossing_compaction_threshold() -> Result<()> { skip_if_no_network!(Ok(())); @@ -335,17 +245,18 @@ async fn token_budget_reminder_emits_after_crossing_compaction_threshold() -> Re let requests = responses.requests(); assert_eq!(requests.len(), 2); - let initial_context = token_budget_texts(&requests[0]); + let initial_context = token_budget_contexts(&requests[0]); assert_eq!(initial_context.len(), 1); - let remaining_context = - "\nYou have 1500 tokens left in this context window.\n" - .to_string(); - let reminder = "\nYour context window is nearly exhausted (only 1000 tokens remaining) and will be automatically reset for you soon. Once reset, message items in current context window will be cleared in the new window, but notes and history items will be persistent across windows.\n" - .to_string(); + let reminder = "Your context window is nearly exhausted (only 1000 tokens remaining) and will be automatically reset for you soon. Once reset, message items in current context window will be cleared in the new window, but notes and history items will be persistent across windows."; assert_eq!( - token_budget_texts(&requests[1]), - vec![initial_context[0].clone(), remaining_context, reminder] + requests[1] + .message_input_texts("developer") + .into_iter() + .filter(|text| text == reminder) + .count(), + 1 ); + assert_eq!(token_budget_contexts(&requests[1]), initial_context); Ok(()) } @@ -401,13 +312,10 @@ async fn get_context_remaining_returns_token_budget_remaining_fragment() -> Resu ); let thread_id = test.session_configured.thread_id; - let remaining_context = - "\nYou have 7000 tokens left in this context window.\n" - .to_string(); - let token_budgets = token_budget_texts(&requests[1]); - assert_eq!(token_budgets.len(), 2); + let remaining_context = "You have 7000 tokens left in this context window.".to_string(); + let token_budgets = token_budget_contexts(&requests[1]); + assert_eq!(token_budgets.len(), 1); token_budget_window_ids(&token_budgets[0], thread_id); - assert_eq!(token_budgets[1], remaining_context); assert_eq!( requests[2].function_call_output_content_and_success(call_id), Some((Some(remaining_context), None)) @@ -464,14 +372,11 @@ async fn get_context_remaining_returns_unknown_when_window_is_unavailable() -> R "get_context_remaining should be exposed when token budget is enabled" ); - assert_eq!(token_budget_texts(&requests[0]), Vec::::new()); + assert_eq!(token_budget_contexts(&requests[0]), Vec::::new()); assert_eq!( requests[1].function_call_output_content_and_success(call_id), Some(( - Some( - "\nYou have unknown tokens left in this context window.\n" - .to_string() - ), + Some("You have unknown tokens left in this context window.".to_string()), None, )) ); @@ -527,11 +432,11 @@ async fn token_budget_context_uses_new_window_after_compaction() -> Result<()> { assert_eq!(requests.len(), 3); let thread_id = test.session_configured.thread_id; - let initial_token_budget = token_budget_texts(&requests[0]); + let initial_token_budget = token_budget_contexts(&requests[0]); assert_eq!(initial_token_budget.len(), 1); let (initial_first_window_id, initial_previous_window_id, initial_window_id) = token_budget_window_ids(&initial_token_budget[0], thread_id); - let post_compaction_token_budget = token_budget_texts(&requests[2]); + let post_compaction_token_budget = token_budget_contexts(&requests[2]); assert_eq!(post_compaction_token_budget.len(), 1); let ( post_compaction_first_window_id, @@ -606,11 +511,11 @@ async fn new_context_tool_starts_new_window_before_follow_up() -> Result<()> { "new_context should be exposed when token budget is enabled" ); let thread_id = test.session_configured.thread_id; - let initial_token_budget = token_budget_texts(&requests[0]); + let initial_token_budget = token_budget_contexts(&requests[0]); assert_eq!(initial_token_budget.len(), 1); let (initial_first_window_id, _, initial_window_id) = token_budget_window_ids(&initial_token_budget[0], thread_id); - let new_window_token_budget = token_budget_texts(&requests[2]); + let new_window_token_budget = token_budget_contexts(&requests[2]); assert_eq!(new_window_token_budget.len(), 1); let (new_first_window_id, new_previous_window_id, new_window_id) = token_budget_window_ids(&new_window_token_budget[0], thread_id); From bd2968a4db3057657f6d242010bfd15f589124f3 Mon Sep 17 00:00:00 2001 From: jif Date: Sun, 21 Jun 2026 11:33:21 +0100 Subject: [PATCH 086/112] Carry sandbox intent to remote exec servers (#29108) ## What changed PR #29099 stopped sending the orchestrator's concrete sandbox wrapper to a remote exec-server. Remote commands now arrive as plain native argv. This PR adds the next piece: Codex also sends portable sandbox intent next to that plain argv. For a remote unified-exec command, the request can now include: - the canonical permission profile before local workspace-root materialization - the sandbox cwd and workspace roots as `PathUri` values - Windows sandbox settings - the legacy Landlock setting - whether managed networking must be enforced The important part is that symbolic entries such as `:workspace_roots` stay symbolic while crossing the boundary. The executor can then bind them to its own workspace-root paths instead of receiving orchestrator-local absolute paths. The data travels through `ExecRequest` into `ExecParams`. Older exec-servers can still deserialize requests because the new fields have defaults. ## Why The orchestrator should not decide how another machine implements sandboxing. For example: - a local macOS Codex would normally build a Seatbelt command - a remote Linux executor needs a Linux sandbox command instead The orchestrator now sends the plain command plus the policy it intended to enforce. A later PR can let the exec-server choose and build the correct sandbox for its own operating system. ## Important detail This keeps the portable intent separate from the local `SandboxType`. `SandboxType::None` is ambiguous: - it can mean the command was explicitly approved to run without a sandbox - it can also mean the orchestrator host has no concrete sandbox implementation available Those cases are different for remote execution. This PR adds `sandbox_requested` so an executor can still receive sandbox intent when the orchestrator cannot build a local wrapper. Explicit unsandboxed retries still send no sandbox context. ## Behavior today This PR only transports the intent. The exec-server accepts the new fields but does not apply them yet. Remote commands therefore remain unsandboxed after this PR, just as they are after PR #29099. ## Follow-up The next PR will make exec-server read this portable intent, bind symbolic workspace permissions to executor-native roots, choose the sandbox for its own operating system, build the wrapper locally, and then spawn the command. --- codex-rs/core/src/exec.rs | 2 + codex-rs/core/src/sandboxing/mod.rs | 7 +++ codex-rs/core/src/tasks/user_shell.rs | 2 + codex-rs/core/src/tools/orchestrator.rs | 42 +++++++++++++---- .../src/tools/runtimes/apply_patch_tests.rs | 4 ++ codex-rs/core/src/tools/runtimes/mod_tests.rs | 2 + .../tools/runtimes/shell/unix_escalation.rs | 4 ++ codex-rs/core/src/tools/sandboxing.rs | 30 +++++++++++- codex-rs/core/src/tools/sandboxing_tests.rs | 29 ++++++++---- .../core/src/unified_exec/process_manager.rs | 2 + .../src/unified_exec/process_manager_tests.rs | 2 + codex-rs/exec-server/src/environment.rs | 2 + codex-rs/exec-server/src/local_process.rs | 2 + codex-rs/exec-server/src/protocol.rs | 6 +++ .../exec-server/src/server/handler/tests.rs | 2 + codex-rs/exec-server/src/server/processor.rs | 2 + codex-rs/exec-server/tests/exec_process.rs | 24 ++++++++++ codex-rs/exec-server/tests/relay.rs | 2 + .../rmcp-client/src/stdio_server_launcher.rs | 2 + codex-rs/sandboxing/src/manager.rs | 46 ++++++++++++------- 20 files changed, 178 insertions(+), 36 deletions(-) diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 9d55ae8864..99c87cdd99 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -456,6 +456,8 @@ pub(crate) async fn execute_exec_request( windows_sandbox_filesystem_overrides, network_environment_id, arg0, + exec_server_sandbox: _, + exec_server_enforce_managed_network: _, } = exec_request; // TODO(anp): Keep PathUri through the local process launch boundary. diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index c156b0d70e..d0006f8657 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -14,6 +14,7 @@ use crate::exec::execute_exec_request; #[cfg(target_os = "macos")] use crate::spawn::CODEX_SANDBOX_ENV_VAR; use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; +use codex_file_system::FileSystemSandboxContext; use codex_network_proxy::NetworkProxy; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::exec_output::ExecToolCallOutput; @@ -60,6 +61,8 @@ pub struct ExecRequest { pub network_sandbox_policy: NetworkSandboxPolicy, pub(crate) windows_sandbox_filesystem_overrides: Option, pub arg0: Option, + pub(crate) exec_server_sandbox: Option, + pub(crate) exec_server_enforce_managed_network: bool, } impl ExecRequest { @@ -102,6 +105,8 @@ impl ExecRequest { network_sandbox_policy, windows_sandbox_filesystem_overrides: None, arg0, + exec_server_sandbox: None, + exec_server_enforce_managed_network: false, } } @@ -158,6 +163,8 @@ impl ExecRequest { network_sandbox_policy, windows_sandbox_filesystem_overrides: None, arg0, + exec_server_sandbox: None, + exec_server_enforce_managed_network: false, } } } diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index c5b44a6494..844e327bdf 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -224,6 +224,8 @@ pub(crate) async fn execute_user_shell_command( network_sandbox_policy: permission_profile.network_sandbox_policy(), windows_sandbox_filesystem_overrides: None, arg0: None, + exec_server_sandbox: None, + exec_server_enforce_managed_network: false, }; let stdout_stream = Some(StdoutStream { diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 803b8d6a9f..aaa0dfe9d1 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -84,7 +84,9 @@ impl ToolOrchestrator { }; let attempt_with_network_approval = SandboxAttempt { sandbox: attempt.sandbox, + sandbox_requested: attempt.sandbox_requested, permissions: attempt.permissions, + exec_server_permissions: attempt.exec_server_permissions, enforce_managed_network: attempt.enforce_managed_network, manager: attempt.manager, sandbox_cwd: attempt.sandbox_cwd, @@ -225,16 +227,27 @@ impl ToolOrchestrator { &file_system_sandbox_policy, ); let managed_network_active = turn_ctx.network.is_some(); - let initial_sandbox = match sandbox_override { - SandboxOverride::BypassSandboxFirstAttempt => SandboxType::None, - SandboxOverride::NoOverride => self.sandbox.select_initial( + let sandbox_preference = tool.sandbox_preference(); + let sandbox_requested = match sandbox_override { + SandboxOverride::BypassSandboxFirstAttempt => false, + SandboxOverride::NoOverride => self.sandbox.should_sandbox( &file_system_sandbox_policy, network_sandbox_policy, - tool.sandbox_preference(), - turn_ctx.windows_sandbox_level, + sandbox_preference, managed_network_active, ), }; + let initial_sandbox = if sandbox_requested { + self.sandbox.select_initial( + &file_system_sandbox_policy, + network_sandbox_policy, + sandbox_preference, + turn_ctx.windows_sandbox_level, + managed_network_active, + ) + } else { + SandboxType::None + }; // Platform-specific flag gating is handled by SandboxManager::select_initial. let use_legacy_landlock = turn_ctx.config.features.use_legacy_landlock(); @@ -246,7 +259,9 @@ impl ToolOrchestrator { let workspace_roots = turn_ctx.config.effective_workspace_roots(); let initial_attempt = SandboxAttempt { sandbox: initial_sandbox, + sandbox_requested, permissions: &turn_ctx.permission_profile, + exec_server_permissions: turn_ctx.config.permissions.permission_profile(), enforce_managed_network: managed_network_active, manager: &self.sandbox, sandbox_cwd: &sandbox_policy_cwd, @@ -401,16 +416,23 @@ impl ToolOrchestrator { .await?; } - let retry_sandbox = if unsandboxed_allowed { - SandboxType::None - } else { + let retry_sandbox_requested = !unsandboxed_allowed + && self.sandbox.should_sandbox( + &file_system_sandbox_policy, + network_sandbox_policy, + sandbox_preference, + managed_network_active, + ); + let retry_sandbox = if retry_sandbox_requested { self.sandbox.select_initial( &file_system_sandbox_policy, network_sandbox_policy, - tool.sandbox_preference(), + sandbox_preference, turn_ctx.windows_sandbox_level, managed_network_active, ) + } else { + SandboxType::None }; let retry_codex_linux_sandbox_exe = if unsandboxed_allowed { None @@ -419,7 +441,9 @@ impl ToolOrchestrator { }; let retry_attempt = SandboxAttempt { sandbox: retry_sandbox, + sandbox_requested: retry_sandbox_requested, permissions: &turn_ctx.permission_profile, + exec_server_permissions: turn_ctx.config.permissions.permission_profile(), enforce_managed_network: managed_network_active, manager: &self.sandbox, sandbox_cwd: &sandbox_policy_cwd, diff --git a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs index 054f2c7c15..f1c6f43aa3 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -220,7 +220,9 @@ async fn file_system_sandbox_context_uses_active_attempt() { let sandbox_policy_cwd = PathUri::from_abs_path(&path); let attempt = SandboxAttempt { sandbox: SandboxType::MacosSeatbelt, + sandbox_requested: true, permissions: &permissions, + exec_server_permissions: &permissions, enforce_managed_network: false, manager: &manager, sandbox_cwd: &sandbox_policy_cwd, @@ -286,7 +288,9 @@ async fn no_sandbox_attempt_has_no_file_system_context() { let sandbox_policy_cwd = PathUri::from_abs_path(&path); let attempt = SandboxAttempt { sandbox: SandboxType::None, + sandbox_requested: false, permissions: &permissions, + exec_server_permissions: &permissions, enforce_managed_network: false, manager: &manager, sandbox_cwd: &sandbox_policy_cwd, diff --git a/codex-rs/core/src/tools/runtimes/mod_tests.rs b/codex-rs/core/src/tools/runtimes/mod_tests.rs index 91db26aaae..9b485dbe58 100644 --- a/codex-rs/core/src/tools/runtimes/mod_tests.rs +++ b/codex-rs/core/src/tools/runtimes/mod_tests.rs @@ -106,7 +106,9 @@ async fn explicit_escalation_prepares_exec_without_managed_network() -> anyhow:: let manager = SandboxManager::new(); let attempt = SandboxAttempt { sandbox: SandboxType::None, + sandbox_requested: false, permissions: &permissions, + exec_server_permissions: &permissions, enforce_managed_network: false, manager: &manager, sandbox_cwd: &sandbox_policy_cwd, diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index c08148c7ee..a77645f941 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -166,6 +166,8 @@ pub(super) async fn try_run_zsh_fork( network_sandbox_policy, windows_sandbox_filesystem_overrides: _windows_sandbox_filesystem_overrides, arg0, + exec_server_sandbox: _, + exec_server_enforce_managed_network: _, } = sandbox_exec_request; let ParsedShellCommand { script, login, .. } = extract_shell_script(&command)?; let effective_timeout = Duration::from_millis( @@ -898,6 +900,8 @@ impl CoreShellCommandExecutor { network_sandbox_policy: self.network_sandbox_policy, windows_sandbox_filesystem_overrides: None, arg0: self.arg0.clone(), + exec_server_sandbox: None, + exec_server_enforce_managed_network: false, }, /*stdout_stream*/ None, after_spawn, diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 847a3c7584..f9d21dccfa 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -11,6 +11,7 @@ use crate::session::turn_context::TurnContext; use crate::state::SessionServices; use crate::tools::hook_names::HookToolName; use crate::tools::network_approval::NetworkApprovalSpec; +use codex_file_system::FileSystemSandboxContext; use codex_network_proxy::NetworkProxy; use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::approvals::NetworkApprovalContext; @@ -24,6 +25,7 @@ use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxTransformRequest; use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; +use codex_sandboxing::policy_transforms::effective_permission_profile; use codex_tools::ToolName; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; @@ -408,7 +410,11 @@ pub(crate) trait ToolRuntime: Approvable + Sandboxable { pub(crate) struct SandboxAttempt<'a> { pub sandbox: SandboxType, + /// Whether policy requested sandboxing, independent of this host's concrete wrapper. + pub sandbox_requested: bool, pub permissions: &'a codex_protocol::models::PermissionProfile, + /// Canonical permissions before this host materializes workspace roots. + pub exec_server_permissions: &'a codex_protocol::models::PermissionProfile, pub enforce_managed_network: bool, pub(crate) manager: &'a SandboxManager, pub(crate) sandbox_cwd: &'a PathUri, @@ -460,6 +466,10 @@ impl<'a> SandboxAttempt<'a> { network: Option<&NetworkProxy>, environment_id: Option<&str>, ) -> Result { + let exec_server_permissions = effective_permission_profile( + self.exec_server_permissions, + command.additional_permissions.as_ref(), + ); let request = self .manager .transform(SandboxTransformRequest { @@ -477,11 +487,27 @@ impl<'a> SandboxAttempt<'a> { windows_sandbox_private_desktop: self.windows_sandbox_private_desktop, }) .map_err(CodexErr::from)?; - Ok(crate::sandboxing::ExecRequest::from_sandbox_exec_request( + let mut exec_request = crate::sandboxing::ExecRequest::from_sandbox_exec_request( request, options, self.workspace_roots.to_vec(), - )) + ); + if self.sandbox_requested { + exec_request.exec_server_sandbox = Some(FileSystemSandboxContext { + permissions: exec_server_permissions.into(), + cwd: Some(exec_request.windows_sandbox_policy_cwd.clone()), + workspace_roots: self + .workspace_roots + .iter() + .map(PathUri::from_abs_path) + .collect(), + windows_sandbox_level: self.windows_sandbox_level, + windows_sandbox_private_desktop: self.windows_sandbox_private_desktop, + use_legacy_landlock: self.use_legacy_landlock, + }); + exec_request.exec_server_enforce_managed_network = self.enforce_managed_network; + } + Ok(exec_request) } } diff --git a/codex-rs/core/src/tools/sandboxing_tests.rs b/codex-rs/core/src/tools/sandboxing_tests.rs index f4d6e6ce42..47b0b4e891 100644 --- a/codex-rs/core/src/tools/sandboxing_tests.rs +++ b/codex-rs/core/src/tools/sandboxing_tests.rs @@ -4,7 +4,6 @@ use crate::tools::hook_names::HookToolName; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; -use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::GranularApprovalConfig; use codex_sandboxing::SandboxCommand; use codex_sandboxing::SandboxManager; @@ -202,21 +201,23 @@ fn deny_read_blocks_explicit_escalation_and_policy_bypass() { } #[test] -fn exec_server_env_keeps_command_native() { +fn exec_server_env_keeps_command_native_and_carries_sandbox_context() { let cwd: AbsolutePathBuf = std::env::current_dir() .expect("current dir") .try_into() .expect("absolute cwd"); let cwd_uri = PathUri::from_abs_path(&cwd); - let permissions = codex_protocol::models::PermissionProfile::from_runtime_permissions( - &FileSystemSandboxPolicy::default(), - NetworkSandboxPolicy::Restricted, - ); + let exec_server_permissions = codex_protocol::models::PermissionProfile::workspace_write(); + let permissions = exec_server_permissions + .clone() + .materialize_project_roots_with_workspace_roots(std::slice::from_ref(&cwd)); let manager = SandboxManager::new(); let attempt = SandboxAttempt { - sandbox: SandboxType::MacosSeatbelt, + sandbox: SandboxType::None, + sandbox_requested: true, permissions: &permissions, - enforce_managed_network: false, + exec_server_permissions: &exec_server_permissions, + enforce_managed_network: true, manager: &manager, sandbox_cwd: &cwd_uri, workspace_roots: std::slice::from_ref(&cwd), @@ -252,4 +253,16 @@ fn exec_server_env_keeps_command_native() { ); assert_eq!(request.arg0, None); assert_eq!(request.sandbox, SandboxType::None); + assert_eq!( + request.exec_server_sandbox, + Some(codex_exec_server::FileSystemSandboxContext { + permissions: exec_server_permissions.into(), + cwd: Some(cwd_uri), + workspace_roots: vec![PathUri::from_abs_path(&cwd)], + windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, + windows_sandbox_private_desktop: false, + use_legacy_landlock: false, + }) + ); + assert!(request.exec_server_enforce_managed_network); } diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index fc5508eb80..a3b4bcf9c7 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -166,6 +166,8 @@ fn exec_server_params_for_request( tty, pipe_stdin: false, arg0: request.arg0.clone(), + sandbox: request.exec_server_sandbox.clone(), + enforce_managed_network: request.exec_server_enforce_managed_network, } } diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index 5abd926c62..b6afdc85ba 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -111,6 +111,8 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { network_sandbox_policy, windows_sandbox_filesystem_overrides: None, arg0: None, + exec_server_sandbox: None, + exec_server_enforce_managed_network: false, }; let params = diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 9496bb8be6..d3e7e5e981 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -1156,6 +1156,8 @@ mod tests { tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await .expect("start process"); diff --git a/codex-rs/exec-server/src/local_process.rs b/codex-rs/exec-server/src/local_process.rs index 5e1b52b11c..1a6f2cf00f 100644 --- a/codex-rs/exec-server/src/local_process.rs +++ b/codex-rs/exec-server/src/local_process.rs @@ -902,6 +902,8 @@ mod tests { tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, } } diff --git a/codex-rs/exec-server/src/protocol.rs b/codex-rs/exec-server/src/protocol.rs index 97ef86aad2..e05595f273 100644 --- a/codex-rs/exec-server/src/protocol.rs +++ b/codex-rs/exec-server/src/protocol.rs @@ -103,6 +103,12 @@ pub struct ExecParams { /// Optional process-visible argv0 override. Values such as `codex-linux-sandbox` are command /// names rather than paths, so this is not a [`PathUri`]. pub arg0: Option, + /// Portable sandbox intent. Concrete wrapper argv is resolved by the exec-server. + #[serde(default)] + pub sandbox: Option, + /// Whether the eventual executor-side sandbox must enforce managed networking. + #[serde(default)] + pub enforce_managed_network: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/codex-rs/exec-server/src/server/handler/tests.rs b/codex-rs/exec-server/src/server/handler/tests.rs index 0bd682bd96..9cbbf78d00 100644 --- a/codex-rs/exec-server/src/server/handler/tests.rs +++ b/codex-rs/exec-server/src/server/handler/tests.rs @@ -33,6 +33,8 @@ fn exec_params_with_argv(process_id: &str, argv: Vec) -> ExecParams { tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, } } diff --git a/codex-rs/exec-server/src/server/processor.rs b/codex-rs/exec-server/src/server/processor.rs index 952f414213..b2daba7caf 100644 --- a/codex-rs/exec-server/src/server/processor.rs +++ b/codex-rs/exec-server/src/server/processor.rs @@ -403,6 +403,8 @@ mod tests { tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, } } diff --git a/codex-rs/exec-server/tests/exec_process.rs b/codex-rs/exec-server/tests/exec_process.rs index 11a3f3ece7..ac38f50eaf 100644 --- a/codex-rs/exec-server/tests/exec_process.rs +++ b/codex-rs/exec-server/tests/exec_process.rs @@ -81,6 +81,8 @@ async fn assert_exec_process_starts_and_exits(use_remote: bool) -> Result<()> { tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), "proc-1"); @@ -222,6 +224,8 @@ async fn assert_exec_process_streams_output(use_remote: bool) -> Result<()> { tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -253,6 +257,8 @@ async fn assert_exec_process_pushes_events(use_remote: bool) -> Result<()> { tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -300,6 +306,8 @@ async fn assert_exec_process_replays_events_after_close(use_remote: bool) -> Res tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -348,6 +356,8 @@ async fn assert_exec_process_retains_output_after_exit_until_streams_close( tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -421,6 +431,8 @@ async fn assert_exec_process_write_then_read(use_remote: bool) -> Result<()> { tty: true, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -458,6 +470,8 @@ async fn assert_exec_process_write_then_read_without_tty(use_remote: bool) -> Re tty: false, pipe_stdin: true, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -491,6 +505,8 @@ async fn assert_exec_process_rejects_write_without_pipe_stdin(use_remote: bool) tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -525,6 +541,8 @@ async fn assert_exec_process_signal_interrupts_process(use_remote: bool) -> Resu tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -578,6 +596,8 @@ async fn assert_exec_process_signal_reports_unsupported_on_windows(use_remote: b tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; @@ -618,6 +638,8 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe( tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; @@ -676,6 +698,8 @@ async fn remote_exec_process_recovers_after_transport_disconnect() -> Result<()> tty: false, pipe_stdin: true, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; diff --git a/codex-rs/exec-server/tests/relay.rs b/codex-rs/exec-server/tests/relay.rs index 5e58618410..5f49655e39 100644 --- a/codex-rs/exec-server/tests/relay.rs +++ b/codex-rs/exec-server/tests/relay.rs @@ -150,6 +150,8 @@ async fn remote_environment_routes_encrypted_exec_server_rpc() -> Result<()> { tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!( diff --git a/codex-rs/rmcp-client/src/stdio_server_launcher.rs b/codex-rs/rmcp-client/src/stdio_server_launcher.rs index 8ada806ff7..94aa45c1e7 100644 --- a/codex-rs/rmcp-client/src/stdio_server_launcher.rs +++ b/codex-rs/rmcp-client/src/stdio_server_launcher.rs @@ -503,6 +503,8 @@ impl ExecutorStdioServerLauncher { tty: false, pipe_stdin: true, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await .map_err(io::Error::other)?; diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index dd7aba3614..29b1177b11 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -282,24 +282,36 @@ impl SandboxManager { windows_sandbox_level: WindowsSandboxLevel, has_managed_network_requirements: bool, ) -> SandboxType { + if self.should_sandbox( + file_system_policy, + network_policy, + pref, + has_managed_network_requirements, + ) { + get_platform_sandbox(windows_sandbox_level != WindowsSandboxLevel::Disabled) + .unwrap_or(SandboxType::None) + } else { + SandboxType::None + } + } + + /// Returns whether the request needs a sandbox, independently of whether + /// this host can provide a concrete sandbox implementation. + pub fn should_sandbox( + &self, + file_system_policy: &FileSystemSandboxPolicy, + network_policy: NetworkSandboxPolicy, + pref: SandboxablePreference, + has_managed_network_requirements: bool, + ) -> bool { match pref { - SandboxablePreference::Forbid => SandboxType::None, - SandboxablePreference::Require => { - get_platform_sandbox(windows_sandbox_level != WindowsSandboxLevel::Disabled) - .unwrap_or(SandboxType::None) - } - SandboxablePreference::Auto => { - if should_require_platform_sandbox( - file_system_policy, - network_policy, - has_managed_network_requirements, - ) { - get_platform_sandbox(windows_sandbox_level != WindowsSandboxLevel::Disabled) - .unwrap_or(SandboxType::None) - } else { - SandboxType::None - } - } + SandboxablePreference::Forbid => false, + SandboxablePreference::Require => true, + SandboxablePreference::Auto => should_require_platform_sandbox( + file_system_policy, + network_policy, + has_managed_network_requirements, + ), } } From 1088b30fda97c83b70b3b64e883d481ba40528ae Mon Sep 17 00:00:00 2001 From: jif Date: Sun, 21 Jun 2026 12:40:51 +0100 Subject: [PATCH 087/112] Test pipelined scalar exec-server requests (#29325) ## Summary This adds focused coverage for the simpler same-connection scalar request path. The exec-server connection already supports multiple in-flight JSON-RPC scalar requests on one connection. This test locks in that behavior by sending two normal requests before reading either response, without adding a batch frame or any new API surface. ## What changed - Added a processor-level test that initializes an exec-server connection. - Sends two scalar `environment/info` requests back-to-back on the same connection. - Verifies both responses come back on the same connection by request id. Checked locally with: - `just test -p codex-exec-server connection_accepts_pipelined_scalar_requests` --- codex-rs/exec-server/src/server/processor.rs | 35 ++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/codex-rs/exec-server/src/server/processor.rs b/codex-rs/exec-server/src/server/processor.rs index b2daba7caf..f5c2ba760d 100644 --- a/codex-rs/exec-server/src/server/processor.rs +++ b/codex-rs/exec-server/src/server/processor.rs @@ -196,6 +196,7 @@ mod tests { use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_utils_path_uri::PathUri; + use pretty_assertions::assert_eq; use serde::Serialize; use serde::de::DeserializeOwned; use tokio::io::AsyncBufReadExt; @@ -211,9 +212,11 @@ mod tests { use crate::ExecServerRuntimePaths; use crate::ProcessId; use crate::connection::JsonRpcConnection; + use crate::protocol::ENVIRONMENT_INFO_METHOD; use crate::protocol::EXEC_METHOD; use crate::protocol::EXEC_READ_METHOD; use crate::protocol::EXEC_TERMINATE_METHOD; + use crate::protocol::EnvironmentInfo; use crate::protocol::ExecParams; use crate::protocol::ExecResponse; use crate::protocol::INITIALIZE_METHOD; @@ -225,6 +228,38 @@ mod tests { use crate::protocol::TerminateResponse; use crate::server::session_registry::SessionRegistry; + #[tokio::test] + async fn connection_accepts_pipelined_scalar_requests() { + let registry = SessionRegistry::new(); + let (mut writer, mut lines, task) = spawn_test_connection(registry, "pipelined-scalar"); + + send_request( + &mut writer, + /*id*/ 1, + INITIALIZE_METHOD, + &InitializeParams { + client_name: "exec-server-test".to_string(), + resume_session_id: None, + }, + ) + .await; + let _: InitializeResponse = read_response(&mut lines, /*expected_id*/ 1).await; + send_notification(&mut writer, INITIALIZED_METHOD, &()).await; + + send_request(&mut writer, /*id*/ 2, ENVIRONMENT_INFO_METHOD, &()).await; + send_request(&mut writer, /*id*/ 3, ENVIRONMENT_INFO_METHOD, &()).await; + + let _: EnvironmentInfo = read_response(&mut lines, /*expected_id*/ 2).await; + let _: EnvironmentInfo = read_response(&mut lines, /*expected_id*/ 3).await; + + drop(writer); + drop(lines); + timeout(Duration::from_secs(1), task) + .await + .expect("processor should exit") + .expect("processor should join"); + } + #[tokio::test] async fn transport_disconnect_detaches_session_during_in_flight_read() { let registry = SessionRegistry::new(); From c2fbf4247abb9718a280c2647cedeab87db2e169 Mon Sep 17 00:00:00 2001 From: jif Date: Sun, 21 Jun 2026 13:04:18 +0100 Subject: [PATCH 088/112] Parallelize skill metadata stats (#29326) ## Summary This switches skill discovery to the simpler same-connection scalar request shape. After reading a skills directory, discovery now starts the existing `fs/getMetadata` calls for all visible entries in that directory before awaiting the results. There is no JSON-RPC batch frame and no new filesystem API; remote filesystems use the existing request-id multiplexing on the same exec-server connection. This is the scoped alternative to the batch-frame approach in #29074 / #29075. ## What changed - Collect visible directory entries before processing them. - Run their existing `fs.get_metadata(...)` calls with `join_all`. - Process the results in the original directory order, so skill discovery behavior stays the same. ## Benchmarks Fresh local benchmark against generated skill trees over a real exec-server remote filesystem. The benchmark calls the actual `load_skills_from_roots` path, so this includes directory reads, metadata stats, `SKILL.md` reads, and parsing. Times are p50 milliseconds from 5 samples after 1 warmup, using warmed runs. | Scenario | Legacy `main` | Batch frame stack (#29074 / #29075) | Same-connection scalar stack | | --- | ---: | ---: | ---: | | 100 flat skills | 377.4 | 389.0 | 378.6 | | 500 flat skills | 1983.2 | 1856.6 | 1757.5 | Takeaway: for the actual skill discovery path, same-connection scalar is tied with legacy at 100 skills and best at 500 skills. The batch-frame stack does not show enough win here to justify the extra protocol/API surface. Benchmark command: - `just test -p codex-exec-server benchmark_remote_skill_discovery --run-ignored ignored-only --no-capture` Checked locally with: - `just test -p codex-core-skills` - `just bazel-lock-update` - `just bazel-lock-check` --- codex-rs/Cargo.lock | 1 + codex-rs/core-skills/Cargo.toml | 1 + codex-rs/core-skills/src/loader.rs | 31 ++++++++++++++++++++++-------- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f6f0e55831..f35402efd0 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2809,6 +2809,7 @@ dependencies = [ "codex-utils-plugins", "dirs", "dunce", + "futures", "pretty_assertions", "serde", "serde_json", diff --git a/codex-rs/core-skills/Cargo.toml b/codex-rs/core-skills/Cargo.toml index 7748540ac1..3ac82be9a8 100644 --- a/codex-rs/core-skills/Cargo.toml +++ b/codex-rs/core-skills/Cargo.toml @@ -31,6 +31,7 @@ codex-utils-path-uri = { workspace = true } codex-utils-plugins = { workspace = true } dirs = { workspace = true } dunce = { workspace = true } +futures = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } serde_yaml = { workspace = true } diff --git a/codex-rs/core-skills/src/loader.rs b/codex-rs/core-skills/src/loader.rs index aa9688f9a2..b377e909ea 100644 --- a/codex-rs/core-skills/src/loader.rs +++ b/codex-rs/core-skills/src/loader.rs @@ -22,6 +22,7 @@ use codex_utils_path_uri::PathUri; use codex_utils_plugins::PluginSkillRoot; use codex_utils_plugins::plugin_namespace_for_skill_path; use dirs::home_dir; +use futures::future::join_all; use serde::Deserialize; use std::collections::HashSet; use std::collections::VecDeque; @@ -534,15 +535,29 @@ async fn discover_skills_under_root( } }; - for entry in entries { - let file_name = entry.file_name; - if file_name.starts_with('.') { - continue; - } + let paths = entries + .into_iter() + .filter_map(|entry| { + let file_name = entry.file_name; + if file_name.starts_with('.') { + return None; + } + let path = dir.join(&file_name); + let path_uri = PathUri::from_abs_path(&path); + Some((file_name, path, path_uri)) + }) + .collect::>(); + let metadata_results = join_all( + paths + .iter() + .map(|(_, _, path_uri)| fs.get_metadata(path_uri, /*sandbox*/ None)), + ) + .await; - let path = dir.join(&file_name); - let path_uri = PathUri::from_abs_path(&path); - let metadata = match fs.get_metadata(&path_uri, /*sandbox*/ None).await { + for ((file_name, path, path_uri), metadata_result) in + paths.into_iter().zip(metadata_results) + { + let metadata = match metadata_result { Ok(metadata) => metadata, Err(e) => { error!("failed to stat skills path {}: {e:#}", path.display()); From 6f5dd7b4226f3c77d4d253d8be1e10ac1686ccf9 Mon Sep 17 00:00:00 2001 From: jif Date: Sun, 21 Jun 2026 13:53:16 +0100 Subject: [PATCH 089/112] Use controlled time for remote initialization timeout test (#29329) ## Summary The remote-control initialization timeout test used a 50 ms wall-clock deadline around a 10 ms transport timeout. A busy CI runner could miss that outer deadline even when the rollback behavior was correct. Pause Tokio time and advance it explicitly through the transport timeout instead. The test still verifies that initialization fails and emits the matching connection-closed event, without depending on scheduler speed. --- codex-rs/app-server-transport/Cargo.toml | 1 + .../transport/remote_control/client_tracker.rs | 18 +++++++++--------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/codex-rs/app-server-transport/Cargo.toml b/codex-rs/app-server-transport/Cargo.toml index 175890962e..7ad9444921 100644 --- a/codex-rs/app-server-transport/Cargo.toml +++ b/codex-rs/app-server-transport/Cargo.toml @@ -57,3 +57,4 @@ chrono = { workspace = true } codex-config = { workspace = true } pretty_assertions = { workspace = true } tempfile = { workspace = true } +tokio = { workspace = true, features = ["test-util"] } diff --git a/codex-rs/app-server-transport/src/transport/remote_control/client_tracker.rs b/codex-rs/app-server-transport/src/transport/remote_control/client_tracker.rs index 22e5e74eda..aef2bfd25d 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/client_tracker.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/client_tracker.rs @@ -692,14 +692,14 @@ mod tests { } } - #[tokio::test] + #[tokio::test(start_paused = true)] async fn initialize_timeout_closes_open_connection() { let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY); let (transport_event_tx, mut transport_event_rx) = mpsc::channel(1); let shutdown_token = CancellationToken::new(); let client_tracker = ClientTracker::new(server_event_tx, transport_event_tx, &shutdown_token); - let mut handle_message = tokio::spawn(async move { + let handle_message = tokio::spawn(async move { let mut client_tracker = client_tracker; client_tracker .handle_message(initialize_envelope_with_stream_id( @@ -709,13 +709,13 @@ mod tests { .await }); - assert!( - timeout(Duration::from_millis(50), &mut handle_message) - .await - .expect("initialize timeout rollback should not wait for close delivery") - .expect("handle message task should not panic") - .is_err() - ); + tokio::task::yield_now().await; + tokio::time::advance( + REMOTE_CONTROL_TRANSPORT_EVENT_SEND_TIMEOUT + Duration::from_millis(1), + ) + .await; + + assert!(handle_message.await.expect("handle message task").is_err()); let connection_id = match transport_event_rx.recv().await.expect("open event") { TransportEvent::ConnectionOpened { connection_id, .. } => connection_id, other => panic!("expected connection opened, got {other:?}"), From 6d993ca6464570993e9312096d77b964e64bfab5 Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Sun, 21 Jun 2026 10:49:31 -0700 Subject: [PATCH 090/112] code-mode: define transport-neutral runtime types (#29170) ## Summary - introduce a private `session_runtime` boundary for cell creation requests, observation modes, lifecycle events, output items, and tool metadata - update the cell actor and in-process service to use those transport-neutral types - keep cell ID allocation on the owning session side ## Motivation Cell lifecycle vocabulary currently lives inside the cell actor implementation. That makes the service adapter and future session runtime depend on actor-specific types, increasing the size and complexity of the runtime ownership change. This is the first reviewable slice of the session-runtime stack. It separates the transport-neutral data model without moving lifecycle ownership or changing behavior. Later slices will move session state behind this boundary, harden terminal and shutdown behavior, and split cell creation from observation. ## Behavior There are no public API or user-visible behavior changes in this PR. In particular: - `CodeModeSession::execute` and `wait` are unchanged - cell IDs remain allocated by the owning session - cell admission, observation, termination, and shutdown behavior are unchanged --- .../code-mode/src/cell_actor/conversions.rs | 8 +- codex-rs/code-mode/src/cell_actor/mod.rs | 12 +-- codex-rs/code-mode/src/cell_actor/tests.rs | 1 + codex-rs/code-mode/src/cell_actor/types.rs | 78 ++---------------- codex-rs/code-mode/src/lib.rs | 1 + codex-rs/code-mode/src/service.rs | 16 ++-- codex-rs/code-mode/src/session_runtime/mod.rs | 10 +++ .../code-mode/src/session_runtime/types.rs | 79 +++++++++++++++++++ 8 files changed, 114 insertions(+), 91 deletions(-) create mode 100644 codex-rs/code-mode/src/session_runtime/mod.rs create mode 100644 codex-rs/code-mode/src/session_runtime/types.rs diff --git a/codex-rs/code-mode/src/cell_actor/conversions.rs b/codex-rs/code-mode/src/cell_actor/conversions.rs index ebc12d29f5..7fb2a25c63 100644 --- a/codex-rs/code-mode/src/cell_actor/conversions.rs +++ b/codex-rs/code-mode/src/cell_actor/conversions.rs @@ -5,10 +5,10 @@ use codex_code_mode_protocol::ImageDetail; use codex_code_mode_protocol::ToolDefinition; use codex_protocol::ToolName; -use super::CellImageDetail; -use super::CellOutputItem; -use super::CellRequest; -use super::CellToolKind; +use crate::session_runtime::CreateCellRequest as CellRequest; +use crate::session_runtime::ImageDetail as CellImageDetail; +use crate::session_runtime::OutputItem as CellOutputItem; +use crate::session_runtime::ToolKind as CellToolKind; pub(super) fn runtime_request(request: CellRequest) -> ExecuteRequest { ExecuteRequest { diff --git a/codex-rs/code-mode/src/cell_actor/mod.rs b/codex-rs/code-mode/src/cell_actor/mod.rs index d42ce48a24..6e0ffb9e0c 100644 --- a/codex-rs/code-mode/src/cell_actor/mod.rs +++ b/codex-rs/code-mode/src/cell_actor/mod.rs @@ -22,23 +22,19 @@ use self::conversions::output_item; use self::conversions::runtime_request; use self::types::CellCommand; pub(crate) use self::types::CellError; -pub(crate) use self::types::CellEvent; pub(crate) use self::types::CellEventFuture; pub(crate) use self::types::CellHandle; pub(crate) use self::types::CellHost; -pub(crate) use self::types::CellImageDetail; -pub(crate) use self::types::CellOutputItem; -pub(crate) use self::types::CellRequest; pub(crate) use self::types::CellToolCall; -pub(crate) use self::types::CellToolDefinition; -pub(crate) use self::types::CellToolKind; -pub(crate) use self::types::CellToolName; -pub(crate) use self::types::ObserveMode; use crate::runtime::PendingRuntimeMode; use crate::runtime::RuntimeCommand; use crate::runtime::RuntimeControlCommand; use crate::runtime::RuntimeEvent; use crate::runtime::spawn_runtime; +use crate::session_runtime::CellEvent; +use crate::session_runtime::CreateCellRequest as CellRequest; +use crate::session_runtime::ObserveMode; +use crate::session_runtime::ToolName as CellToolName; pub(crate) struct CellActor; diff --git a/codex-rs/code-mode/src/cell_actor/tests.rs b/codex-rs/code-mode/src/cell_actor/tests.rs index 218f1fcde4..d7b72a1037 100644 --- a/codex-rs/code-mode/src/cell_actor/tests.rs +++ b/codex-rs/code-mode/src/cell_actor/tests.rs @@ -11,6 +11,7 @@ use tokio::sync::oneshot; use tokio_util::sync::CancellationToken; use super::*; +use crate::session_runtime::OutputItem as CellOutputItem; struct TestHost; diff --git a/codex-rs/code-mode/src/cell_actor/types.rs b/codex-rs/code-mode/src/cell_actor/types.rs index aef0b5211f..d82f898e76 100644 --- a/codex-rs/code-mode/src/cell_actor/types.rs +++ b/codex-rs/code-mode/src/cell_actor/types.rs @@ -4,40 +4,20 @@ use std::pin::Pin; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; -use std::time::Duration; use serde_json::Value as JsonValue; use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio_util::sync::CancellationToken; +use crate::session_runtime::CellEvent; +use crate::session_runtime::ObserveMode; +use crate::session_runtime::ToolKind; +use crate::session_runtime::ToolName; + pub(crate) type CellEventFuture = Pin> + Send + 'static>>; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum ObserveMode { - YieldAfter(Duration), - PendingFrontier, -} - -#[derive(Clone, Debug, PartialEq)] -pub(crate) enum CellEvent { - Yielded { - content_items: Vec, - }, - Pending { - content_items: Vec, - pending_tool_call_ids: Vec, - }, - Completed { - content_items: Vec, - error_text: Option, - }, - Terminated { - content_items: Vec, - }, -} - #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum CellError { Busy, @@ -45,54 +25,10 @@ pub(crate) enum CellError { Closed, } -#[derive(Clone, Debug, PartialEq)] -pub(crate) enum CellOutputItem { - Text { - text: String, - }, - Image { - image_url: String, - detail: Option, - }, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum CellImageDetail { - Auto, - Low, - High, - Original, -} - -pub(crate) struct CellRequest { - pub(crate) tool_call_id: String, - pub(crate) enabled_tools: Vec, - pub(crate) source: String, -} - -pub(crate) struct CellToolDefinition { - pub(crate) name: String, - pub(crate) tool_name: CellToolName, - pub(crate) description: String, - pub(crate) kind: CellToolKind, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct CellToolName { - pub(crate) name: String, - pub(crate) namespace: Option, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum CellToolKind { - Function, - Freeform, -} - pub(crate) struct CellToolCall { pub(crate) id: String, - pub(crate) name: CellToolName, - pub(crate) kind: CellToolKind, + pub(crate) name: ToolName, + pub(crate) kind: ToolKind, pub(crate) input: Option, } diff --git a/codex-rs/code-mode/src/lib.rs b/codex-rs/code-mode/src/lib.rs index 76f8aa9b97..cbe8bfe6df 100644 --- a/codex-rs/code-mode/src/lib.rs +++ b/codex-rs/code-mode/src/lib.rs @@ -1,6 +1,7 @@ mod cell_actor; mod runtime; mod service; +mod session_runtime; pub use codex_code_mode_protocol::*; pub use service::CodeModeService; diff --git a/codex-rs/code-mode/src/service.rs b/codex-rs/code-mode/src/service.rs index 4c8fa517ae..bf32156a0d 100644 --- a/codex-rs/code-mode/src/service.rs +++ b/codex-rs/code-mode/src/service.rs @@ -33,18 +33,18 @@ use tokio_util::sync::CancellationToken; use crate::cell_actor::CellActor; use crate::cell_actor::CellError; -use crate::cell_actor::CellEvent; use crate::cell_actor::CellEventFuture; use crate::cell_actor::CellHandle; use crate::cell_actor::CellHost; -use crate::cell_actor::CellImageDetail; -use crate::cell_actor::CellOutputItem; -use crate::cell_actor::CellRequest; use crate::cell_actor::CellToolCall; -use crate::cell_actor::CellToolDefinition; -use crate::cell_actor::CellToolKind; -use crate::cell_actor::CellToolName; -use crate::cell_actor::ObserveMode; +use crate::session_runtime::CellEvent; +use crate::session_runtime::CreateCellRequest as CellRequest; +use crate::session_runtime::ImageDetail as CellImageDetail; +use crate::session_runtime::ObserveMode; +use crate::session_runtime::OutputItem as CellOutputItem; +use crate::session_runtime::ToolDefinition as CellToolDefinition; +use crate::session_runtime::ToolKind as CellToolKind; +use crate::session_runtime::ToolName as CellToolName; pub struct NoopCodeModeSessionDelegate; diff --git a/codex-rs/code-mode/src/session_runtime/mod.rs b/codex-rs/code-mode/src/session_runtime/mod.rs new file mode 100644 index 0000000000..bb09e0e437 --- /dev/null +++ b/codex-rs/code-mode/src/session_runtime/mod.rs @@ -0,0 +1,10 @@ +mod types; + +pub(crate) use self::types::CellEvent; +pub(crate) use self::types::CreateCellRequest; +pub(crate) use self::types::ImageDetail; +pub(crate) use self::types::ObserveMode; +pub(crate) use self::types::OutputItem; +pub(crate) use self::types::ToolDefinition; +pub(crate) use self::types::ToolKind; +pub(crate) use self::types::ToolName; diff --git a/codex-rs/code-mode/src/session_runtime/types.rs b/codex-rs/code-mode/src/session_runtime/types.rs new file mode 100644 index 0000000000..f3719c96c3 --- /dev/null +++ b/codex-rs/code-mode/src/session_runtime/types.rs @@ -0,0 +1,79 @@ +use std::time::Duration; + +/// Selects the next observable frontier for a running cell. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ObserveMode { + YieldAfter(Duration), + PendingFrontier, +} + +/// An observable cell lifecycle event. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum CellEvent { + Yielded { + content_items: Vec, + }, + Pending { + content_items: Vec, + pending_tool_call_ids: Vec, + }, + Completed { + content_items: Vec, + error_text: Option, + }, + Terminated { + content_items: Vec, + }, +} + +/// Output emitted by a cell since its preceding observation. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum OutputItem { + Text { + text: String, + }, + Image { + image_url: String, + detail: Option, + }, +} + +/// Requested image fidelity for an output image. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ImageDetail { + Auto, + Low, + High, + Original, +} + +/// Transport-neutral input for creating a cell. +/// +/// The owning session assigns the cell ID when it admits the request. +pub(crate) struct CreateCellRequest { + pub(crate) tool_call_id: String, + pub(crate) enabled_tools: Vec, + pub(crate) source: String, +} + +/// Tool metadata exposed to code running inside a cell. +pub(crate) struct ToolDefinition { + pub(crate) name: String, + pub(crate) tool_name: ToolName, + pub(crate) description: String, + pub(crate) kind: ToolKind, +} + +/// A tool name with an optional namespace. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ToolName { + pub(crate) name: String, + pub(crate) namespace: Option, +} + +/// The JavaScript calling convention for a tool. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ToolKind { + Function, + Freeform, +} From 63f009e9dad2e70454b7ed6434d8aa28dfb52b51 Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Sun, 21 Jun 2026 11:18:36 -0700 Subject: [PATCH 091/112] code-mode: move session ownership into runtime (#29285) ## Summary - Move code-mode cell ownership and shared stored values from `CodeModeService` into `SessionRuntime`. - Keep the protocol-facing execute/wait behavior behind the existing service adapter. - Add runtime-level ownership and isolation coverage. ## Why This establishes a transport-neutral session boundary before later lifecycle and create/observe changes. ## Impact No intended model-facing behavior change. This is an ownership and layering refactor. ## Validation - Stack-tip validation: `just test -p codex-code-mode -p codex-code-mode-protocol` (70 passed). - Parent branch: `cconger/code-mode-runtime-compact-03a-runtime-types`. --- codex-rs/code-mode/src/service.rs | 346 +++++++----------- .../code-mode/src/service_contract_tests.rs | 16 +- codex-rs/code-mode/src/service_tests.rs | 11 +- codex-rs/code-mode/src/session_runtime/mod.rs | 268 ++++++++++++++ .../code-mode/src/session_runtime/types.rs | 93 +++++ 5 files changed, 510 insertions(+), 224 deletions(-) diff --git a/codex-rs/code-mode/src/service.rs b/codex-rs/code-mode/src/service.rs index bf32156a0d..bb7aa842e1 100644 --- a/codex-rs/code-mode/src/service.rs +++ b/codex-rs/code-mode/src/service.rs @@ -1,8 +1,4 @@ -use std::collections::HashMap; use std::sync::Arc; -use std::sync::atomic::AtomicBool; -use std::sync::atomic::AtomicU64; -use std::sync::atomic::Ordering; use std::time::Duration; use codex_code_mode_protocol::CellId; @@ -27,24 +23,11 @@ use codex_code_mode_protocol::WaitRequest; use codex_code_mode_protocol::WaitToPendingOutcome; use codex_code_mode_protocol::WaitToPendingRequest; use serde_json::Value as JsonValue; -use tokio::sync::Mutex; use tokio::sync::oneshot; use tokio_util::sync::CancellationToken; -use crate::cell_actor::CellActor; -use crate::cell_actor::CellError; -use crate::cell_actor::CellEventFuture; -use crate::cell_actor::CellHandle; -use crate::cell_actor::CellHost; -use crate::cell_actor::CellToolCall; -use crate::session_runtime::CellEvent; -use crate::session_runtime::CreateCellRequest as CellRequest; -use crate::session_runtime::ImageDetail as CellImageDetail; -use crate::session_runtime::ObserveMode; -use crate::session_runtime::OutputItem as CellOutputItem; -use crate::session_runtime::ToolDefinition as CellToolDefinition; -use crate::session_runtime::ToolKind as CellToolKind; -use crate::session_runtime::ToolName as CellToolName; +use crate::session_runtime as runtime; +use crate::session_runtime::SessionRuntime; pub struct NoopCodeModeSessionDelegate; @@ -89,16 +72,8 @@ impl CodeModeSessionProvider for InProcessCodeModeSessionProvider { } } -struct Inner { - stored_values: Mutex>, - cells: Mutex>, - delegate: Arc, - shutting_down: AtomicBool, - next_cell_id: AtomicU64, -} - pub struct CodeModeService { - inner: Arc, + runtime: SessionRuntime, } impl CodeModeService { @@ -108,45 +83,31 @@ impl CodeModeService { pub fn with_delegate(delegate: Arc) -> Self { Self { - inner: Arc::new(Inner { - stored_values: Mutex::new(HashMap::new()), - cells: Mutex::new(HashMap::new()), - delegate, - shutting_down: AtomicBool::new(false), - next_cell_id: AtomicU64::new(1), - }), + runtime: SessionRuntime::new(Arc::new(ProtocolDelegate { delegate })), } } - fn allocate_cell_id(&self) -> CellId { - CellId::new( - self.inner - .next_cell_id - .fetch_add(1, Ordering::Relaxed) - .to_string(), - ) - } - pub async fn execute(&self, request: ExecuteRequest) -> Result { let yield_time_ms = request.yield_time_ms.unwrap_or(DEFAULT_EXEC_YIELD_TIME_MS); - let cell_id = self.allocate_cell_id(); - let initial_event = self - .start_cell( - cell_id.clone(), - request, - ObserveMode::YieldAfter(Duration::from_millis(yield_time_ms)), + let started = self + .runtime + .execute( + runtime_request(request), + runtime::ObserveMode::YieldAfter(Duration::from_millis(yield_time_ms)), ) - .await?; + .await + .map_err(|error| error.to_string())?; + let cell_id = protocol_cell_id(&started.cell_id); let response_cell_id = cell_id.clone(); let (response_tx, response_rx) = oneshot::channel(); tokio::spawn(async move { - let response = initial_event + let response = started + .initial_event() .await - .map_err(|error| cell_error_text(&response_cell_id, error)) + .map_err(|error| error.to_string()) .and_then(|event| runtime_response(&response_cell_id, event)); let _ = response_tx.send(response); }); - Ok(StartedCell::from_result_receiver(cell_id, response_rx)) } @@ -154,45 +115,22 @@ impl CodeModeService { &self, request: ExecuteRequest, ) -> Result { - let cell_id = self.allocate_cell_id(); - let event = self - .start_cell(cell_id.clone(), request, ObserveMode::PendingFrontier) - .await? + let started = self + .runtime + .execute( + runtime_request(request), + runtime::ObserveMode::PendingFrontier, + ) .await - .map_err(|error| cell_error_text(&cell_id, error))?; + .map_err(|error| error.to_string())?; + let cell_id = protocol_cell_id(&started.cell_id); + let event = started + .initial_event() + .await + .map_err(|error| error.to_string())?; pending_outcome(&cell_id, event) } - async fn start_cell( - &self, - cell_id: CellId, - request: ExecuteRequest, - initial_observe_mode: ObserveMode, - ) -> Result { - let stored_values = self.inner.stored_values.lock().await.clone(); - let host = Arc::new(ServiceCellHost { - cell_id: cell_id.clone(), - inner: Arc::clone(&self.inner), - }); - let mut cells = self.inner.cells.lock().await; - if self.inner.shutting_down.load(Ordering::Acquire) { - return Err("code mode session is shutting down".to_string()); - } - if cells.contains_key(&cell_id) { - return Err(format!("exec cell {cell_id} already exists")); - } - let (handle, initial_event, task) = CellActor::prepare( - cell_request(request), - stored_values, - host, - initial_observe_mode, - )?; - cells.insert(cell_id, handle); - drop(cells); - tokio::spawn(task); - Ok(initial_event) - } - pub async fn wait(&self, request: WaitRequest) -> Result { self.begin_wait(request).await.await } @@ -205,24 +143,39 @@ impl CodeModeService { cell_id, yield_time_ms, } = request; - let handle = self.inner.cells.lock().await.get(&cell_id).cloned(); - let Some(handle) = handle else { - return missing_wait(cell_id); - }; - wait_for_event( - cell_id, - handle.observe(ObserveMode::YieldAfter(Duration::from_millis( - yield_time_ms, - ))), - ) + let runtime_cell_id = runtime_cell_id(&cell_id); + match self + .runtime + .begin_observe( + &runtime_cell_id, + runtime::ObserveMode::YieldAfter(Duration::from_millis(yield_time_ms)), + ) + .await + { + Ok(pending_event) => Box::pin(async move { + match pending_event.event().await { + Ok(event) => Ok(WaitOutcome::LiveCell(runtime_response(&cell_id, event)?)), + Err(runtime::Error::MissingCell(_) | runtime::Error::ClosedCell(_)) => { + Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))) + } + Err(error) => Err(error.to_string()), + } + }), + Err(runtime::Error::MissingCell(_) | runtime::Error::ClosedCell(_)) => { + missing_wait(cell_id) + } + Err(error) => Box::pin(async move { Err(error.to_string()) }), + } } pub async fn terminate(&self, cell_id: CellId) -> Result { - let handle = self.inner.cells.lock().await.get(&cell_id).cloned(); - let Some(handle) = handle else { - return Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))); - }; - wait_for_event(cell_id, handle.terminate()).await + match self.runtime.terminate(&runtime_cell_id(&cell_id)).await { + Ok(event) => Ok(WaitOutcome::LiveCell(runtime_response(&cell_id, event)?)), + Err(runtime::Error::MissingCell(_) | runtime::Error::ClosedCell(_)) => { + Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))) + } + Err(error) => Err(error.to_string()), + } } pub async fn wait_to_pending( @@ -230,40 +183,29 @@ impl CodeModeService { request: WaitToPendingRequest, ) -> Result { let cell_id = request.cell_id; - let handle = self.inner.cells.lock().await.get(&cell_id).cloned(); - let Some(handle) = handle else { - return Ok(WaitToPendingOutcome::MissingCell(missing_cell_response( - cell_id, - ))); - }; - match handle.observe(ObserveMode::PendingFrontier).await { + match self + .runtime + .observe( + &runtime_cell_id(&cell_id), + runtime::ObserveMode::PendingFrontier, + ) + .await + { Ok(event) => Ok(WaitToPendingOutcome::LiveCell(pending_outcome( &cell_id, event, )?)), - Err(CellError::Closed) => Ok(WaitToPendingOutcome::MissingCell(missing_cell_response( - cell_id, - ))), - Err(error) => Err(cell_error_text(&cell_id, error)), + Err(runtime::Error::MissingCell(_) | runtime::Error::ClosedCell(_)) => Ok( + WaitToPendingOutcome::MissingCell(missing_cell_response(cell_id)), + ), + Err(error) => Err(error.to_string()), } } pub async fn shutdown(&self) -> Result<(), String> { - self.inner.shutting_down.store(true, Ordering::Release); - let handles = self - .inner - .cells - .lock() + self.runtime + .shutdown() .await - .values() - .cloned() - .collect::>(); - for handle in handles { - handle.shutdown(); - } - while !self.inner.cells.lock().await.is_empty() { - tokio::task::yield_now().await; - } - Ok(()) + .map_err(|error| error.to_string()) } } @@ -273,20 +215,9 @@ impl Default for CodeModeService { } } -impl Drop for CodeModeService { - fn drop(&mut self) { - self.inner.shutting_down.store(true, Ordering::Release); - if let Ok(cells) = self.inner.cells.try_lock() { - for handle in cells.values() { - handle.shutdown(); - } - } - } -} - impl CodeModeSession for CodeModeService { fn is_alive(&self) -> bool { - !self.inner.shutting_down.load(Ordering::Acquire) + self.runtime.is_alive() } fn execute<'a>( @@ -309,30 +240,28 @@ impl CodeModeSession for CodeModeService { } } -struct ServiceCellHost { - cell_id: CellId, - inner: Arc, +struct ProtocolDelegate { + delegate: Arc, } -impl CellHost for ServiceCellHost { +impl runtime::SessionRuntimeDelegate for ProtocolDelegate { async fn invoke_tool( &self, - invocation: CellToolCall, + invocation: runtime::NestedToolCall, cancellation_token: CancellationToken, ) -> Result { - self.inner - .delegate + self.delegate .invoke_tool( CodeModeNestedToolCall { - cell_id: self.cell_id.clone(), - runtime_tool_call_id: invocation.id, + cell_id: protocol_cell_id(&invocation.cell_id), + runtime_tool_call_id: invocation.runtime_tool_call_id, tool_name: codex_protocol::ToolName { - name: invocation.name.name, - namespace: invocation.name.namespace, + name: invocation.tool_name.name, + namespace: invocation.tool_name.namespace, }, - tool_kind: match invocation.kind { - CellToolKind::Function => CodeModeToolKind::Function, - CellToolKind::Freeform => CodeModeToolKind::Freeform, + tool_kind: match invocation.tool_kind { + runtime::ToolKind::Function => CodeModeToolKind::Function, + runtime::ToolKind::Freeform => CodeModeToolKind::Freeform, }, input: invocation.input, }, @@ -344,45 +273,41 @@ impl CellHost for ServiceCellHost { async fn notify( &self, call_id: String, + cell_id: runtime::CellId, text: String, cancellation_token: CancellationToken, ) -> Result<(), String> { - self.inner - .delegate - .notify(call_id, self.cell_id.clone(), text, cancellation_token) + self.delegate + .notify( + call_id, + protocol_cell_id(&cell_id), + text, + cancellation_token, + ) .await } - async fn commit_stored_values(&self, stored_value_writes: HashMap) { - self.inner - .stored_values - .lock() - .await - .extend(stored_value_writes); - } - - async fn closed(&self) { - self.inner.cells.lock().await.remove(&self.cell_id); - self.inner.delegate.cell_closed(&self.cell_id); + fn cell_closed(&self, cell_id: &runtime::CellId) { + self.delegate.cell_closed(&protocol_cell_id(cell_id)); } } -fn cell_request(request: ExecuteRequest) -> CellRequest { - CellRequest { +fn runtime_request(request: ExecuteRequest) -> runtime::CreateCellRequest { + runtime::CreateCellRequest { tool_call_id: request.tool_call_id, enabled_tools: request .enabled_tools .into_iter() - .map(|definition| CellToolDefinition { + .map(|definition| runtime::ToolDefinition { name: definition.name, - tool_name: CellToolName { + tool_name: runtime::ToolName { name: definition.tool_name.name, namespace: definition.tool_name.namespace, }, description: definition.description, kind: match definition.kind { - CodeModeToolKind::Function => CellToolKind::Function, - CodeModeToolKind::Freeform => CellToolKind::Freeform, + CodeModeToolKind::Function => runtime::ToolKind::Function, + CodeModeToolKind::Freeform => runtime::ToolKind::Freeform, }, }) .collect(), @@ -390,22 +315,20 @@ fn cell_request(request: ExecuteRequest) -> CellRequest { } } -fn wait_for_event( - cell_id: CellId, - event: CellEventFuture, -) -> CodeModeSessionResultFuture<'static, WaitOutcome> { - Box::pin(async move { - match event.await { - Ok(event) => Ok(WaitOutcome::LiveCell(runtime_response(&cell_id, event)?)), - Err(CellError::Closed) => Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))), - Err(error) => Err(cell_error_text(&cell_id, error)), - } - }) +fn runtime_cell_id(cell_id: &CellId) -> runtime::CellId { + runtime::CellId::new(cell_id.as_str()) } -fn pending_outcome(cell_id: &CellId, event: CellEvent) -> Result { +fn protocol_cell_id(cell_id: &runtime::CellId) -> CellId { + CellId::new(cell_id.as_str().to_string()) +} + +fn pending_outcome( + cell_id: &CellId, + event: runtime::CellEvent, +) -> Result { match event { - CellEvent::Pending { + runtime::CellEvent::Pending { content_items, pending_tool_call_ids, } => Ok(ExecuteToPendingOutcome::Pending { @@ -419,13 +342,16 @@ fn pending_outcome(cell_id: &CellId, event: CellEvent) -> Result Result { +fn runtime_response( + cell_id: &CellId, + event: runtime::CellEvent, +) -> Result { match event { - CellEvent::Yielded { content_items } => Ok(RuntimeResponse::Yielded { + runtime::CellEvent::Yielded { content_items } => Ok(RuntimeResponse::Yielded { cell_id: cell_id.clone(), content_items: content_items.into_iter().map(output_item).collect(), }), - CellEvent::Completed { + runtime::CellEvent::Completed { content_items, error_text, } => Ok(RuntimeResponse::Result { @@ -433,36 +359,30 @@ fn runtime_response(cell_id: &CellId, event: CellEvent) -> Result Ok(RuntimeResponse::Terminated { + runtime::CellEvent::Terminated { content_items } => Ok(RuntimeResponse::Terminated { cell_id: cell_id.clone(), content_items: content_items.into_iter().map(output_item).collect(), }), - CellEvent::Pending { .. } => { + runtime::CellEvent::Pending { .. } => { Err("cell returned a pending frontier unexpectedly".to_string()) } } } -fn output_item(item: CellOutputItem) -> FunctionCallOutputContentItem { +fn output_item(item: runtime::OutputItem) -> FunctionCallOutputContentItem { match item { - CellOutputItem::Text { text } => FunctionCallOutputContentItem::InputText { text }, - CellOutputItem::Image { image_url, detail } => FunctionCallOutputContentItem::InputImage { - image_url, - detail: detail.map(|detail| match detail { - CellImageDetail::Auto => ImageDetail::Auto, - CellImageDetail::Low => ImageDetail::Low, - CellImageDetail::High => ImageDetail::High, - CellImageDetail::Original => ImageDetail::Original, - }), - }, - } -} - -fn cell_error_text(cell_id: &CellId, error: CellError) -> String { - match error { - CellError::Busy => format!("exec cell {cell_id} already has an active observer"), - CellError::AlreadyTerminating => format!("exec cell {cell_id} is already terminating"), - CellError::Closed => format!("exec cell {cell_id} closed unexpectedly"), + runtime::OutputItem::Text { text } => FunctionCallOutputContentItem::InputText { text }, + runtime::OutputItem::Image { image_url, detail } => { + FunctionCallOutputContentItem::InputImage { + image_url, + detail: detail.map(|detail| match detail { + runtime::ImageDetail::Auto => ImageDetail::Auto, + runtime::ImageDetail::Low => ImageDetail::Low, + runtime::ImageDetail::High => ImageDetail::High, + runtime::ImageDetail::Original => ImageDetail::Original, + }), + } + } } } diff --git a/codex-rs/code-mode/src/service_contract_tests.rs b/codex-rs/code-mode/src/service_contract_tests.rs index 3d814e98cc..9484310323 100644 --- a/codex-rs/code-mode/src/service_contract_tests.rs +++ b/codex-rs/code-mode/src/service_contract_tests.rs @@ -288,8 +288,20 @@ async fn observed_natural_completion_wins_over_termination() { ); tokio::time::timeout(Duration::from_secs(1), async { loop { - if service.inner.stored_values.lock().await.get("finished") - == Some(&serde_json::json!(true)) + let response = service + .execute(execute_request(r#"text(String(load("finished")));"#)) + .await + .unwrap() + .initial_response() + .await + .unwrap(); + let RuntimeResponse::Result { content_items, .. } = response else { + panic!("expected stored-value probe to complete"); + }; + if content_items + == vec![FunctionCallOutputContentItem::InputText { + text: "true".to_string(), + }] { break; } diff --git a/codex-rs/code-mode/src/service_tests.rs b/codex-rs/code-mode/src/service_tests.rs index 688bd89cd2..d90d4b6afb 100644 --- a/codex-rs/code-mode/src/service_tests.rs +++ b/codex-rs/code-mode/src/service_tests.rs @@ -1,5 +1,4 @@ use std::sync::Arc; -use std::sync::atomic::Ordering; use std::time::Duration; use super::CellId; @@ -7,7 +6,6 @@ use super::CodeModeNestedToolCall; use super::CodeModeService; use super::CodeModeSessionDelegate; use super::NotificationFuture; -use super::ObserveMode; use super::RuntimeResponse; use super::ToolInvocationFuture; use super::WaitOutcome; @@ -216,20 +214,15 @@ async fn shutdown_interrupts_cpu_bound_cells() { #[tokio::test] async fn start_cell_rejects_new_cell_after_shutdown_begins() { let service = CodeModeService::new(); - service.inner.shutting_down.store(true, Ordering::Release); + service.shutdown().await.unwrap(); let error = service - .start_cell( - cell_id("late-cell"), - execute_request(""), - ObserveMode::YieldAfter(Duration::from_millis(1)), - ) + .execute(execute_request("text('late');")) .await .err() .unwrap(); assert_eq!(error, "code mode session is shutting down".to_string()); - assert!(service.inner.cells.lock().await.is_empty()); } #[tokio::test] diff --git a/codex-rs/code-mode/src/session_runtime/mod.rs b/codex-rs/code-mode/src/session_runtime/mod.rs index bb09e0e437..f72aadb10b 100644 --- a/codex-rs/code-mode/src/session_runtime/mod.rs +++ b/codex-rs/code-mode/src/session_runtime/mod.rs @@ -1,10 +1,278 @@ mod types; +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; + +use serde_json::Value as JsonValue; +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; + pub(crate) use self::types::CellEvent; +pub(crate) use self::types::CellId; pub(crate) use self::types::CreateCellRequest; +pub(crate) use self::types::Error; pub(crate) use self::types::ImageDetail; +pub(crate) use self::types::NestedToolCall; pub(crate) use self::types::ObserveMode; pub(crate) use self::types::OutputItem; +pub(crate) use self::types::SessionRuntimeDelegate; pub(crate) use self::types::ToolDefinition; pub(crate) use self::types::ToolKind; pub(crate) use self::types::ToolName; +use crate::cell_actor::CellActor; +use crate::cell_actor::CellError; +use crate::cell_actor::CellEventFuture; +use crate::cell_actor::CellHandle; +use crate::cell_actor::CellHost; +use crate::cell_actor::CellToolCall; + +type RuntimeEventFuture = Pin> + Send + 'static>>; + +/// Owns all cells and shared state for one transport-neutral code-mode session. +pub(crate) struct SessionRuntime { + inner: Arc>, +} + +struct Inner { + stored_values: Mutex>, + cells: Mutex>, + delegate: Arc, + shutting_down: AtomicBool, + next_cell_id: AtomicU64, +} + +impl SessionRuntime { + pub(crate) fn new(delegate: Arc) -> Self { + Self { + inner: Arc::new(Inner { + stored_values: Mutex::new(HashMap::new()), + cells: Mutex::new(HashMap::new()), + delegate, + shutting_down: AtomicBool::new(false), + next_cell_id: AtomicU64::new(1), + }), + } + } + + pub(crate) fn is_alive(&self) -> bool { + !self.inner.shutting_down.load(Ordering::Acquire) + } + + pub(crate) async fn execute( + &self, + request: CreateCellRequest, + initial_observe_mode: ObserveMode, + ) -> Result { + let cell_id = self.allocate_cell_id(); + let initial_event = self + .start_cell(cell_id.clone(), request, initial_observe_mode) + .await?; + Ok(StartedCell { + cell_id, + initial_event, + }) + } + + pub(crate) async fn observe( + &self, + cell_id: &CellId, + mode: ObserveMode, + ) -> Result { + self.begin_observe(cell_id, mode).await?.event().await + } + + pub(crate) async fn begin_observe( + &self, + cell_id: &CellId, + mode: ObserveMode, + ) -> Result { + let handle = self + .inner + .cells + .lock() + .await + .get(cell_id) + .cloned() + .ok_or_else(|| Error::MissingCell(cell_id.clone()))?; + Ok(PendingEvent { + event: map_actor_event(cell_id.clone(), handle.observe(mode)), + }) + } + + pub(crate) async fn terminate(&self, cell_id: &CellId) -> Result { + let handle = self + .inner + .cells + .lock() + .await + .get(cell_id) + .cloned() + .ok_or_else(|| Error::MissingCell(cell_id.clone()))?; + handle + .terminate() + .await + .map_err(|error| actor_error(cell_id, error)) + } + + pub(crate) async fn shutdown(&self) -> Result<(), Error> { + self.inner.shutting_down.store(true, Ordering::Release); + let handles = self + .inner + .cells + .lock() + .await + .values() + .cloned() + .collect::>(); + for handle in handles { + handle.shutdown(); + } + while !self.inner.cells.lock().await.is_empty() { + tokio::task::yield_now().await; + } + Ok(()) + } + + fn allocate_cell_id(&self) -> CellId { + CellId::new( + self.inner + .next_cell_id + .fetch_add(1, Ordering::Relaxed) + .to_string(), + ) + } + + async fn start_cell( + &self, + cell_id: CellId, + request: CreateCellRequest, + initial_observe_mode: ObserveMode, + ) -> Result { + let stored_values = self.inner.stored_values.lock().await.clone(); + let host = Arc::new(RuntimeCellHost { + cell_id: cell_id.clone(), + inner: Arc::clone(&self.inner), + }); + let mut cells = self.inner.cells.lock().await; + if self.inner.shutting_down.load(Ordering::Acquire) { + return Err(Error::ShuttingDown); + } + if cells.contains_key(&cell_id) { + return Err(Error::DuplicateCell(cell_id)); + } + let (handle, initial_event, task) = + CellActor::prepare(request, stored_values, host, initial_observe_mode) + .map_err(Error::Runtime)?; + cells.insert(cell_id.clone(), handle); + drop(cells); + tokio::spawn(task); + Ok(map_actor_event(cell_id, initial_event)) + } + + fn begin_shutdown(&self) { + self.inner.shutting_down.store(true, Ordering::Release); + if let Ok(cells) = self.inner.cells.try_lock() { + for handle in cells.values() { + handle.shutdown(); + } + } + } +} + +impl Drop for SessionRuntime { + fn drop(&mut self) { + self.begin_shutdown(); + } +} + +/// A cell admitted by [`SessionRuntime::execute`]. +pub(crate) struct StartedCell { + pub(crate) cell_id: CellId, + initial_event: RuntimeEventFuture, +} + +impl StartedCell { + pub(crate) async fn initial_event(self) -> Result { + self.initial_event.await + } +} + +/// An admitted observation that has not reached its requested frontier yet. +pub(crate) struct PendingEvent { + event: RuntimeEventFuture, +} + +impl PendingEvent { + pub(crate) async fn event(self) -> Result { + self.event.await + } +} + +struct RuntimeCellHost { + cell_id: CellId, + inner: Arc>, +} + +impl CellHost for RuntimeCellHost { + async fn invoke_tool( + &self, + invocation: CellToolCall, + cancellation_token: CancellationToken, + ) -> Result { + self.inner + .delegate + .invoke_tool( + NestedToolCall { + cell_id: self.cell_id.clone(), + runtime_tool_call_id: invocation.id, + tool_name: invocation.name, + tool_kind: invocation.kind, + input: invocation.input, + }, + cancellation_token, + ) + .await + } + + async fn notify( + &self, + call_id: String, + text: String, + cancellation_token: CancellationToken, + ) -> Result<(), String> { + self.inner + .delegate + .notify(call_id, self.cell_id.clone(), text, cancellation_token) + .await + } + + async fn commit_stored_values(&self, stored_value_writes: HashMap) { + self.inner + .stored_values + .lock() + .await + .extend(stored_value_writes); + } + + async fn closed(&self) { + self.inner.cells.lock().await.remove(&self.cell_id); + self.inner.delegate.cell_closed(&self.cell_id); + } +} + +fn map_actor_event(cell_id: CellId, event: CellEventFuture) -> RuntimeEventFuture { + Box::pin(async move { event.await.map_err(|error| actor_error(&cell_id, error)) }) +} + +fn actor_error(cell_id: &CellId, error: CellError) -> Error { + match error { + CellError::Busy => Error::BusyObserver(cell_id.clone()), + CellError::AlreadyTerminating => Error::AlreadyTerminating(cell_id.clone()), + CellError::Closed => Error::ClosedCell(cell_id.clone()), + } +} diff --git a/codex-rs/code-mode/src/session_runtime/types.rs b/codex-rs/code-mode/src/session_runtime/types.rs index f3719c96c3..b04bd0f236 100644 --- a/codex-rs/code-mode/src/session_runtime/types.rs +++ b/codex-rs/code-mode/src/session_runtime/types.rs @@ -1,5 +1,30 @@ +use std::fmt; +use std::future::Future; use std::time::Duration; +use serde_json::Value as JsonValue; +use tokio_util::sync::CancellationToken; + +/// Identifies one execution cell within a session runtime. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(crate) struct CellId(String); + +impl CellId { + pub(crate) fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for CellId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + /// Selects the next observable frontier for a running cell. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum ObserveMode { @@ -77,3 +102,71 @@ pub(crate) enum ToolKind { Function, Freeform, } + +/// A nested tool request emitted by a running cell. +pub(crate) struct NestedToolCall { + pub(crate) cell_id: CellId, + pub(crate) runtime_tool_call_id: String, + pub(crate) tool_name: ToolName, + pub(crate) tool_kind: ToolKind, + pub(crate) input: Option, +} + +/// Host callbacks used by cells owned by a [`super::SessionRuntime`]. +/// +/// Implementations must honor cancellation tokens. `cell_closed` is called +/// after the runtime has stopped routing requests to the cell. +pub(crate) trait SessionRuntimeDelegate: Send + Sync + 'static { + fn invoke_tool( + &self, + invocation: NestedToolCall, + cancellation_token: CancellationToken, + ) -> impl Future> + Send; + + fn notify( + &self, + call_id: String, + cell_id: CellId, + text: String, + cancellation_token: CancellationToken, + ) -> impl Future> + Send; + + fn cell_closed(&self, cell_id: &CellId); +} + +/// A failure reported by a session runtime operation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum Error { + ShuttingDown, + DuplicateCell(CellId), + MissingCell(CellId), + BusyObserver(CellId), + AlreadyTerminating(CellId), + ClosedCell(CellId), + Runtime(String), +} + +impl fmt::Display for Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ShuttingDown => formatter.write_str("code mode session is shutting down"), + Self::DuplicateCell(cell_id) => write!(formatter, "exec cell {cell_id} already exists"), + Self::MissingCell(cell_id) => write!(formatter, "exec cell {cell_id} not found"), + Self::BusyObserver(cell_id) => { + write!( + formatter, + "exec cell {cell_id} already has an active observer" + ) + } + Self::AlreadyTerminating(cell_id) => { + write!(formatter, "exec cell {cell_id} is already terminating") + } + Self::ClosedCell(cell_id) => { + write!(formatter, "exec cell {cell_id} closed unexpectedly") + } + Self::Runtime(error_text) => formatter.write_str(error_text), + } + } +} + +impl std::error::Error for Error {} From f774455c3a831dfab2c6f37a1f624b8097f6f2c2 Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Sun, 21 Jun 2026 12:05:24 -0700 Subject: [PATCH 092/112] code-mode: linearize cell terminal state (#29286) ## Summary - Introduce a single cell terminal-state machine for completion and termination. - Make stored-value commits atomic with the winning terminal outcome. - Buffer terminal results for later observation and cover termination-before-commit behavior. ## Why Completion, termination, observation, and stored-value updates must agree on one linearized outcome under cancellation races. ## Impact Terminal delivery becomes deterministic and terminated cells cannot commit state after termination wins. ## Validation - Focused terminal-state regression passed. - Stack-tip validation: `just test -p codex-code-mode -p codex-code-mode-protocol` (70 passed). - Parent branch: `cconger/code-mode-runtime-compact-03b-session-runtime`. --- codex-rs/code-mode/src/cell_actor/mod.rs | 305 ++++++++++-------- codex-rs/code-mode/src/cell_actor/tests.rs | 97 +++++- codex-rs/code-mode/src/cell_actor/types.rs | 282 +++++++++++++--- codex-rs/code-mode/src/session_runtime/mod.rs | 39 ++- .../code-mode/src/session_runtime/tests.rs | 110 +++++++ 5 files changed, 640 insertions(+), 193 deletions(-) create mode 100644 codex-rs/code-mode/src/session_runtime/tests.rs diff --git a/codex-rs/code-mode/src/cell_actor/mod.rs b/codex-rs/code-mode/src/cell_actor/mod.rs index 6e0ffb9e0c..d288bd2dbe 100644 --- a/codex-rs/code-mode/src/cell_actor/mod.rs +++ b/codex-rs/code-mode/src/cell_actor/mod.rs @@ -25,7 +25,11 @@ pub(crate) use self::types::CellError; pub(crate) use self::types::CellEventFuture; pub(crate) use self::types::CellHandle; pub(crate) use self::types::CellHost; +pub(crate) use self::types::CellState; pub(crate) use self::types::CellToolCall; +pub(crate) use self::types::CompletionCommit; +use self::types::CompletionDelivery; +use self::types::ObservationDelivery; use crate::runtime::PendingRuntimeMode; use crate::runtime::RuntimeCommand; use crate::runtime::RuntimeControlCommand; @@ -34,6 +38,7 @@ use crate::runtime::spawn_runtime; use crate::session_runtime::CellEvent; use crate::session_runtime::CreateCellRequest as CellRequest; use crate::session_runtime::ObserveMode; +use crate::session_runtime::OutputItem; use crate::session_runtime::ToolName as CellToolName; pub(crate) struct CellActor; @@ -44,6 +49,7 @@ impl CellActor { stored_values: HashMap, host: Arc, initial_observe_mode: ObserveMode, + cell_state: Arc, ) -> Result< ( CellHandle, @@ -61,15 +67,14 @@ impl CellActor { event_tx, PendingRuntimeMode::PauseUntilResumed, )?; - let cancellation_token = CancellationToken::new(); - let handle = CellHandle::new(command_tx, cancellation_token.clone()); + let handle = CellHandle::new(command_tx, Arc::clone(&cell_state)); let task = run_cell( host, CellContext { runtime_tx, runtime_control_tx, runtime_terminate_handle, - cancellation_token, + cell_state, }, event_rx, command_rx, @@ -88,7 +93,7 @@ struct CellContext { runtime_tx: std::sync::mpsc::Sender, runtime_control_tx: std::sync::mpsc::Sender, runtime_terminate_handle: v8::IsolateHandle, - cancellation_token: CancellationToken, + cell_state: Arc, } struct Observer { @@ -96,115 +101,98 @@ struct Observer { response_tx: oneshot::Sender>, } -struct Termination { - response_tx: Option>>, -} - async fn run_cell( host: Arc, context: CellContext, mut event_rx: mpsc::UnboundedReceiver, - mut command_rx: mpsc::UnboundedReceiver, + command_rx: mpsc::UnboundedReceiver, initial_observer: Observer, ) { let CellContext { runtime_tx, runtime_control_tx, runtime_terminate_handle, - cancellation_token, + cell_state, } = context; + let cancellation_token = cell_state.cancellation_token(); + let callback_cancellation_token = cancellation_token.child_token(); let mut content_items = Vec::new(); let mut pending_tool_call_ids = Vec::new(); - let mut completed_event = None; let mut observer = Some(initial_observer); - let mut termination: Option = None; + let mut termination = false; let mut runtime_closed = false; let mut runtime_paused = false; let mut yield_timer: Option>> = None; let mut notification_tasks = JoinSet::new(); let mut tool_tasks = JoinSet::new(); - + let mut command_rx = Some(command_rx); loop { let yield_deadline_elapsed = yield_timer .as_ref() .is_some_and(|yield_timer| yield_timer.deadline() <= tokio::time::Instant::now()); tokio::select! { biased; - maybe_command = command_rx.recv() => { - let Some(command) = maybe_command else { - if completed_event.is_some() { - break; - } - termination = Some(Termination { response_tx: None }); - begin_termination( - &runtime_tx, - &runtime_control_tx, - &runtime_terminate_handle, - &cancellation_token, + _ = cancellation_token.cancelled(), if !termination => { + termination = true; + yield_timer = None; + drop(command_rx.take()); + begin_termination( + &runtime_tx, + &runtime_control_tx, + &runtime_terminate_handle, + &cancellation_token, + ); + if runtime_closed { + finish_callbacks( + &callback_cancellation_token, + &mut notification_tasks, + &mut tool_tasks, + CallbackCompletion::Cancel, + ).await; + finish_termination( + &cell_state, + observer.take().map(|observer| observer.response_tx), + CellEvent::Terminated { + content_items: std::mem::take(&mut content_items), + }, ); - if runtime_closed { - break; - } + break; + } + } + maybe_command = async { + match command_rx.as_mut() { + Some(command_rx) => command_rx.recv().await, + None => std::future::pending::>().await, + } + } => { + let Some(CellCommand::Observe { mode, response_tx }) = maybe_command else { + cancellation_token.cancel(); continue; }; - match command { - CellCommand::Observe { mode, response_tx } => { - if let Some(event) = completed_event.take() { - let _ = response_tx.send(Ok(event)); - break; - } - if observer.is_some() || termination.is_some() { - let _ = response_tx.send(Err(CellError::Busy)); - continue; - } - observer = Some(Observer { mode, response_tx }); - yield_timer = observer.as_ref().and_then(observer_timer); - resume_for_observation( - mode, - &mut runtime_paused, - &runtime_tx, - &runtime_control_tx, - ); - } - CellCommand::Terminate { response_tx } => { - if let Some(event) = completed_event.take() { - if let Some(response_tx) = response_tx { - let _ = response_tx.send(Ok(event)); - } - break; - } - if termination.is_some() { - if let Some(response_tx) = response_tx { - let _ = response_tx.send(Err(CellError::AlreadyTerminating)); - } - continue; - } - termination = Some(Termination { response_tx }); - yield_timer = None; - begin_termination( - &runtime_tx, - &runtime_control_tx, - &runtime_terminate_handle, - &cancellation_token, - ); - if runtime_closed { - finish_callbacks( - &cancellation_token, - &mut notification_tasks, - &mut tool_tasks, - CallbackCompletion::Cancel, - ).await; - send_termination_events( - observer.take(), - termination.take(), - CellEvent::Terminated { - content_items: std::mem::take(&mut content_items), - }, - ); - break; - } - } + let response_tx = match cell_state.route_observation(response_tx) { + ObservationDelivery::Running(response_tx) => response_tx, + ObservationDelivery::Delivered => break, + ObservationDelivery::Buffered | ObservationDelivery::Closed => continue, + }; + if observer + .as_ref() + .is_some_and(|observer| observer.response_tx.is_closed()) + { + observer = None; + yield_timer = None; } + if observer.is_some() || termination { + let _ = response_tx.send(Err(CellError::Busy)); + continue; + } + observer = Some(Observer { mode, response_tx }); + yield_timer = observer.as_ref().and_then(observer_timer); + resume_for_observation( + mode, + &mut runtime_paused, + &runtime_tx, + &runtime_control_tx, + ); } _ = async { if let Some(yield_timer) = yield_timer.as_mut() { @@ -230,38 +218,57 @@ async fn run_cell( }, if !yield_deadline_elapsed => { let Some(event) = maybe_event else { runtime_closed = true; - if termination.is_some() { + if termination || cancellation_token.is_cancelled() { finish_callbacks( - &cancellation_token, + &callback_cancellation_token, &mut notification_tasks, &mut tool_tasks, CallbackCompletion::Cancel, ).await; - send_termination_events( - observer.take(), - termination.take(), + finish_termination( + &cell_state, + observer.take().map(|observer| observer.response_tx), CellEvent::Terminated { content_items: std::mem::take(&mut content_items), }, ); break; } - if completed_event.is_none() { - finish_callbacks( - &cancellation_token, - &mut notification_tasks, - &mut tool_tasks, - CallbackCompletion::DrainNotifications, - ).await; - let event = CellEvent::Completed { - content_items: std::mem::take(&mut content_items), - error_text: Some("exec runtime ended unexpectedly".to_string()), - }; - if send_or_buffer_completion( + finish_callbacks( + &callback_cancellation_token, + &mut notification_tasks, + &mut tool_tasks, + CallbackCompletion::DrainNotifications, + ) + .await; + let event = CellEvent::Completed { + content_items: std::mem::take(&mut content_items), + error_text: Some("exec runtime ended unexpectedly".to_string()), + }; + let rejected_event = match host + .commit_completion( + HashMap::new(), event, - &mut observer, - &mut completed_event, - ) { + Arc::clone(&cell_state), + ) + .await + { + CompletionCommit::Committed => None, + CompletionCommit::Rejected(event) => Some(event), + }; + match cell_state.deliver_completion( + observer.take().map(|observer| observer.response_tx), + ) { + CompletionDelivery::Delivered => break, + CompletionDelivery::Buffered => {} + CompletionDelivery::Rejected(response_tx) => { + finish_termination( + &cell_state, + response_tx, + CellEvent::Terminated { + content_items: rejected_completion_content(rejected_event), + }, + ); break; } } @@ -314,7 +321,7 @@ async fn run_cell( Arc::clone(&host), call_id, text, - cancellation_token.child_token(), + callback_cancellation_token.child_token(), ); } RuntimeEvent::ToolCall { id, name, kind, input } => { @@ -332,22 +339,22 @@ async fn run_cell( input, }, runtime_tx.clone(), - cancellation_token.child_token(), + callback_cancellation_token.child_token(), ); } RuntimeEvent::Result { stored_value_writes, error_text } => { runtime_closed = true; yield_timer = None; - if termination.is_some() { + if termination || cancellation_token.is_cancelled() { finish_callbacks( - &cancellation_token, + &callback_cancellation_token, &mut notification_tasks, &mut tool_tasks, CallbackCompletion::Cancel, ).await; - send_termination_events( - observer.take(), - termination.take(), + finish_termination( + &cell_state, + observer.take().map(|observer| observer.response_tx), CellEvent::Terminated { content_items: std::mem::take(&mut content_items), }, @@ -355,22 +362,42 @@ async fn run_cell( break; } finish_callbacks( - &cancellation_token, + &callback_cancellation_token, &mut notification_tasks, &mut tool_tasks, CallbackCompletion::DrainNotifications, - ).await; - host.commit_stored_values(stored_value_writes).await; + ) + .await; let event = CellEvent::Completed { content_items: std::mem::take(&mut content_items), error_text, }; - if send_or_buffer_completion( - event, - &mut observer, - &mut completed_event, + let rejected_event = match host + .commit_completion( + stored_value_writes, + event, + Arc::clone(&cell_state), + ) + .await + { + CompletionCommit::Committed => None, + CompletionCommit::Rejected(event) => Some(event), + }; + match cell_state.deliver_completion( + observer.take().map(|observer| observer.response_tx), ) { - break; + CompletionDelivery::Delivered => break, + CompletionDelivery::Buffered => {} + CompletionDelivery::Rejected(response_tx) => { + finish_termination( + &cell_state, + response_tx, + CellEvent::Terminated { + content_items: rejected_completion_content(rejected_event), + }, + ); + break; + } } } } @@ -383,6 +410,9 @@ async fn run_cell( } } } + // Reject requests that arrive while asynchronous terminal cleanup runs. + cell_state.tombstone(); + drop(command_rx.take()); begin_termination( &runtime_tx, &runtime_control_tx, @@ -390,7 +420,7 @@ async fn run_cell( &cancellation_token, ); finish_callbacks( - &cancellation_token, + &callback_cancellation_token, &mut notification_tasks, &mut tool_tasks, CallbackCompletion::Cancel, @@ -399,34 +429,29 @@ async fn run_cell( host.closed().await; } -fn send_or_buffer_completion( - event: CellEvent, - observer: &mut Option, - completed_event: &mut Option, -) -> bool { - if observer.is_some() { - send_observer_event(observer.take(), event); - true - } else { - *completed_event = Some(event); - false - } -} - fn send_observer_event(observer: Option, event: CellEvent) { if let Some(observer) = observer { let _ = observer.response_tx.send(Ok(event)); } } -fn send_termination_events( - observer: Option, - termination: Option, +fn rejected_completion_content(event: Option) -> Vec { + match event { + Some(CellEvent::Completed { content_items, .. }) => content_items, + None => Vec::new(), + Some(event) => panic!("completion commit rejected an unexpected event: {event:?}"), + } +} + +fn finish_termination( + cell_state: &CellState, + observer_tx: Option>>, event: CellEvent, ) { - send_observer_event(observer, event.clone()); - if let Some(response_tx) = termination.and_then(|termination| termination.response_tx) { - let _ = response_tx.send(Ok(event)); + if let Some(event) = cell_state.finish_termination(event) + && let Some(observer_tx) = observer_tx + { + let _ = observer_tx.send(Ok(event)); } } diff --git a/codex-rs/code-mode/src/cell_actor/tests.rs b/codex-rs/code-mode/src/cell_actor/tests.rs index d7b72a1037..7404b9fe10 100644 --- a/codex-rs/code-mode/src/cell_actor/tests.rs +++ b/codex-rs/code-mode/src/cell_actor/tests.rs @@ -33,7 +33,14 @@ impl CellHost for TestHost { Ok(()) } - async fn commit_stored_values(&self, _stored_value_writes: HashMap) {} + async fn commit_completion( + &self, + _stored_value_writes: HashMap, + event: CellEvent, + cell_state: Arc, + ) -> CompletionCommit { + cell_state.commit_completion(event, || {}) + } async fn closed(&self) {} } @@ -64,14 +71,15 @@ fn spawn_cell_actor_harness(initial_observe_mode: ObserveMode) -> CellActorHarne PendingRuntimeMode::PauseUntilResumed, ) .unwrap(); - let handle = CellHandle::new(command_tx, CancellationToken::new()); + let cell_state = Arc::new(CellState::new(CancellationToken::new())); + let handle = CellHandle::new(command_tx, Arc::clone(&cell_state)); let task = tokio::spawn(run_cell( Arc::new(TestHost), CellContext { runtime_tx, runtime_control_tx, runtime_terminate_handle, - cancellation_token: CancellationToken::new(), + cell_state, }, event_rx, command_rx, @@ -142,3 +150,86 @@ async fn queued_termination_preempts_unobserved_runtime_completion() { assert_eq!(harness.initial_event_rx.await.unwrap(), terminated); harness.task.await.unwrap(); } + +#[tokio::test] +async fn only_the_first_termination_claims_a_buffered_completion() { + let cell_state = CellState::new(CancellationToken::new()); + let completion = CellEvent::Completed { + content_items: Vec::new(), + error_text: None, + }; + assert_eq!( + cell_state.commit_completion(completion.clone(), || {}), + CompletionCommit::Committed + ); + assert!(matches!( + cell_state.deliver_completion(/*response_tx*/ None), + CompletionDelivery::Buffered + )); + + let first_termination = cell_state.request_termination(); + assert_eq!( + cell_state.request_termination().await, + Err(CellError::AlreadyTerminating) + ); + assert_eq!(first_termination.await, Ok(completion.clone())); + assert_eq!( + cell_state.finish_termination(CellEvent::Terminated { + content_items: Vec::new(), + }), + Some(completion) + ); +} + +#[tokio::test] +async fn termination_claim_prevents_stored_value_commit() { + let cell_state = CellState::new(CancellationToken::new()); + let termination = cell_state.request_termination(); + let mut commit_ran = false; + let completion = CellEvent::Completed { + content_items: Vec::new(), + error_text: None, + }; + + assert_eq!( + cell_state.commit_completion(completion.clone(), || commit_ran = true), + CompletionCommit::Rejected(completion) + ); + assert!(!commit_ran); + + let terminated = CellEvent::Terminated { + content_items: Vec::new(), + }; + assert_eq!( + cell_state.finish_termination(terminated.clone()), + Some(terminated.clone()) + ); + assert_eq!(termination.await, Ok(terminated)); +} + +#[test] +fn failed_completion_delivery_rebuffers_the_event() { + let cell_state = CellState::new(CancellationToken::new()); + let event = CellEvent::Completed { + content_items: Vec::new(), + error_text: None, + }; + assert_eq!( + cell_state.commit_completion(event.clone(), || {}), + CompletionCommit::Committed + ); + let (response_tx, response_rx) = oneshot::channel(); + drop(response_rx); + assert!(matches!( + cell_state.deliver_completion(Some(response_tx)), + CompletionDelivery::Buffered + )); + assert!(cell_state.accepting_observations()); + + let (response_tx, mut response_rx) = oneshot::channel(); + assert!(matches!( + cell_state.route_observation(response_tx), + ObservationDelivery::Delivered + )); + assert_eq!(response_rx.try_recv(), Ok(Ok(event))); +} diff --git a/codex-rs/code-mode/src/cell_actor/types.rs b/codex-rs/code-mode/src/cell_actor/types.rs index d82f898e76..020ae3dcb1 100644 --- a/codex-rs/code-mode/src/cell_actor/types.rs +++ b/codex-rs/code-mode/src/cell_actor/types.rs @@ -2,8 +2,7 @@ use std::collections::HashMap; use std::future::Future; use std::pin::Pin; use std::sync::Arc; -use std::sync::atomic::AtomicBool; -use std::sync::atomic::Ordering; +use std::sync::Mutex; use serde_json::Value as JsonValue; use tokio::sync::mpsc; @@ -32,10 +31,11 @@ pub(crate) struct CellToolCall { pub(crate) input: Option, } -/// Connects a cell actor to session-owned callbacks and lifecycle state. +/// Connects a cell actor to session-owned callbacks and stored values. /// -/// Implementations must honor callback cancellation and must not return from -/// `closed` until the session can no longer route requests to the cell. +/// Implementations should forward callback cancellation to downstream work. +/// Implementations must not return from `closed` until the session can no longer +/// route requests to the cell. pub(crate) trait CellHost: Send + Sync + 'static { fn invoke_tool( &self, @@ -50,10 +50,12 @@ pub(crate) trait CellHost: Send + Sync + 'static { cancellation_token: CancellationToken, ) -> impl Future> + Send; - fn commit_stored_values( + fn commit_completion( &self, stored_value_writes: HashMap, - ) -> impl Future + Send; + event: CellEvent, + cell_state: Arc, + ) -> impl Future + Send; fn closed(&self) -> impl Future + Send; } @@ -61,23 +63,21 @@ pub(crate) trait CellHost: Send + Sync + 'static { #[derive(Clone)] pub(crate) struct CellHandle { command_tx: mpsc::UnboundedSender, - cancellation_token: CancellationToken, - termination_requested: Arc, + state: Arc, } impl CellHandle { pub(super) fn new( command_tx: mpsc::UnboundedSender, - cancellation_token: CancellationToken, + state: Arc, ) -> Self { - Self { - command_tx, - cancellation_token, - termination_requested: Arc::new(AtomicBool::new(false)), - } + Self { command_tx, state } } pub(crate) fn observe(&self, mode: ObserveMode) -> CellEventFuture { + if !self.state.accepting_observations() { + return closed_event(); + } let (response_tx, response_rx) = oneshot::channel(); if self .command_tx @@ -90,33 +90,232 @@ impl CellHandle { } pub(crate) fn terminate(&self) -> CellEventFuture { - if self - .termination_requested - .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) - .is_err() - { - return Box::pin(async { Err(CellError::AlreadyTerminating) }); - } - let (response_tx, response_rx) = oneshot::channel(); - if self - .command_tx - .send(CellCommand::Terminate { - response_tx: Some(response_tx), - }) - .is_err() - { - self.termination_requested.store(false, Ordering::Relaxed); - return closed_event(); - } - response_event(response_rx) + self.state.request_termination() } pub(crate) fn shutdown(&self) { - self.termination_requested.store(true, Ordering::Relaxed); + self.state.cancellation_token().cancel(); + } +} + +/// The single linearization point for a cell's terminal outcome. +/// +/// Callback cancellation tokens are children of the cell token, so a terminal +/// decision cancels runtime work and its callbacks together. +/// +/// The mutex is held only for synchronous phase transitions and terminal +/// delivery. Runtime execution, observation waits, and callbacks never run +/// while it is held. +pub(crate) struct CellState { + phase: Mutex, + cancellation_token: CancellationToken, +} + +enum CellPhase { + Running, + Terminating { + response_tx: oneshot::Sender>, + }, + Completed(CellEvent), + CompletionClaimed(CellEvent), + Tombstone, +} + +pub(crate) enum CompletionDelivery { + Delivered, + Buffered, + Rejected(Option>>), +} + +/// Result of atomically publishing a completed cell and its session side effects. +#[derive(Debug, PartialEq)] +pub(crate) enum CompletionCommit { + Committed, + Rejected(CellEvent), +} + +pub(crate) enum ObservationDelivery { + Running(oneshot::Sender>), + Delivered, + Buffered, + Closed, +} + +impl CellState { + pub(crate) fn new(cancellation_token: CancellationToken) -> Self { + Self { + phase: Mutex::new(CellPhase::Running), + cancellation_token, + } + } + + pub(crate) fn accepting_observations(&self) -> bool { + let accepting_phase = matches!( + *self + .phase + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + CellPhase::Running | CellPhase::Completed(_) + ); + accepting_phase && !self.cancellation_token.is_cancelled() + } + + pub(crate) fn request_termination(&self) -> CellEventFuture { + let mut phase = self + .phase + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match std::mem::replace(&mut *phase, CellPhase::Tombstone) { + CellPhase::Running => { + let (response_tx, response_rx) = oneshot::channel(); + *phase = CellPhase::Terminating { response_tx }; + self.cancellation_token.cancel(); + response_event(response_rx) + } + CellPhase::Terminating { response_tx } => { + *phase = CellPhase::Terminating { response_tx }; + Box::pin(async { Err(CellError::AlreadyTerminating) }) + } + CellPhase::Completed(event) => { + *phase = CellPhase::CompletionClaimed(event.clone()); + self.cancellation_token.cancel(); + ready_event(event) + } + CellPhase::CompletionClaimed(event) => { + *phase = CellPhase::CompletionClaimed(event); + Box::pin(async { Err(CellError::AlreadyTerminating) }) + } + CellPhase::Tombstone => closed_event(), + } + } + + pub(crate) fn commit_completion( + &self, + event: CellEvent, + commit: impl FnOnce(), + ) -> CompletionCommit { + let mut phase = self + .phase + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if !matches!(*phase, CellPhase::Running) || self.cancellation_token.is_cancelled() { + return CompletionCommit::Rejected(event); + } + commit(); + *phase = CellPhase::Completed(event); + CompletionCommit::Committed + } + + pub(crate) fn deliver_completion( + &self, + response_tx: Option>>, + ) -> CompletionDelivery { + let mut phase = self + .phase + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let event = match std::mem::replace(&mut *phase, CellPhase::Tombstone) { + CellPhase::Completed(event) => event, + previous => { + *phase = previous; + return CompletionDelivery::Rejected(response_tx); + } + }; + let Some(response_tx) = response_tx else { + *phase = CellPhase::Completed(event); + return CompletionDelivery::Buffered; + }; + match response_tx.send(Ok(event)) { + Ok(()) => { + self.cancellation_token.cancel(); + CompletionDelivery::Delivered + } + Err(Ok(event)) => { + *phase = CellPhase::Completed(event); + CompletionDelivery::Buffered + } + Err(Err(error)) => { + panic!("completion delivery unexpectedly carried an actor error: {error:?}") + } + } + } + + pub(crate) fn route_observation( + &self, + response_tx: oneshot::Sender>, + ) -> ObservationDelivery { + let mut phase = self + .phase + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match std::mem::replace(&mut *phase, CellPhase::Tombstone) { + CellPhase::Running => { + *phase = CellPhase::Running; + ObservationDelivery::Running(response_tx) + } + CellPhase::Completed(event) => match response_tx.send(Ok(event)) { + Ok(()) => { + self.cancellation_token.cancel(); + ObservationDelivery::Delivered + } + Err(Ok(event)) => { + *phase = CellPhase::Completed(event); + ObservationDelivery::Buffered + } + Err(Err(error)) => { + panic!("completion delivery unexpectedly carried an actor error: {error:?}") + } + }, + CellPhase::Terminating { + response_tx: termination_tx, + } => { + *phase = CellPhase::Terminating { + response_tx: termination_tx, + }; + let _ = response_tx.send(Err(CellError::Closed)); + ObservationDelivery::Closed + } + CellPhase::CompletionClaimed(event) => { + *phase = CellPhase::CompletionClaimed(event); + let _ = response_tx.send(Err(CellError::Closed)); + ObservationDelivery::Closed + } + CellPhase::Tombstone => { + let _ = response_tx.send(Err(CellError::Closed)); + ObservationDelivery::Closed + } + } + } + + pub(crate) fn finish_termination(&self, event: CellEvent) -> Option { + let mut phase = self + .phase + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let observer_event = match std::mem::replace(&mut *phase, CellPhase::Tombstone) { + CellPhase::Running => Some(event), + CellPhase::Terminating { response_tx } => { + let _ = response_tx.send(Ok(event.clone())); + Some(event) + } + CellPhase::Completed(completed_event) => Some(completed_event), + CellPhase::CompletionClaimed(completed_event) => Some(completed_event), + CellPhase::Tombstone => None, + }; self.cancellation_token.cancel(); - let _ = self - .command_tx - .send(CellCommand::Terminate { response_tx: None }); + observer_event + } + + pub(crate) fn tombstone(&self) { + *self + .phase + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = CellPhase::Tombstone; + self.cancellation_token.cancel(); + } + + pub(crate) fn cancellation_token(&self) -> CancellationToken { + self.cancellation_token.clone() } } @@ -125,15 +324,16 @@ pub(super) enum CellCommand { mode: ObserveMode, response_tx: oneshot::Sender>, }, - Terminate { - response_tx: Option>>, - }, } fn response_event(response_rx: oneshot::Receiver>) -> CellEventFuture { Box::pin(async move { response_rx.await.unwrap_or(Err(CellError::Closed)) }) } +fn ready_event(event: CellEvent) -> CellEventFuture { + Box::pin(async move { Ok(event) }) +} + fn closed_event() -> CellEventFuture { Box::pin(async { Err(CellError::Closed) }) } diff --git a/codex-rs/code-mode/src/session_runtime/mod.rs b/codex-rs/code-mode/src/session_runtime/mod.rs index f72aadb10b..362dccda74 100644 --- a/codex-rs/code-mode/src/session_runtime/mod.rs +++ b/codex-rs/code-mode/src/session_runtime/mod.rs @@ -29,7 +29,9 @@ use crate::cell_actor::CellError; use crate::cell_actor::CellEventFuture; use crate::cell_actor::CellHandle; use crate::cell_actor::CellHost; +use crate::cell_actor::CellState; use crate::cell_actor::CellToolCall; +use crate::cell_actor::CompletionCommit; type RuntimeEventFuture = Pin> + Send + 'static>>; @@ -165,9 +167,15 @@ impl SessionRuntime { if cells.contains_key(&cell_id) { return Err(Error::DuplicateCell(cell_id)); } - let (handle, initial_event, task) = - CellActor::prepare(request, stored_values, host, initial_observe_mode) - .map_err(Error::Runtime)?; + let cell_state = Arc::new(CellState::new(CancellationToken::new())); + let (handle, initial_event, task) = CellActor::prepare( + request, + stored_values, + host, + initial_observe_mode, + cell_state, + ) + .map_err(Error::Runtime)?; cells.insert(cell_id.clone(), handle); drop(cells); tokio::spawn(task); @@ -251,12 +259,21 @@ impl CellHost for RuntimeCellHost { .await } - async fn commit_stored_values(&self, stored_value_writes: HashMap) { - self.inner - .stored_values - .lock() - .await - .extend(stored_value_writes); + async fn commit_completion( + &self, + stored_value_writes: HashMap, + event: CellEvent, + cell_state: Arc, + ) -> CompletionCommit { + let cancellation_token = cell_state.cancellation_token(); + let mut stored_values = tokio::select! { + biased; + _ = cancellation_token.cancelled() => { + return CompletionCommit::Rejected(event); + } + stored_values = self.inner.stored_values.lock() => stored_values, + }; + cell_state.commit_completion(event, || stored_values.extend(stored_value_writes)) } async fn closed(&self) { @@ -276,3 +293,7 @@ fn actor_error(cell_id: &CellId, error: CellError) -> Error { CellError::Closed => Error::ClosedCell(cell_id.clone()), } } + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/codex-rs/code-mode/src/session_runtime/tests.rs b/codex-rs/code-mode/src/session_runtime/tests.rs new file mode 100644 index 0000000000..b4457b3f7b --- /dev/null +++ b/codex-rs/code-mode/src/session_runtime/tests.rs @@ -0,0 +1,110 @@ +use std::collections::HashMap; +use std::future::Future; +use std::sync::Arc; +use std::task::Context; +use std::task::Poll; +use std::task::Waker; +use std::time::Duration; + +use pretty_assertions::assert_eq; +use serde_json::Value as JsonValue; +use tokio_util::sync::CancellationToken; + +use super::*; +use crate::cell_actor::CompletionCommit; + +struct RecordingDelegate; + +impl SessionRuntimeDelegate for RecordingDelegate { + async fn invoke_tool( + &self, + _invocation: NestedToolCall, + _cancellation_token: CancellationToken, + ) -> Result { + Ok(JsonValue::Null) + } + + async fn notify( + &self, + _call_id: String, + _cell_id: CellId, + _text: String, + _cancellation_token: CancellationToken, + ) -> Result<(), String> { + Ok(()) + } + + fn cell_closed(&self, _cell_id: &CellId) {} +} + +#[tokio::test] +async fn termination_rejects_a_waiting_store_commit_before_the_next_cell_can_load_it() { + let runtime = SessionRuntime::new(Arc::new(RecordingDelegate)); + let cell_state = Arc::new(CellState::new(CancellationToken::new())); + let host = RuntimeCellHost { + cell_id: CellId::new("terminating-writer"), + inner: Arc::clone(&runtime.inner), + }; + let completion = CellEvent::Completed { + content_items: vec![OutputItem::Text { + text: "uncommitted output".to_string(), + }], + error_text: None, + }; + + let stored_values = runtime.inner.stored_values.lock().await; + let commit = host.commit_completion( + HashMap::from([( + "candidate".to_string(), + JsonValue::String("lost".to_string()), + )]), + completion.clone(), + Arc::clone(&cell_state), + ); + tokio::pin!(commit); + let waker = Waker::noop(); + let mut context = Context::from_waker(waker); + assert!(matches!(commit.as_mut().poll(&mut context), Poll::Pending)); + + let termination = cell_state.request_termination(); + drop(stored_values); + assert_eq!(commit.await, CompletionCommit::Rejected(completion)); + let terminated = CellEvent::Terminated { + content_items: Vec::new(), + }; + assert_eq!( + cell_state.finish_termination(terminated.clone()), + Some(terminated.clone()) + ); + assert_eq!(termination.await, Ok(terminated)); + assert!( + !runtime + .inner + .stored_values + .lock() + .await + .contains_key("candidate") + ); + + let reader = runtime + .execute( + CreateCellRequest { + tool_call_id: "reader".to_string(), + enabled_tools: Vec::new(), + source: r#"text(String(load("candidate")));"#.to_string(), + }, + ObserveMode::YieldAfter(Duration::from_secs(1)), + ) + .await + .unwrap(); + assert_eq!( + reader.initial_event().await, + Ok(CellEvent::Completed { + content_items: vec![OutputItem::Text { + text: "undefined".to_string(), + }], + error_text: None, + }) + ); + runtime.shutdown().await.unwrap(); +} From 9c79d87d0606f1076eb309a609be2bac22cec968 Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Sun, 21 Jun 2026 13:15:38 -0700 Subject: [PATCH 093/112] code-mode: make session shutdown authoritative (#29287) ## Summary - Give each session and cell a hierarchical cancellation token. - Track cell tasks so shutdown waits for admitted actors without polling the registry. - Make shutdown authoritative across concurrent admission and non-cooperative callbacks. ## Why A best-effort registry scan can miss cells admitted concurrently or blocked behind the registry lock. ## Impact Session shutdown reliably stops every admitted cell and rejects new work once shutdown begins. ## Validation - Stack-tip validation: `just test -p codex-code-mode -p codex-code-mode-protocol` (70 passed). - Parent branch: `cconger/code-mode-runtime-compact-03c-terminal-state`. --- codex-rs/code-mode/src/cell_actor/types.rs | 9 +-- codex-rs/code-mode/src/runtime/mod.rs | 2 +- .../code-mode/src/service_contract_tests.rs | 5 +- codex-rs/code-mode/src/service_tests.rs | 2 +- codex-rs/code-mode/src/session_runtime/mod.rs | 49 +++++------- .../code-mode/src/session_runtime/tests.rs | 77 +++++++++++++++++++ 6 files changed, 107 insertions(+), 37 deletions(-) diff --git a/codex-rs/code-mode/src/cell_actor/types.rs b/codex-rs/code-mode/src/cell_actor/types.rs index 020ae3dcb1..ee345d6ab7 100644 --- a/codex-rs/code-mode/src/cell_actor/types.rs +++ b/codex-rs/code-mode/src/cell_actor/types.rs @@ -92,16 +92,13 @@ impl CellHandle { pub(crate) fn terminate(&self) -> CellEventFuture { self.state.request_termination() } - - pub(crate) fn shutdown(&self) { - self.state.cancellation_token().cancel(); - } } /// The single linearization point for a cell's terminal outcome. /// -/// Callback cancellation tokens are children of the cell token, so a terminal -/// decision cancels runtime work and its callbacks together. +/// The cancellation token is a child of the owning session token. Callback +/// tokens are children of this token, so cancellation flows strictly from the +/// session to the cell and then to its callbacks. /// /// The mutex is held only for synchronous phase transitions and terminal /// delivery. Runtime execution, observation waits, and callbacks never run diff --git a/codex-rs/code-mode/src/runtime/mod.rs b/codex-rs/code-mode/src/runtime/mod.rs index bfc54f71be..42af5c0b95 100644 --- a/codex-rs/code-mode/src/runtime/mod.rs +++ b/codex-rs/code-mode/src/runtime/mod.rs @@ -420,7 +420,7 @@ await new Promise(() => {}); .send(RuntimeCommand::TimeoutFired { id: 1 }) .unwrap(); assert!( - tokio::time::timeout(Duration::from_millis(100), event_rx.recv()) + tokio::time::timeout(Duration::from_secs(1), event_rx.recv()) .await .is_err() ); diff --git a/codex-rs/code-mode/src/service_contract_tests.rs b/codex-rs/code-mode/src/service_contract_tests.rs index 9484310323..8ead39640a 100644 --- a/codex-rs/code-mode/src/service_contract_tests.rs +++ b/codex-rs/code-mode/src/service_contract_tests.rs @@ -289,7 +289,10 @@ async fn observed_natural_completion_wins_over_termination() { tokio::time::timeout(Duration::from_secs(1), async { loop { let response = service - .execute(execute_request(r#"text(String(load("finished")));"#)) + .execute(ExecuteRequest { + yield_time_ms: Some(60_000), + ..execute_request(r#"text(String(load("finished")));"#) + }) .await .unwrap() .initial_response() diff --git a/codex-rs/code-mode/src/service_tests.rs b/codex-rs/code-mode/src/service_tests.rs index d90d4b6afb..6344bfdab9 100644 --- a/codex-rs/code-mode/src/service_tests.rs +++ b/codex-rs/code-mode/src/service_tests.rs @@ -360,7 +360,7 @@ await Promise.all([ } ); - tokio::time::sleep(Duration::from_millis(1100)).await; + tokio::time::sleep(Duration::from_secs(2)).await; let resumed_response = tokio::time::timeout( Duration::from_secs(1), diff --git a/codex-rs/code-mode/src/session_runtime/mod.rs b/codex-rs/code-mode/src/session_runtime/mod.rs index 362dccda74..71cf3d6137 100644 --- a/codex-rs/code-mode/src/session_runtime/mod.rs +++ b/codex-rs/code-mode/src/session_runtime/mod.rs @@ -4,13 +4,13 @@ use std::collections::HashMap; use std::future::Future; use std::pin::Pin; use std::sync::Arc; -use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicU64; use std::sync::atomic::Ordering; use serde_json::Value as JsonValue; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; +use tokio_util::task::TaskTracker; pub(crate) use self::types::CellEvent; pub(crate) use self::types::CellId; @@ -43,8 +43,9 @@ pub(crate) struct SessionRuntime { struct Inner { stored_values: Mutex>, cells: Mutex>, + cell_tasks: TaskTracker, + shutdown_token: CancellationToken, delegate: Arc, - shutting_down: AtomicBool, next_cell_id: AtomicU64, } @@ -54,15 +55,16 @@ impl SessionRuntime { inner: Arc::new(Inner { stored_values: Mutex::new(HashMap::new()), cells: Mutex::new(HashMap::new()), + cell_tasks: TaskTracker::new(), + shutdown_token: CancellationToken::new(), delegate, - shutting_down: AtomicBool::new(false), next_cell_id: AtomicU64::new(1), }), } } pub(crate) fn is_alive(&self) -> bool { - !self.inner.shutting_down.load(Ordering::Acquire) + !self.inner.shutdown_token.is_cancelled() } pub(crate) async fn execute( @@ -70,6 +72,9 @@ impl SessionRuntime { request: CreateCellRequest, initial_observe_mode: ObserveMode, ) -> Result { + if self.inner.shutdown_token.is_cancelled() { + return Err(Error::ShuttingDown); + } let cell_id = self.allocate_cell_id(); let initial_event = self .start_cell(cell_id.clone(), request, initial_observe_mode) @@ -122,21 +127,13 @@ impl SessionRuntime { } pub(crate) async fn shutdown(&self) -> Result<(), Error> { - self.inner.shutting_down.store(true, Ordering::Release); - let handles = self - .inner - .cells - .lock() - .await - .values() - .cloned() - .collect::>(); - for handle in handles { - handle.shutdown(); - } - while !self.inner.cells.lock().await.is_empty() { - tokio::task::yield_now().await; - } + self.begin_shutdown(); + // Taking the registry lock ensures every cell that passed the shutdown + // check has registered its actor with the tracker before we wait. + let cells = self.inner.cells.lock().await; + self.inner.cell_tasks.close(); + drop(cells); + self.inner.cell_tasks.wait().await; Ok(()) } @@ -161,13 +158,13 @@ impl SessionRuntime { inner: Arc::clone(&self.inner), }); let mut cells = self.inner.cells.lock().await; - if self.inner.shutting_down.load(Ordering::Acquire) { + if self.inner.shutdown_token.is_cancelled() { return Err(Error::ShuttingDown); } if cells.contains_key(&cell_id) { return Err(Error::DuplicateCell(cell_id)); } - let cell_state = Arc::new(CellState::new(CancellationToken::new())); + let cell_state = Arc::new(CellState::new(self.inner.shutdown_token.child_token())); let (handle, initial_event, task) = CellActor::prepare( request, stored_values, @@ -177,18 +174,14 @@ impl SessionRuntime { ) .map_err(Error::Runtime)?; cells.insert(cell_id.clone(), handle); + self.inner.cell_tasks.spawn(task); drop(cells); - tokio::spawn(task); Ok(map_actor_event(cell_id, initial_event)) } fn begin_shutdown(&self) { - self.inner.shutting_down.store(true, Ordering::Release); - if let Ok(cells) = self.inner.cells.try_lock() { - for handle in cells.values() { - handle.shutdown(); - } - } + self.inner.shutdown_token.cancel(); + self.inner.cell_tasks.close(); } } diff --git a/codex-rs/code-mode/src/session_runtime/tests.rs b/codex-rs/code-mode/src/session_runtime/tests.rs index b4457b3f7b..a1dc18c0e8 100644 --- a/codex-rs/code-mode/src/session_runtime/tests.rs +++ b/codex-rs/code-mode/src/session_runtime/tests.rs @@ -108,3 +108,80 @@ async fn termination_rejects_a_waiting_store_commit_before_the_next_cell_can_loa ); runtime.shutdown().await.unwrap(); } + +fn execute_request(source: &str) -> CreateCellRequest { + CreateCellRequest { + tool_call_id: "call-1".to_string(), + enabled_tools: Vec::new(), + source: source.to_string(), + } +} + +#[tokio::test] +#[expect( + clippy::await_holding_invalid_type, + reason = "test holds the registry lock to force admission ahead of shutdown" +)] +async fn shutdown_rejects_cell_admission_queued_before_the_registry_lock() { + let runtime = Arc::new(SessionRuntime::new(Arc::new(RecordingDelegate))); + let cells = runtime.inner.cells.lock().await; + + let execution = runtime.execute( + execute_request("while (true) {}"), + ObserveMode::YieldAfter(Duration::from_millis(/*millis*/ 1)), + ); + tokio::pin!(execution); + std::future::poll_fn(|context| match execution.as_mut().poll(context) { + Poll::Pending => Poll::Ready(()), + Poll::Ready(Ok(_)) => panic!("execution completed before the registry lock was released"), + Poll::Ready(Err(error)) => { + panic!("execution failed before the registry lock was released: {error}") + } + }) + .await; + + let shutdown = runtime.shutdown(); + tokio::pin!(shutdown); + std::future::poll_fn(|context| match shutdown.as_mut().poll(context) { + Poll::Pending => Poll::Ready(()), + Poll::Ready(Ok(())) => panic!("shutdown completed before acquiring the registry lock"), + Poll::Ready(Err(error)) => { + panic!("shutdown failed before acquiring the registry lock: {error}") + } + }) + .await; + + assert!(!runtime.is_alive()); + drop(cells); + assert!(matches!(execution.await, Err(Error::ShuttingDown))); + assert_eq!(shutdown.await, Ok(())); +} + +#[tokio::test] +async fn drop_terminates_cells_when_the_registry_is_locked() { + let runtime = SessionRuntime::new(Arc::new(RecordingDelegate)); + let started = runtime + .execute( + execute_request("while (true) {}"), + ObserveMode::YieldAfter(Duration::from_millis(/*millis*/ 1)), + ) + .await + .unwrap(); + assert_eq!(started.cell_id, CellId::new("1")); + assert_eq!( + started.initial_event().await, + Ok(CellEvent::Yielded { + content_items: Vec::new(), + }) + ); + + let inner = Arc::clone(&runtime.inner); + let cells = inner.cells.lock().await; + drop(runtime); + drop(cells); + + tokio::time::timeout(Duration::from_secs(/*secs*/ 1), inner.cell_tasks.wait()) + .await + .unwrap(); + assert!(inner.cell_tasks.is_empty()); +} From 6bfc58a688a73c287da51797d99c3345caecbae8 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sun, 21 Jun 2026 13:35:25 -0700 Subject: [PATCH 094/112] [prompting] updated plan mode prompt (#29301) Update plan mode prompt to render the implementation plan to the user on relevant follow-ups, such that the user can exit out of plan mode to implement rather than manually switch of plan mode. --- codex-rs/collaboration-mode-templates/templates/plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/collaboration-mode-templates/templates/plan.md b/codex-rs/collaboration-mode-templates/templates/plan.md index 8a1a934f83..ca68f41c89 100644 --- a/codex-rs/collaboration-mode-templates/templates/plan.md +++ b/codex-rs/collaboration-mode-templates/templates/plan.md @@ -125,4 +125,4 @@ Do not ask "should I proceed?" in the final output. The user can easily switch o Only produce at most one `` block per turn, and only when you are presenting a complete spec. -If the user stays in Plan mode and asks for revisions after a prior ``, any new `` must be a complete replacement. +If the user stays in Plan mode and asks for revisions after a prior ``, any new `` must be a complete replacement. If the user indicates that the prior plan is not acceptable but does not provide enough information to produce a complete replacement, address the concern and continue planning without producing a `` block. If the follow-up neither requires changes nor calls the plan into question (e.g. clarifying question), answer it before the block, then reproduce the prior `` unchanged. From 3b605b9c63f9123b39493b24d95d9a8428813e9f Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Sun, 21 Jun 2026 13:53:37 -0700 Subject: [PATCH 095/112] code-mode: preserve dropped observation output (#29288) ## Summary - Restore yielded output when an observation receiver disappears before delivery. - Preserve pending-frontier output and tool IDs across failed delivery. - Add dropped-observer coverage for yield and pending observations. ## Why Canceling a wait must not consume output or a pending frontier that the caller never received. ## Impact A later observation can recover undelivered incremental output without duplication. ## Validation - Stack-tip validation: `just test -p codex-code-mode -p codex-code-mode-protocol` (70 passed). - Parent branch: `cconger/code-mode-runtime-compact-03e-shutdown-hierarchy`. --- codex-rs/code-mode/src/cell_actor/mod.rs | 112 ++++++++-- codex-rs/code-mode/src/cell_actor/tests.rs | 246 ++++++++++++++++++++- 2 files changed, 337 insertions(+), 21 deletions(-) diff --git a/codex-rs/code-mode/src/cell_actor/mod.rs b/codex-rs/code-mode/src/cell_actor/mod.rs index d288bd2dbe..8379f372d8 100644 --- a/codex-rs/code-mode/src/cell_actor/mod.rs +++ b/codex-rs/code-mode/src/cell_actor/mod.rs @@ -118,6 +118,7 @@ async fn run_cell( let callback_cancellation_token = cancellation_token.child_token(); let mut content_items = Vec::new(); let mut pending_tool_call_ids = Vec::new(); + let mut pending_frontier_ready = false; let mut observer = Some(initial_observer); let mut termination = false; let mut runtime_closed = false; @@ -169,6 +170,9 @@ async fn run_cell( cancellation_token.cancel(); continue; }; + if response_tx.is_closed() { + continue; + } let response_tx = match cell_state.route_observation(response_tx) { ObservationDelivery::Running(response_tx) => response_tx, ObservationDelivery::Delivered => break, @@ -185,8 +189,36 @@ async fn run_cell( let _ = response_tx.send(Err(CellError::Busy)); continue; } + if matches!(mode, ObserveMode::PendingFrontier) && pending_frontier_ready { + pending_frontier_ready = false; + match send_cell_event( + response_tx, + CellEvent::Pending { + content_items: std::mem::take(&mut content_items), + pending_tool_call_ids: std::mem::take(&mut pending_tool_call_ids), + }, + ) { + Ok(()) => {} + Err(CellEvent::Pending { + content_items: undelivered_items, + pending_tool_call_ids: undelivered_tool_call_ids, + }) => { + content_items = undelivered_items; + pending_tool_call_ids = undelivered_tool_call_ids; + pending_frontier_ready = true; + } + Err(event) => { + panic!("pending delivery returned an unexpected event: {event:?}") + } + } + continue; + } observer = Some(Observer { mode, response_tx }); yield_timer = observer.as_ref().and_then(observer_timer); + if runtime_paused && matches!(mode, ObserveMode::YieldAfter(_)) { + pending_frontier_ready = false; + pending_tool_call_ids.clear(); + } resume_for_observation( mode, &mut runtime_paused, @@ -202,11 +234,14 @@ async fn run_cell( } } => { yield_timer = None; - send_observer_event( - observer.take(), - CellEvent::Yielded { - content_items: std::mem::take(&mut content_items), - }, + restore_undelivered_yield( + send_observer_event( + observer.take(), + CellEvent::Yielded { + content_items: std::mem::take(&mut content_items), + }, + ), + &mut content_items, ); } maybe_event = async { @@ -285,7 +320,8 @@ async fn run_cell( Some(ObserveMode::PendingFrontier) ) { yield_timer = None; - send_observer_event( + pending_frontier_ready = false; + match send_observer_event( observer.take(), CellEvent::Pending { content_items: std::mem::take(&mut content_items), @@ -293,7 +329,20 @@ async fn run_cell( &mut pending_tool_call_ids, ), }, - ); + ) { + Ok(()) => {} + Err(CellEvent::Pending { + content_items: undelivered_items, + pending_tool_call_ids: undelivered_tool_call_ids, + }) => { + content_items = undelivered_items; + pending_tool_call_ids = undelivered_tool_call_ids; + pending_frontier_ready = true; + } + Err(event) => { + panic!("pending delivery returned an unexpected event: {event:?}") + } + } } else { pending_tool_call_ids.clear(); let _ = runtime_control_tx.send(RuntimeControlCommand::Continue); @@ -302,16 +351,20 @@ async fn run_cell( } RuntimeEvent::ContentItem(item) => content_items.push(output_item(item)), RuntimeEvent::YieldRequested => { - if matches!( + let yield_observer = matches!( observer.as_ref().map(|observer| observer.mode), Some(ObserveMode::YieldAfter(_)) - ) { + ); + if yield_observer { yield_timer = None; - send_observer_event( - observer.take(), - CellEvent::Yielded { - content_items: std::mem::take(&mut content_items), - }, + restore_undelivered_yield( + send_observer_event( + observer.take(), + CellEvent::Yielded { + content_items: std::mem::take(&mut content_items), + }, + ), + &mut content_items, ); } } @@ -429,9 +482,34 @@ async fn run_cell( host.closed().await; } -fn send_observer_event(observer: Option, event: CellEvent) { - if let Some(observer) = observer { - let _ = observer.response_tx.send(Ok(event)); +fn send_observer_event(observer: Option, event: CellEvent) -> Result<(), CellEvent> { + let Some(observer) = observer else { + return Err(event); + }; + send_cell_event(observer.response_tx, event) +} + +fn send_cell_event( + response_tx: oneshot::Sender>, + event: CellEvent, +) -> Result<(), CellEvent> { + match response_tx.send(Ok(event)) { + Ok(()) => Ok(()), + Err(Ok(event)) => Err(event), + Err(Err(error)) => panic!("cell event delivery returned an actor error: {error:?}"), + } +} + +fn restore_undelivered_yield(delivery: Result<(), CellEvent>, content_items: &mut Vec) { + match delivery { + Ok(()) => {} + Err(CellEvent::Yielded { + content_items: mut undelivered_items, + }) => { + undelivered_items.append(content_items); + *content_items = undelivered_items; + } + Err(event) => panic!("yield delivery returned an unexpected event: {event:?}"), } } diff --git a/codex-rs/code-mode/src/cell_actor/tests.rs b/codex-rs/code-mode/src/cell_actor/tests.rs index 7404b9fe10..e217122b27 100644 --- a/codex-rs/code-mode/src/cell_actor/tests.rs +++ b/codex-rs/code-mode/src/cell_actor/tests.rs @@ -1,5 +1,8 @@ use std::collections::HashMap; use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::sync::mpsc as std_mpsc; use std::time::Duration; use codex_code_mode_protocol::ExecuteRequest; @@ -11,10 +14,15 @@ use tokio::sync::oneshot; use tokio_util::sync::CancellationToken; use super::*; -use crate::session_runtime::OutputItem as CellOutputItem; +use crate::session_runtime::OutputItem; struct TestHost; +#[derive(Default)] +struct RecordingHost { + notified: AtomicBool, +} + impl CellHost for TestHost { async fn invoke_tool( &self, @@ -45,20 +53,59 @@ impl CellHost for TestHost { async fn closed(&self) {} } +impl CellHost for RecordingHost { + async fn invoke_tool( + &self, + _invocation: CellToolCall, + _cancellation_token: CancellationToken, + ) -> Result { + Err("unexpected tool call".to_string()) + } + + async fn notify( + &self, + _call_id: String, + _text: String, + _cancellation_token: CancellationToken, + ) -> Result<(), String> { + self.notified.store(true, Ordering::Release); + Ok(()) + } + + async fn commit_completion( + &self, + _stored_value_writes: HashMap, + event: CellEvent, + cell_state: Arc, + ) -> CompletionCommit { + cell_state.commit_completion(event, || {}) + } + + async fn closed(&self) {} +} + struct CellActorHarness { event_tx: mpsc::UnboundedSender, handle: CellHandle, initial_event_rx: oneshot::Receiver>, task: tokio::task::JoinHandle<()>, + runtime_control_rx: std_mpsc::Receiver, _runtime_event_rx: mpsc::UnboundedReceiver, } fn spawn_cell_actor_harness(initial_observe_mode: ObserveMode) -> CellActorHarness { + spawn_cell_actor_harness_with_host(initial_observe_mode, Arc::new(TestHost)) +} + +fn spawn_cell_actor_harness_with_host( + initial_observe_mode: ObserveMode, + host: Arc, +) -> CellActorHarness { let (event_tx, event_rx) = mpsc::unbounded_channel(); let (command_tx, command_rx) = mpsc::unbounded_channel(); let (initial_event_tx, initial_event_rx) = oneshot::channel(); let (runtime_event_tx, runtime_event_rx) = mpsc::unbounded_channel(); - let (runtime_tx, runtime_control_tx, runtime_terminate_handle) = spawn_runtime( + let (runtime_tx, _runtime_control_tx, runtime_terminate_handle) = spawn_runtime( HashMap::new(), ExecuteRequest { tool_call_id: "call-1".to_string(), @@ -71,10 +118,11 @@ fn spawn_cell_actor_harness(initial_observe_mode: ObserveMode) -> CellActorHarne PendingRuntimeMode::PauseUntilResumed, ) .unwrap(); + let (runtime_control_tx, runtime_control_rx) = std_mpsc::channel(); let cell_state = Arc::new(CellState::new(CancellationToken::new())); let handle = CellHandle::new(command_tx, Arc::clone(&cell_state)); let task = tokio::spawn(run_cell( - Arc::new(TestHost), + host, CellContext { runtime_tx, runtime_control_tx, @@ -94,10 +142,21 @@ fn spawn_cell_actor_harness(initial_observe_mode: ObserveMode) -> CellActorHarne handle, initial_event_rx, task, + runtime_control_rx, _runtime_event_rx: runtime_event_rx, } } +async fn wait_for_notification(host: &RecordingHost) { + tokio::time::timeout(Duration::from_secs(1), async { + while !host.notified.load(Ordering::Acquire) { + tokio::task::yield_now().await; + } + }) + .await + .expect("notification barrier timed out"); +} + #[tokio::test] async fn yield_timer_preempts_buffered_runtime_output() { let harness = spawn_cell_actor_harness(ObserveMode::YieldAfter(Duration::ZERO)); @@ -123,7 +182,7 @@ async fn yield_timer_preempts_buffered_runtime_output() { assert_eq!( termination.await, Ok(CellEvent::Terminated { - content_items: vec![CellOutputItem::Text { + content_items: vec![OutputItem::Text { text: "queued output".to_string(), }], }) @@ -151,6 +210,185 @@ async fn queued_termination_preempts_unobserved_runtime_completion() { harness.task.await.unwrap(); } +#[tokio::test] +async fn observation_dropped_before_dequeue_does_not_consume_output() { + let host = Arc::new(RecordingHost::default()); + let harness = spawn_cell_actor_harness_with_host( + ObserveMode::YieldAfter(Duration::from_secs(60)), + Arc::clone(&host), + ); + harness.event_tx.send(RuntimeEvent::YieldRequested).unwrap(); + assert!(harness.initial_event_rx.await.unwrap().is_ok()); + + drop( + harness + .handle + .observe(ObserveMode::YieldAfter(Duration::from_secs(60))), + ); + harness + .event_tx + .send(RuntimeEvent::ContentItem( + FunctionCallOutputContentItem::InputText { + text: "survives pre-dequeue cancellation".to_string(), + }, + )) + .unwrap(); + harness.event_tx.send(RuntimeEvent::YieldRequested).unwrap(); + harness + .event_tx + .send(RuntimeEvent::Notify { + call_id: "after-dropped-command".to_string(), + text: "barrier".to_string(), + }) + .unwrap(); + wait_for_notification(&host).await; + + assert_eq!( + harness + .handle + .observe(ObserveMode::YieldAfter(Duration::ZERO)) + .await, + Ok(CellEvent::Yielded { + content_items: vec![OutputItem::Text { + text: "survives pre-dequeue cancellation".to_string(), + }], + }) + ); + + let termination = harness.handle.terminate(); + drop(harness.event_tx); + assert_eq!( + termination.await, + Ok(CellEvent::Terminated { + content_items: Vec::new(), + }) + ); + harness.task.await.unwrap(); +} + +#[tokio::test] +async fn dropped_yield_observer_preserves_output_for_the_next_observation() { + let host = Arc::new(RecordingHost::default()); + let harness = spawn_cell_actor_harness_with_host( + ObserveMode::YieldAfter(Duration::from_secs(60)), + Arc::clone(&host), + ); + harness.event_tx.send(RuntimeEvent::YieldRequested).unwrap(); + assert!(harness.initial_event_rx.await.unwrap().is_ok()); + + let dropped_observation = harness + .handle + .observe(ObserveMode::YieldAfter(Duration::from_secs(60))); + assert_eq!( + harness + .handle + .observe(ObserveMode::YieldAfter(Duration::ZERO)) + .await, + Err(CellError::Busy) + ); + drop(dropped_observation); + harness + .event_tx + .send(RuntimeEvent::ContentItem( + FunctionCallOutputContentItem::InputText { + text: "survives active cancellation".to_string(), + }, + )) + .unwrap(); + harness.event_tx.send(RuntimeEvent::YieldRequested).unwrap(); + harness + .event_tx + .send(RuntimeEvent::Notify { + call_id: "after-dropped-observer".to_string(), + text: "barrier".to_string(), + }) + .unwrap(); + wait_for_notification(&host).await; + + assert_eq!( + harness + .handle + .observe(ObserveMode::YieldAfter(Duration::ZERO)) + .await, + Ok(CellEvent::Yielded { + content_items: vec![OutputItem::Text { + text: "survives active cancellation".to_string(), + }], + }) + ); + + let termination = harness.handle.terminate(); + drop(harness.event_tx); + assert_eq!( + termination.await, + Ok(CellEvent::Terminated { + content_items: Vec::new(), + }) + ); + harness.task.await.unwrap(); +} + +#[tokio::test] +async fn dropped_pending_observer_preserves_the_frontier_for_the_next_observation() { + let host = Arc::new(RecordingHost::default()); + let harness = spawn_cell_actor_harness_with_host( + ObserveMode::YieldAfter(Duration::from_secs(60)), + Arc::clone(&host), + ); + harness.event_tx.send(RuntimeEvent::YieldRequested).unwrap(); + assert!(harness.initial_event_rx.await.unwrap().is_ok()); + + let dropped_observation = harness.handle.observe(ObserveMode::PendingFrontier); + assert_eq!( + harness.handle.observe(ObserveMode::PendingFrontier).await, + Err(CellError::Busy) + ); + drop(dropped_observation); + harness + .event_tx + .send(RuntimeEvent::ToolCall { + id: "tool-1".to_string(), + name: codex_protocol::ToolName { + name: "echo".to_string(), + namespace: None, + }, + kind: codex_code_mode_protocol::CodeModeToolKind::Function, + input: Some(serde_json::json!({})), + }) + .unwrap(); + harness.event_tx.send(RuntimeEvent::Pending).unwrap(); + harness + .event_tx + .send(RuntimeEvent::Notify { + call_id: "after-dropped-pending".to_string(), + text: "barrier".to_string(), + }) + .unwrap(); + wait_for_notification(&host).await; + + assert_eq!( + harness.handle.observe(ObserveMode::PendingFrontier).await, + Ok(CellEvent::Pending { + content_items: Vec::new(), + pending_tool_call_ids: vec!["tool-1".to_string()], + }) + ); + assert!(matches!( + harness.runtime_control_rx.try_recv(), + Err(std_mpsc::TryRecvError::Empty) + )); + + let termination = harness.handle.terminate(); + drop(harness.event_tx); + assert_eq!( + termination.await, + Ok(CellEvent::Terminated { + content_items: Vec::new(), + }) + ); + harness.task.await.unwrap(); +} + #[tokio::test] async fn only_the_first_termination_claims_a_buffered_completion() { let cell_state = CellState::new(CancellationToken::new()); From eb8c1ee85f18d055d00b3a0912dd0e55e62a82e8 Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Sun, 21 Jun 2026 15:35:01 -0700 Subject: [PATCH 096/112] code-mode: preserve initial yield at completion (#29289) ## Summary - Retain the first pre-observation `yield_control()` boundary when a cell completes before observation. - Deliver the preserved yield before the buffered completion. - Keep later unattached yields as no-ops. ## Why Create followed by the initial wait must preserve the former execute response boundary even when the script runs to completion first. ## Impact The first wait observes the same initial yield boundary as before create and observe were decoupled. ## Validation - Focused initial-yield signature regression passed. - Stack-tip validation: `just test -p codex-code-mode -p codex-code-mode-protocol` (70 passed). - Parent branch: `cconger/code-mode-runtime-compact-03e2-observation-delivery`. --- codex-rs/code-mode/src/cell_actor/mod.rs | 4 +- codex-rs/code-mode/src/cell_actor/tests.rs | 169 +++++++++++++++++- codex-rs/code-mode/src/cell_actor/types.rs | 158 +++++++++++++--- codex-rs/code-mode/src/session_runtime/mod.rs | 5 +- .../code-mode/src/session_runtime/tests.rs | 1 + 5 files changed, 304 insertions(+), 33 deletions(-) diff --git a/codex-rs/code-mode/src/cell_actor/mod.rs b/codex-rs/code-mode/src/cell_actor/mod.rs index 8379f372d8..24eca22517 100644 --- a/codex-rs/code-mode/src/cell_actor/mod.rs +++ b/codex-rs/code-mode/src/cell_actor/mod.rs @@ -173,7 +173,7 @@ async fn run_cell( if response_tx.is_closed() { continue; } - let response_tx = match cell_state.route_observation(response_tx) { + let response_tx = match cell_state.route_observation(mode, response_tx) { ObservationDelivery::Running(response_tx) => response_tx, ObservationDelivery::Delivered => break, ObservationDelivery::Buffered | ObservationDelivery::Closed => continue, @@ -284,6 +284,7 @@ async fn run_cell( .commit_completion( HashMap::new(), event, + /*pending_initial_yield_items*/ None, Arc::clone(&cell_state), ) .await @@ -429,6 +430,7 @@ async fn run_cell( .commit_completion( stored_value_writes, event, + /*pending_initial_yield_items*/ None, Arc::clone(&cell_state), ) .await diff --git a/codex-rs/code-mode/src/cell_actor/tests.rs b/codex-rs/code-mode/src/cell_actor/tests.rs index e217122b27..a4d93fc3e6 100644 --- a/codex-rs/code-mode/src/cell_actor/tests.rs +++ b/codex-rs/code-mode/src/cell_actor/tests.rs @@ -45,9 +45,10 @@ impl CellHost for TestHost { &self, _stored_value_writes: HashMap, event: CellEvent, + pending_initial_yield_items: Option>, cell_state: Arc, ) -> CompletionCommit { - cell_state.commit_completion(event, || {}) + cell_state.commit_completion(event, pending_initial_yield_items, || {}) } async fn closed(&self) {} @@ -76,9 +77,10 @@ impl CellHost for RecordingHost { &self, _stored_value_writes: HashMap, event: CellEvent, + pending_initial_yield_items: Option>, cell_state: Arc, ) -> CompletionCommit { - cell_state.commit_completion(event, || {}) + cell_state.commit_completion(event, pending_initial_yield_items, || {}) } async fn closed(&self) {} @@ -397,7 +399,11 @@ async fn only_the_first_termination_claims_a_buffered_completion() { error_text: None, }; assert_eq!( - cell_state.commit_completion(completion.clone(), || {}), + cell_state.commit_completion( + completion.clone(), + /*pending_initial_yield_items*/ None, + || {} + ), CompletionCommit::Committed ); assert!(matches!( @@ -430,7 +436,11 @@ async fn termination_claim_prevents_stored_value_commit() { }; assert_eq!( - cell_state.commit_completion(completion.clone(), || commit_ran = true), + cell_state.commit_completion( + completion.clone(), + /*pending_initial_yield_items*/ None, + || commit_ran = true + ), CompletionCommit::Rejected(completion) ); assert!(!commit_ran); @@ -453,7 +463,11 @@ fn failed_completion_delivery_rebuffers_the_event() { error_text: None, }; assert_eq!( - cell_state.commit_completion(event.clone(), || {}), + cell_state.commit_completion( + event.clone(), + /*pending_initial_yield_items*/ None, + || {} + ), CompletionCommit::Committed ); let (response_tx, response_rx) = oneshot::channel(); @@ -466,8 +480,151 @@ fn failed_completion_delivery_rebuffers_the_event() { let (response_tx, mut response_rx) = oneshot::channel(); assert!(matches!( - cell_state.route_observation(response_tx), + cell_state.route_observation(ObserveMode::YieldAfter(Duration::ZERO), response_tx), ObservationDelivery::Delivered )); assert_eq!(response_rx.try_recv(), Ok(Ok(event))); } + +#[test] +fn buffered_initial_yield_precedes_buffered_completion_for_yield_observer() { + let cell_state = CellState::new(CancellationToken::new()); + let completion = CellEvent::Completed { + content_items: vec![OutputItem::Text { + text: "after".to_string(), + }], + error_text: None, + }; + assert_eq!( + cell_state.commit_completion( + completion.clone(), + Some(vec![OutputItem::Text { + text: "before".to_string(), + }]), + || {} + ), + CompletionCommit::Committed + ); + assert!(matches!( + cell_state.deliver_completion(/*response_tx*/ None), + CompletionDelivery::Buffered + )); + + let (response_tx, mut response_rx) = oneshot::channel(); + assert!(matches!( + cell_state.route_observation(ObserveMode::YieldAfter(Duration::ZERO), response_tx), + ObservationDelivery::Buffered + )); + assert_eq!( + response_rx.try_recv(), + Ok(Ok(CellEvent::Yielded { + content_items: vec![OutputItem::Text { + text: "before".to_string(), + }], + })) + ); + + let (response_tx, mut response_rx) = oneshot::channel(); + assert!(matches!( + cell_state.route_observation(ObserveMode::YieldAfter(Duration::ZERO), response_tx), + ObservationDelivery::Delivered + )); + assert_eq!(response_rx.try_recv(), Ok(Ok(completion))); +} + +#[test] +fn pending_observer_merges_initial_yield_and_completion_output() { + let cell_state = CellState::new(CancellationToken::new()); + assert_eq!( + cell_state.commit_completion( + CellEvent::Completed { + content_items: vec![OutputItem::Text { + text: "after".to_string(), + }], + error_text: None, + }, + Some(vec![OutputItem::Text { + text: "before".to_string(), + }]), + || {} + ), + CompletionCommit::Committed + ); + assert!(matches!( + cell_state.deliver_completion(/*response_tx*/ None), + CompletionDelivery::Buffered + )); + + let (response_tx, mut response_rx) = oneshot::channel(); + assert!(matches!( + cell_state.route_observation(ObserveMode::PendingFrontier, response_tx), + ObservationDelivery::Delivered + )); + assert_eq!( + response_rx.try_recv(), + Ok(Ok(CellEvent::Completed { + content_items: vec![ + OutputItem::Text { + text: "before".to_string(), + }, + OutputItem::Text { + text: "after".to_string(), + }, + ], + error_text: None, + })) + ); +} + +#[test] +fn dropped_pending_observation_preserves_the_initial_yield_boundary() { + let cell_state = CellState::new(CancellationToken::new()); + let completion = CellEvent::Completed { + content_items: vec![OutputItem::Text { + text: "after".to_string(), + }], + error_text: None, + }; + assert_eq!( + cell_state.commit_completion( + completion.clone(), + Some(vec![OutputItem::Text { + text: "before".to_string(), + }]), + || {} + ), + CompletionCommit::Committed + ); + assert!(matches!( + cell_state.deliver_completion(/*response_tx*/ None), + CompletionDelivery::Buffered + )); + + let (response_tx, response_rx) = oneshot::channel(); + drop(response_rx); + assert!(matches!( + cell_state.route_observation(ObserveMode::PendingFrontier, response_tx), + ObservationDelivery::Buffered + )); + + let (response_tx, mut response_rx) = oneshot::channel(); + assert!(matches!( + cell_state.route_observation(ObserveMode::YieldAfter(Duration::ZERO), response_tx), + ObservationDelivery::Buffered + )); + assert_eq!( + response_rx.try_recv(), + Ok(Ok(CellEvent::Yielded { + content_items: vec![OutputItem::Text { + text: "before".to_string(), + }], + })) + ); + + let (response_tx, mut response_rx) = oneshot::channel(); + assert!(matches!( + cell_state.route_observation(ObserveMode::YieldAfter(Duration::ZERO), response_tx), + ObservationDelivery::Delivered + )); + assert_eq!(response_rx.try_recv(), Ok(Ok(completion))); +} diff --git a/codex-rs/code-mode/src/cell_actor/types.rs b/codex-rs/code-mode/src/cell_actor/types.rs index ee345d6ab7..672820b93e 100644 --- a/codex-rs/code-mode/src/cell_actor/types.rs +++ b/codex-rs/code-mode/src/cell_actor/types.rs @@ -11,6 +11,7 @@ use tokio_util::sync::CancellationToken; use crate::session_runtime::CellEvent; use crate::session_runtime::ObserveMode; +use crate::session_runtime::OutputItem; use crate::session_runtime::ToolKind; use crate::session_runtime::ToolName; @@ -54,6 +55,7 @@ pub(crate) trait CellHost: Send + Sync + 'static { &self, stored_value_writes: HashMap, event: CellEvent, + pending_initial_yield_items: Option>, cell_state: Arc, ) -> impl Future + Send; @@ -113,7 +115,11 @@ enum CellPhase { Terminating { response_tx: oneshot::Sender>, }, - Completed(CellEvent), + Completed { + // Set only when `yield_control()` races the create-to-first-observe handoff. + pending_initial_yield_items: Option>, + event: CellEvent, + }, CompletionClaimed(CellEvent), Tombstone, } @@ -152,7 +158,7 @@ impl CellState { .phase .lock() .unwrap_or_else(std::sync::PoisonError::into_inner), - CellPhase::Running | CellPhase::Completed(_) + CellPhase::Running | CellPhase::Completed { .. } ); accepting_phase && !self.cancellation_token.is_cancelled() } @@ -173,7 +179,11 @@ impl CellState { *phase = CellPhase::Terminating { response_tx }; Box::pin(async { Err(CellError::AlreadyTerminating) }) } - CellPhase::Completed(event) => { + CellPhase::Completed { + pending_initial_yield_items, + event, + } => { + let event = prepend_initial_yield(event, pending_initial_yield_items); *phase = CellPhase::CompletionClaimed(event.clone()); self.cancellation_token.cancel(); ready_event(event) @@ -189,6 +199,7 @@ impl CellState { pub(crate) fn commit_completion( &self, event: CellEvent, + pending_initial_yield_items: Option>, commit: impl FnOnce(), ) -> CompletionCommit { let mut phase = self @@ -199,7 +210,10 @@ impl CellState { return CompletionCommit::Rejected(event); } commit(); - *phase = CellPhase::Completed(event); + *phase = CellPhase::Completed { + pending_initial_yield_items, + event, + }; CompletionCommit::Committed } @@ -211,15 +225,22 @@ impl CellState { .phase .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let event = match std::mem::replace(&mut *phase, CellPhase::Tombstone) { - CellPhase::Completed(event) => event, - previous => { - *phase = previous; - return CompletionDelivery::Rejected(response_tx); - } - }; + let (pending_initial_yield_items, event) = + match std::mem::replace(&mut *phase, CellPhase::Tombstone) { + CellPhase::Completed { + pending_initial_yield_items, + event, + } => (pending_initial_yield_items, event), + previous => { + *phase = previous; + return CompletionDelivery::Rejected(response_tx); + } + }; let Some(response_tx) = response_tx else { - *phase = CellPhase::Completed(event); + *phase = CellPhase::Completed { + pending_initial_yield_items, + event, + }; return CompletionDelivery::Buffered; }; match response_tx.send(Ok(event)) { @@ -228,7 +249,10 @@ impl CellState { CompletionDelivery::Delivered } Err(Ok(event)) => { - *phase = CellPhase::Completed(event); + *phase = CellPhase::Completed { + pending_initial_yield_items, + event, + }; CompletionDelivery::Buffered } Err(Err(error)) => { @@ -239,6 +263,7 @@ impl CellState { pub(crate) fn route_observation( &self, + mode: ObserveMode, response_tx: oneshot::Sender>, ) -> ObservationDelivery { let mut phase = self @@ -250,19 +275,56 @@ impl CellState { *phase = CellPhase::Running; ObservationDelivery::Running(response_tx) } - CellPhase::Completed(event) => match response_tx.send(Ok(event)) { - Ok(()) => { - self.cancellation_token.cancel(); - ObservationDelivery::Delivered + CellPhase::Completed { + pending_initial_yield_items: Some(content_items), + event, + } if matches!(mode, ObserveMode::YieldAfter(_)) => { + match response_tx.send(Ok(CellEvent::Yielded { content_items })) { + Ok(()) => { + *phase = CellPhase::Completed { + pending_initial_yield_items: None, + event, + }; + ObservationDelivery::Buffered + } + Err(Ok(CellEvent::Yielded { content_items })) => { + *phase = CellPhase::Completed { + pending_initial_yield_items: Some(content_items), + event, + }; + ObservationDelivery::Buffered + } + Err(Ok(event)) => { + panic!("initial yield delivery returned an unexpected event: {event:?}") + } + Err(Err(error)) => { + panic!("initial yield delivery returned an actor error: {error:?}") + } } - Err(Ok(event)) => { - *phase = CellPhase::Completed(event); - ObservationDelivery::Buffered + } + CellPhase::Completed { + pending_initial_yield_items, + event, + } => { + let delivered_event = + prepend_initial_yield(event.clone(), pending_initial_yield_items.clone()); + match response_tx.send(Ok(delivered_event)) { + Ok(()) => { + self.cancellation_token.cancel(); + ObservationDelivery::Delivered + } + Err(Ok(_)) => { + *phase = CellPhase::Completed { + pending_initial_yield_items, + event, + }; + ObservationDelivery::Buffered + } + Err(Err(error)) => { + panic!("completion delivery unexpectedly carried an actor error: {error:?}") + } } - Err(Err(error)) => { - panic!("completion delivery unexpectedly carried an actor error: {error:?}") - } - }, + } CellPhase::Terminating { response_tx: termination_tx, } => { @@ -295,7 +357,10 @@ impl CellState { let _ = response_tx.send(Ok(event.clone())); Some(event) } - CellPhase::Completed(completed_event) => Some(completed_event), + CellPhase::Completed { + pending_initial_yield_items, + event, + } => Some(prepend_initial_yield(event, pending_initial_yield_items)), CellPhase::CompletionClaimed(completed_event) => Some(completed_event), CellPhase::Tombstone => None, }; @@ -316,6 +381,49 @@ impl CellState { } } +fn prepend_initial_yield( + event: CellEvent, + pending_initial_yield_items: Option>, +) -> CellEvent { + let Some(mut pending_initial_yield_items) = pending_initial_yield_items else { + return event; + }; + match event { + CellEvent::Yielded { mut content_items } => { + pending_initial_yield_items.append(&mut content_items); + CellEvent::Yielded { + content_items: pending_initial_yield_items, + } + } + CellEvent::Pending { + mut content_items, + pending_tool_call_ids, + } => { + pending_initial_yield_items.append(&mut content_items); + CellEvent::Pending { + content_items: pending_initial_yield_items, + pending_tool_call_ids, + } + } + CellEvent::Completed { + mut content_items, + error_text, + } => { + pending_initial_yield_items.append(&mut content_items); + CellEvent::Completed { + content_items: pending_initial_yield_items, + error_text, + } + } + CellEvent::Terminated { mut content_items } => { + pending_initial_yield_items.append(&mut content_items); + CellEvent::Terminated { + content_items: pending_initial_yield_items, + } + } + } +} + pub(super) enum CellCommand { Observe { mode: ObserveMode, diff --git a/codex-rs/code-mode/src/session_runtime/mod.rs b/codex-rs/code-mode/src/session_runtime/mod.rs index 71cf3d6137..0f7557a750 100644 --- a/codex-rs/code-mode/src/session_runtime/mod.rs +++ b/codex-rs/code-mode/src/session_runtime/mod.rs @@ -256,6 +256,7 @@ impl CellHost for RuntimeCellHost { &self, stored_value_writes: HashMap, event: CellEvent, + pending_initial_yield_items: Option>, cell_state: Arc, ) -> CompletionCommit { let cancellation_token = cell_state.cancellation_token(); @@ -266,7 +267,9 @@ impl CellHost for RuntimeCellHost { } stored_values = self.inner.stored_values.lock() => stored_values, }; - cell_state.commit_completion(event, || stored_values.extend(stored_value_writes)) + cell_state.commit_completion(event, pending_initial_yield_items, || { + stored_values.extend(stored_value_writes); + }) } async fn closed(&self) { diff --git a/codex-rs/code-mode/src/session_runtime/tests.rs b/codex-rs/code-mode/src/session_runtime/tests.rs index a1dc18c0e8..e0838d89d8 100644 --- a/codex-rs/code-mode/src/session_runtime/tests.rs +++ b/codex-rs/code-mode/src/session_runtime/tests.rs @@ -59,6 +59,7 @@ async fn termination_rejects_a_waiting_store_commit_before_the_next_cell_can_loa JsonValue::String("lost".to_string()), )]), completion.clone(), + /*pending_initial_yield_items*/ None, Arc::clone(&cell_state), ); tokio::pin!(commit); From b21f0e7a98c05a570cc227e2ec62a2e29c3a7225 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sun, 21 Jun 2026 20:11:50 -0700 Subject: [PATCH 097/112] [codex] Add internal auto-compaction opt-out (#28260) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - add a default-on `auto_compaction` feature flag as an internal escape hatch - skip pre-turn, model-switch/hash, and mid-turn automatic compaction when the flag is disabled - preserve manual `/compact` behavior and surface the existing context-window error when the provider runs out of room - add integration coverage for disabled pre-turn and mid-turn compaction ## Motivation Long-running SPO optimization rollouts need the option to preserve their full context and fail on context exhaustion instead of entering another compaction window. This deliberately uses the existing feature-flag mechanism rather than adding a dedicated public config or app-server API. Disable it with: ```sh codex --disable auto_compaction ``` ## Testing - `just test -p codex-features` — 51 passed - `just test -p codex-core auto_compaction_feature_disabled` — 2 passed - `just fix -p codex-core -p codex-features` - `just write-config-schema` - `just test -p codex-core` — the new compaction tests passed; the overall local run had 54 unrelated environment failures, primarily missing first-party test binaries and shell-snapshot timeouts --- codex-rs/core/config.schema.json | 6 + codex-rs/core/src/session/turn.rs | 9 +- codex-rs/core/src/tools/spec_plan.rs | 4 +- codex-rs/core/tests/suite/compact.rs | 208 ++++++++++++++++++++++ codex-rs/core/tests/suite/token_budget.rs | 48 +++++ codex-rs/features/src/lib.rs | 8 + 6 files changed, 281 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 2655f2e743..003ca63393 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -440,6 +440,9 @@ "auth_elicitation": { "type": "boolean" }, + "auto_compaction": { + "type": "boolean" + }, "browser_use": { "type": "boolean" }, @@ -4734,6 +4737,9 @@ "auth_elicitation": { "type": "boolean" }, + "auto_compaction": { + "type": "boolean" + }, "browser_use": { "type": "boolean" }, diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index a35d9b60ca..909048ce66 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -333,7 +333,10 @@ pub(crate) async fn run_turn( } // as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop. - if token_limit_reached && needs_follow_up { + if turn_context.features.enabled(Feature::AutoCompaction) + && token_limit_reached + && needs_follow_up + { if let Err(err) = run_auto_compact( &sess, &turn_context, @@ -844,6 +847,10 @@ async fn run_pre_sampling_compact( turn_context: &Arc, client_session: &mut ModelClientSession, ) -> CodexResult<()> { + if !turn_context.features.enabled(Feature::AutoCompaction) { + return Ok(()); + } + maybe_run_previous_model_inline_compact(sess, turn_context, client_session).await?; let token_status = auto_compact_token_status(sess.as_ref(), turn_context.as_ref()).await; // Compact if the configured auto-compaction budget or usable context window is exhausted. diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index af7acf17a0..3e2268d92d 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -708,7 +708,9 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut } if features.enabled(Feature::TokenBudget) { - planned_tools.add_with_exposure(NewContextWindowHandler, ToolExposure::DirectModelOnly); + if features.enabled(Feature::AutoCompaction) { + planned_tools.add_with_exposure(NewContextWindowHandler, ToolExposure::DirectModelOnly); + } planned_tools.add(GetContextRemainingHandler); } diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 92ec66e161..e40a13dcd4 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1,5 +1,6 @@ use anyhow::Result; use anyhow::anyhow; +use codex_core::CodexThread; use codex_core::compact::SUMMARIZATION_PROMPT; use codex_core::compact::SUMMARY_PREFIX; use codex_core::config::Config; @@ -35,6 +36,7 @@ use core_test_support::responses; use core_test_support::responses::ev_reasoning_item; use core_test_support::responses::mount_models_once; use core_test_support::skip_if_no_network; +use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; @@ -92,6 +94,48 @@ const REMOTE_V2_SUMMARY: &str = "global-instructions-remote-v2-summary"; pub(super) const COMPACT_WARNING_MESSAGE: &str = "Heads up: Long threads and multiple compactions can cause the model to be less accurate. Start a new thread when possible to keep threads small and targeted."; +async fn build_auto_compaction_disabled_codex(server: &MockServer) -> TestCodex { + let mut model_provider = non_openai_model_provider(server); + model_provider.stream_max_retries = Some(0); + test_codex() + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + config.model_context_window = Some(100); + config.model_auto_compact_token_limit = Some(90); + let _ = config.features.disable(Feature::AutoCompaction); + }) + .build(server) + .await + .expect("build codex") +} + +async fn submit_context_window_exceeded_turn(codex: &Arc, text: &str) { + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await + .expect("submit context window exceeded turn"); + let error_message = wait_for_event_match(codex, |event| match event { + EventMsg::Error(err) => Some(err.message.clone()), + _ => None, + }) + .await; + wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + assert!( + error_message.contains("ran out of room in the model's context window"), + "expected context window exceeded message, got {error_message}" + ); +} + fn ev_shell_command_call(call_id: &str, command: &str) -> serde_json::Value { ev_function_call( call_id, @@ -2276,6 +2320,93 @@ async fn pre_sampling_compact_runs_when_comp_hash_changes() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auto_compaction_feature_disabled_skips_comp_hash_model_switch_compaction() { + skip_if_no_network!(); + + let server = MockServer::start().await; + let previous_model = "gpt-5.3-codex"; + let next_model = "gpt-5.2"; + + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![ + model_info_with_optional_comp_hash(previous_model, Some("hash-a")), + model_info_with_optional_comp_hash(next_model, Some("hash-b")), + ], + }, + ) + .await; + let request_log = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_assistant_message("m1", "before switch"), + ev_completed_with_tokens("r1", /*total_tokens*/ 100), + ]), + sse(vec![ + ev_assistant_message("m2", "after switch"), + ev_completed_with_tokens("r2", /*total_tokens*/ 100), + ]), + ], + ) + .await; + let model_provider = non_openai_model_provider(&server); + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model(previous_model) + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + let _ = config.features.disable(Feature::AutoCompaction); + }); + let test = builder.build(&server).await.expect("build test codex"); + + test.codex + .submit(disabled_permission_user_turn( + "before switch", + test.cwd.path().to_path_buf(), + previous_model.to_string(), + )) + .await + .expect("submit first user turn"); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + test.codex + .submit(disabled_permission_user_turn( + "after switch", + test.cwd.path().to_path_buf(), + next_model.to_string(), + )) + .await + .expect("submit second user turn"); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let requests = request_log.requests(); + assert_eq!(models_mock.requests().len(), 1); + assert_eq!( + requests.len(), + 2, + "disabled auto-compaction should skip compaction on a comp-hash model switch" + ); + let first = requests[0].body_json(); + let second = requests[1].body_json(); + assert_eq!(first["model"].as_str(), Some(previous_model)); + assert_eq!(second["model"].as_str(), Some(next_model)); + assert!(second.to_string().contains("before switch")); + assert!(second.to_string().contains("after switch")); + assert!( + !body_contains_text(&second.to_string(), SUMMARIZATION_PROMPT), + "disabled auto-compaction should preserve history instead of requesting a summary" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn pre_sampling_compact_skips_when_either_comp_hash_is_missing() { skip_if_no_network!(); @@ -3655,6 +3786,45 @@ async fn snapshot_request_shape_mid_turn_continuation_compaction() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auto_compaction_feature_disabled_skips_mid_turn_compaction() { + skip_if_no_network!(); + + let server = start_mock_server().await; + let over_limit_tokens = 100 * 95 / 100 + 1; + let first_turn = sse(vec![ + ev_function_call(DUMMY_CALL_ID, DUMMY_FUNCTION_NAME, "{}"), + ev_completed_with_tokens("r1", over_limit_tokens), + ]); + let request_log = mount_sse_sequence( + &server, + vec![ + first_turn, + sse_failed( + "response-failed", + "context_length_exceeded", + CONTEXT_LIMIT_MESSAGE, + ), + ], + ) + .await; + let test = build_auto_compaction_disabled_codex(&server).await; + + submit_context_window_exceeded_turn(&test.codex, FUNCTION_CALL_LIMIT_MSG).await; + + let requests = request_log.requests(); + assert_eq!(requests.len(), 2); + let continuation_request = &requests[1]; + continuation_request.function_call_output(DUMMY_CALL_ID); + assert!( + !body_contains_text( + &continuation_request.body_json().to_string(), + SUMMARIZATION_PROMPT + ), + "disabled auto-compaction should continue without a compaction request" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn auto_compact_clamps_config_limit_to_context_window() { skip_if_no_network!(); @@ -4488,6 +4658,44 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auto_compaction_feature_disabled_skips_pre_turn_compaction() { + skip_if_no_network!(); + + let server = start_mock_server().await; + let first_turn = sse(vec![ + ev_assistant_message("m1", FIRST_REPLY), + ev_completed_with_tokens("r1", /*total_tokens*/ 500), + ]); + let request_log = mount_sse_sequence( + &server, + vec![ + first_turn, + sse_failed( + "response-failed", + "context_length_exceeded", + CONTEXT_LIMIT_MESSAGE, + ), + ], + ) + .await; + let test = build_auto_compaction_disabled_codex(&server).await; + + test.submit_turn("USER_ONE") + .await + .expect("submit first turn"); + submit_context_window_exceeded_turn(&test.codex, "USER_TWO").await; + + let requests = request_log.requests(); + assert_eq!(requests.len(), 2); + let second_request_body = requests[1].body_json().to_string(); + assert!(second_request_body.contains("USER_TWO")); + assert!( + !body_contains_text(&second_request_body, SUMMARIZATION_PROMPT), + "disabled auto-compaction should sample without a pre-turn compaction request" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn snapshot_request_shape_manual_compact_without_previous_user_messages() { skip_if_no_network!(); diff --git a/codex-rs/core/tests/suite/token_budget.rs b/codex-rs/core/tests/suite/token_budget.rs index 4d448f9e7e..ec86ce5946 100644 --- a/codex-rs/core/tests/suite/token_budget.rs +++ b/codex-rs/core/tests/suite/token_budget.rs @@ -549,3 +549,51 @@ async fn new_context_tool_starts_new_window_before_follow_up() -> Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auto_compaction_feature_disabled_hides_new_context_tool() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let responses = mount_sse_sequence( + &server, + vec![sse(vec![ + ev_response_created("resp-1"), + ev_completed("resp-1"), + ])], + ) + .await; + let test = test_codex() + .with_config(|config| { + config.model_context_window = Some(CONFIGURED_CONTEXT_WINDOW); + config + .features + .enable(Feature::TokenBudget) + .expect("test config should allow token budget"); + config + .features + .disable(Feature::AutoCompaction) + .expect("test config should allow disabling auto-compaction"); + }) + .build(&server) + .await?; + + test.submit_turn("preserve the current context window") + .await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 1); + let tool_names = tool_names(&requests[0]); + assert!( + tool_names + .iter() + .any(|name| name == "get_context_remaining"), + "token budget should continue to expose get_context_remaining" + ); + assert!( + !tool_names.iter().any(|name| name == "new_context"), + "disabled auto-compaction should hide new_context" + ); + + Ok(()) +} diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index f2a30b07b6..3a04621fe0 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -231,6 +231,8 @@ pub enum Feature { RealtimeConversation, /// Prevent idle system sleep while a turn is actively running. PreventIdleSleep, + /// Enable automatic context compaction before or during a turn. + AutoCompaction, /// Enable remote compaction v2 over the normal Responses API. RemoteCompactionV2, /// Use Agent Identity for ChatGPT-authenticated sessions. @@ -1335,6 +1337,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Removed, default_enabled: false, }, + FeatureSpec { + id: Feature::AutoCompaction, + key: "auto_compaction", + stage: Stage::Stable, + default_enabled: true, + }, FeatureSpec { id: Feature::RemoteCompactionV2, key: "remote_compaction_v2", From 566f7bf6314cbf213de523a0268d8df89f93ef62 Mon Sep 17 00:00:00 2001 From: Francis Chalissery Date: Sun, 21 Jun 2026 20:39:14 -0700 Subject: [PATCH 098/112] Propagate safety buffering events to app-server clients (#29371) Responses API safety buffering metadata currently stops at the transport boundary, so app-server clients cannot render the in-progress safety review state. This change: - decodes and deduplicates `safety_buffering` metadata from Responses API SSE and WebSocket events without suppressing the original response event - emits a typed core event containing the requested model plus backend use cases and reasons - forwards that event as `turn/safetyBuffering/updated` through app-server v2 and updates generated protocol schemas - keeps the side-channel event out of persisted rollouts and turn timing This supports the Codex Apps buffering UX and depends on the Responses API backend work in https://github.com/openai/openai/pull/1044569 and https://github.com/openai/openai/pull/1044571. Validation: - focused `codex-core` safety-buffering integration test passes - `cargo check -p codex-core -p codex-app-server -p codex-app-server-protocol` - `just fix -p codex-api -p codex-protocol -p codex-core -p codex-app-server-protocol -p codex-app-server -p codex-rollout -p codex-rollout-trace -p codex-otel` - `just fmt` - broad package test run: 4,430/4,492 passed; 62 unrelated local-environment/concurrency failures involved unavailable test binaries, MCP subprocess setup, and app-server timeouts --- .../schema/json/ServerNotification.json | 53 ++++++++++++++ .../codex_app_server_protocol.schemas.json | 55 ++++++++++++++ .../codex_app_server_protocol.v2.schemas.json | 55 ++++++++++++++ ...delSafetyBufferingUpdatedNotification.json | 35 +++++++++ .../schema/typescript/ServerNotification.ts | 3 +- ...ModelSafetyBufferingUpdatedNotification.ts | 5 ++ .../schema/typescript/v2/index.ts | 1 + .../src/protocol/common.rs | 28 +++++++ .../src/protocol/v2/model.rs | 11 +++ codex-rs/app-server/README.md | 1 + .../app-server/src/bespoke_event_handling.rs | 15 ++++ codex-rs/codex-api/src/common.rs | 7 ++ .../src/endpoint/responses_websocket.rs | 11 +++ codex-rs/codex-api/src/sse/responses.rs | 73 +++++++++++++++++++ codex-rs/core/src/session/turn.rs | 13 ++++ codex-rs/core/src/turn_timing.rs | 1 + codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/safety_buffering.rs | 63 ++++++++++++++++ codex-rs/mcp-server/src/codex_tool_runner.rs | 1 + codex-rs/otel/src/events/session_telemetry.rs | 1 + codex-rs/protocol/src/protocol.rs | 10 +++ codex-rs/rollout-trace/src/protocol_event.rs | 2 + codex-rs/rollout/src/policy.rs | 1 + .../tui/src/app/app_server_event_targets.rs | 3 + codex-rs/tui/src/chatwidget/protocol.rs | 1 + 25 files changed, 449 insertions(+), 1 deletion(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ModelSafetyBufferingUpdatedNotification.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ModelSafetyBufferingUpdatedNotification.ts create mode 100644 codex-rs/core/tests/suite/safety_buffering.rs diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 6ba699dce7..f1fbee5c57 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2583,6 +2583,39 @@ ], "type": "object" }, + "ModelSafetyBufferingUpdatedNotification": { + "properties": { + "model": { + "type": "string" + }, + "reasons": { + "items": { + "type": "string" + }, + "type": "array" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "useCases": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "model", + "reasons", + "threadId", + "turnId", + "useCases" + ], + "type": "object" + }, "ModelVerification": { "enum": [ "trustedAccessForCyber" @@ -6608,6 +6641,26 @@ "title": "Turn/moderationMetadataNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "model/safetyBuffering/updated" + ], + "title": "Model/safetyBuffering/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ModelSafetyBufferingUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Model/safetyBuffering/updatedNotification", + "type": "object" + }, { "properties": { "method": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index f8f8db58b5..bf7f0e5b30 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -5091,6 +5091,26 @@ "title": "Turn/moderationMetadataNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "model/safetyBuffering/updated" + ], + "title": "Model/safetyBuffering/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ModelSafetyBufferingUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Model/safetyBuffering/updatedNotification", + "type": "object" + }, { "properties": { "method": { @@ -12559,6 +12579,41 @@ "title": "ModelReroutedNotification", "type": "object" }, + "ModelSafetyBufferingUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "model": { + "type": "string" + }, + "reasons": { + "items": { + "type": "string" + }, + "type": "array" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "useCases": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "model", + "reasons", + "threadId", + "turnId", + "useCases" + ], + "title": "ModelSafetyBufferingUpdatedNotification", + "type": "object" + }, "ModelServiceTier": { "properties": { "description": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 81a79c0c97..7a080b56bd 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -8983,6 +8983,41 @@ "title": "ModelReroutedNotification", "type": "object" }, + "ModelSafetyBufferingUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "model": { + "type": "string" + }, + "reasons": { + "items": { + "type": "string" + }, + "type": "array" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "useCases": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "model", + "reasons", + "threadId", + "turnId", + "useCases" + ], + "title": "ModelSafetyBufferingUpdatedNotification", + "type": "object" + }, "ModelServiceTier": { "properties": { "description": { @@ -13412,6 +13447,26 @@ "title": "Turn/moderationMetadataNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "model/safetyBuffering/updated" + ], + "title": "Model/safetyBuffering/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ModelSafetyBufferingUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Model/safetyBuffering/updatedNotification", + "type": "object" + }, { "properties": { "method": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ModelSafetyBufferingUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ModelSafetyBufferingUpdatedNotification.json new file mode 100644 index 0000000000..5ed692480b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ModelSafetyBufferingUpdatedNotification.json @@ -0,0 +1,35 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "model": { + "type": "string" + }, + "reasons": { + "items": { + "type": "string" + }, + "type": "array" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "useCases": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "model", + "reasons", + "threadId", + "turnId", + "useCases" + ], + "title": "ModelSafetyBufferingUpdatedNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index 82814111dc..f3f828b8ac 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -30,6 +30,7 @@ import type { McpServerOauthLoginCompletedNotification } from "./v2/McpServerOau import type { McpServerStatusUpdatedNotification } from "./v2/McpServerStatusUpdatedNotification"; import type { McpToolCallProgressNotification } from "./v2/McpToolCallProgressNotification"; import type { ModelReroutedNotification } from "./v2/ModelReroutedNotification"; +import type { ModelSafetyBufferingUpdatedNotification } from "./v2/ModelSafetyBufferingUpdatedNotification"; import type { ModelVerificationNotification } from "./v2/ModelVerificationNotification"; import type { PlanDeltaNotification } from "./v2/PlanDeltaNotification"; import type { ProcessExitedNotification } from "./v2/ProcessExitedNotification"; @@ -73,4 +74,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW /** * Notification sent from the server to the client. */ -export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/deleted", "params": ThreadDeletedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/goal/updated", "params": ThreadGoalUpdatedNotification } | { "method": "thread/goal/cleared", "params": ThreadGoalClearedNotification } | { "method": "thread/settings/updated", "params": ThreadSettingsUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "process/outputDelta", "params": ProcessOutputDeltaNotification } | { "method": "process/exited", "params": ProcessExitedNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "remoteControl/status/changed", "params": RemoteControlStatusChangedNotification } | { "method": "externalAgentConfig/import/progress", "params": ExternalAgentConfigImportProgressNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "model/verification", "params": ModelVerificationNotification } | { "method": "turn/moderationMetadata", "params": TurnModerationMetadataNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; +export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/deleted", "params": ThreadDeletedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/goal/updated", "params": ThreadGoalUpdatedNotification } | { "method": "thread/goal/cleared", "params": ThreadGoalClearedNotification } | { "method": "thread/settings/updated", "params": ThreadSettingsUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "process/outputDelta", "params": ProcessOutputDeltaNotification } | { "method": "process/exited", "params": ProcessExitedNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "remoteControl/status/changed", "params": RemoteControlStatusChangedNotification } | { "method": "externalAgentConfig/import/progress", "params": ExternalAgentConfigImportProgressNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "model/verification", "params": ModelVerificationNotification } | { "method": "turn/moderationMetadata", "params": TurnModerationMetadataNotification } | { "method": "model/safetyBuffering/updated", "params": ModelSafetyBufferingUpdatedNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ModelSafetyBufferingUpdatedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ModelSafetyBufferingUpdatedNotification.ts new file mode 100644 index 0000000000..484f8c6146 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ModelSafetyBufferingUpdatedNotification.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ModelSafetyBufferingUpdatedNotification = { threadId: string, turnId: string, model: string, useCases: Array, reasons: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 9b1bd3b6e8..419cf8c3c6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -260,6 +260,7 @@ export type { ModelProviderCapabilitiesReadParams } from "./ModelProviderCapabil export type { ModelProviderCapabilitiesReadResponse } from "./ModelProviderCapabilitiesReadResponse"; export type { ModelRerouteReason } from "./ModelRerouteReason"; export type { ModelReroutedNotification } from "./ModelReroutedNotification"; +export type { ModelSafetyBufferingUpdatedNotification } from "./ModelSafetyBufferingUpdatedNotification"; export type { ModelServiceTier } from "./ModelServiceTier"; export type { ModelUpgradeInfo } from "./ModelUpgradeInfo"; export type { ModelVerification } from "./ModelVerification"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 2f322aa5ff..eb3e6c82ff 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1655,6 +1655,7 @@ server_notification_definitions! { ModelVerification => "model/verification" (v2::ModelVerificationNotification), #[experimental("turn/moderationMetadata")] TurnModerationMetadata => "turn/moderationMetadata" (v2::TurnModerationMetadataNotification), + ModelSafetyBufferingUpdated => "model/safetyBuffering/updated" (v2::ModelSafetyBufferingUpdatedNotification), Warning => "warning" (v2::WarningNotification), GuardianWarning => "guardianWarning" (v2::GuardianWarningNotification), DeprecationNotice => "deprecationNotice" (v2::DeprecationNoticeNotification), @@ -3347,6 +3348,33 @@ mod tests { Ok(()) } + #[test] + fn serialize_model_safety_buffering_updated_notification() -> Result<()> { + let notification = ServerNotification::ModelSafetyBufferingUpdated( + v2::ModelSafetyBufferingUpdatedNotification { + thread_id: "thr_123".to_string(), + turn_id: "turn_123".to_string(), + model: "gpt-5.4".to_string(), + use_cases: vec!["cyber".to_string()], + reasons: vec!["user_risk".to_string()], + }, + ); + assert_eq!( + json!({ + "method": "model/safetyBuffering/updated", + "params": { + "threadId": "thr_123", + "turnId": "turn_123", + "model": "gpt-5.4", + "useCases": ["cyber"], + "reasons": ["user_risk"] + } + }), + serde_json::to_value(¬ification)?, + ); + Ok(()) + } + #[test] fn serialize_thread_realtime_output_audio_delta_notification() -> Result<()> { let notification = ServerNotification::ThreadRealtimeOutputAudioDelta( diff --git a/codex-rs/app-server-protocol/src/protocol/v2/model.rs b/codex-rs/app-server-protocol/src/protocol/v2/model.rs index d8fa249582..2ce1819024 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/model.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/model.rs @@ -162,3 +162,14 @@ pub struct TurnModerationMetadataNotification { pub turn_id: String, pub metadata: JsonValue, } + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelSafetyBufferingUpdatedNotification { + pub thread_id: String, + pub turn_id: String, + pub model: String, + pub use_cases: Vec, + pub reasons: Vec, +} diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index f8ceb69d42..8de6e3b8c7 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1353,6 +1353,7 @@ The app-server streams JSON-RPC notifications while a turn is running. Each turn - `turn/completed` — `{ turn }` where `turn.status` is `completed`, `interrupted`, or `failed`; failures carry `{ error: { message, codexErrorInfo?, additionalDetails? } }`. - `turn/diff/updated` — `{ threadId, turnId, diff }` represents the up-to-date snapshot of the turn-level unified diff, emitted after every FileChange item. `diff` is the latest aggregated unified diff across every file change in the turn. UIs can render this to show the full "what changed" view without stitching individual `fileChange` items. - `turn/plan/updated` — `{ turnId, explanation?, plan }` whenever the agent shares or changes its plan; each `plan` entry is `{ step, status }` with `status` in `pending`, `inProgress`, or `completed`. +- `model/safetyBuffering/updated` — `{ threadId, turnId, model, useCases, reasons }` when a response enters safety buffering. This notification is transient and is not persisted in rollout history. - `model/rerouted` — `{ threadId, turnId, fromModel, toModel, reason }` when the backend reroutes a request to a different model (for example, due to high-risk cyber safety checks). - `model/verification` — `{ threadId, turnId, verifications }` when the backend flags additional account verification, such as `trustedAccessForCyber`. - `turn/moderationMetadata` — experimental; `{ threadId, turnId, metadata }` when a first-party backend supplies turn-scoped moderation metadata for client-side presentation. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index a77b0f4664..bc69505066 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -40,6 +40,7 @@ use codex_app_server_protocol::McpServerElicitationRequestResponse; use codex_app_server_protocol::McpServerStartupState; use codex_app_server_protocol::McpServerStatusUpdatedNotification; use codex_app_server_protocol::ModelReroutedNotification; +use codex_app_server_protocol::ModelSafetyBufferingUpdatedNotification; use codex_app_server_protocol::ModelVerificationNotification; use codex_app_server_protocol::NetworkApprovalContext as V2NetworkApprovalContext; use codex_app_server_protocol::NetworkPolicyAmendment as V2NetworkPolicyAmendment; @@ -351,6 +352,20 @@ pub(crate) async fn apply_bespoke_event_handling( .send_server_notification(ServerNotification::TurnModerationMetadata(notification)) .await; } + EventMsg::SafetyBuffering(event) => { + let notification = ModelSafetyBufferingUpdatedNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.clone(), + model: event.model, + use_cases: event.use_cases, + reasons: event.reasons, + }; + outgoing + .send_server_notification(ServerNotification::ModelSafetyBufferingUpdated( + notification, + )) + .await; + } EventMsg::RealtimeConversationStarted(event) => { let notification = ThreadRealtimeStartedNotification { thread_id: conversation_id.to_string(), diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index 17753e799a..aeea85e7fc 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -72,6 +72,7 @@ pub struct MemorySummarizeOutput { #[derive(Debug)] pub enum ResponseEvent { Created, + SafetyBuffering(SafetyBuffering), OutputItemDone(ResponseItem), OutputItemAdded(ResponseItem), /// Emitted when the server includes `OpenAI-Model` on the stream response. @@ -113,6 +114,12 @@ pub enum ResponseEvent { ModelsEtag(String), } +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct SafetyBuffering { + pub use_cases: Vec, + pub reasons: Vec, +} + #[derive(Debug, Serialize, Clone, PartialEq)] #[serde(rename_all = "snake_case")] pub enum ReasoningContext { diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index 4ea564f139..fa01530102 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -690,6 +690,7 @@ async fn run_websocket_response_stream( } let model_verifications = event.model_verifications(); let turn_moderation_metadata = event.turn_moderation_metadata(); + let safety_buffering = event.safety_buffering(); if event.kind() == "codex.rate_limits" { if let Some(snapshot) = parse_rate_limit_event(&text) { let _ = tx_event.send(Ok(ResponseEvent::RateLimits(snapshot))).await; @@ -724,6 +725,16 @@ async fn run_websocket_response_stream( "response event consumer dropped".to_string(), )); } + if let Some(buffering) = safety_buffering + && tx_event + .send(Ok(ResponseEvent::SafetyBuffering(buffering))) + .await + .is_err() + { + return Err(ApiError::Stream( + "response event consumer dropped".to_string(), + )); + } match process_responses_event(event) { Ok(Some(event)) => { let is_completed = matches!(event, ResponseEvent::Completed { .. }); diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index a451f67af0..0c20e10392 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -1,5 +1,6 @@ use crate::common::ResponseEvent; use crate::common::ResponseStream; +use crate::common::SafetyBuffering; use crate::error::ApiError; use crate::rate_limits::parse_all_rate_limits; use crate::telemetry::SseTelemetry; @@ -157,6 +158,7 @@ pub struct ResponsesStreamEvent { delta: Option, summary_index: Option, content_index: Option, + safety_buffering: Option, } impl ResponsesStreamEvent { @@ -217,6 +219,10 @@ impl ResponsesStreamEvent { .cloned() .map(|metadata| TurnModerationMetadataEvent { metadata }) } + + pub(crate) fn safety_buffering(&self) -> Option { + serde_json::from_value(self.safety_buffering.as_ref()?.clone()).ok() + } } fn header_openai_model_value_from_json(value: &Value) -> Option { @@ -480,6 +486,7 @@ pub async fn process_sse( }; let model_verifications = event.model_verifications(); let turn_moderation_metadata = event.turn_moderation_metadata(); + let safety_buffering = event.safety_buffering(); if let Some(model) = event.response_model() && last_server_model.as_deref() != Some(model.as_str()) @@ -509,6 +516,14 @@ pub async fn process_sse( { return; } + if let Some(buffering) = safety_buffering + && tx_event + .send(Ok(ResponseEvent::SafetyBuffering(buffering))) + .await + .is_err() + { + return; + } match process_responses_event(event) { Ok(Some(event)) => { @@ -1294,6 +1309,64 @@ mod tests { ); } + #[tokio::test] + async fn process_sse_emits_all_safety_buffering_notifications_without_dropping_response_events() + { + let events = run_sse(vec![ + json!({ + "type": "response.created", + "response": { "id": "resp-1" }, + "safety_buffering": false + }), + json!({ + "type": "response.output_text.delta", + "delta": "hello", + "safety_buffering": { + "use_cases": ["cyber"], + "reasons": ["user_risk"] + } + }), + json!({ + "type": "response.output_text.delta", + "delta": " world", + "safety_buffering": { + "use_cases": ["cyber"], + "reasons": ["user_risk"] + } + }), + json!({ + "type": "response.completed", + "response": { "id": "resp-1" }, + "safety_buffering": { + "use_cases": ["cyber"], + "reasons": ["user_risk"] + } + }), + ]) + .await; + + assert_eq!(events.len(), 7); + assert_matches!(&events[0], ResponseEvent::Created); + assert_matches!( + &events[1], + ResponseEvent::SafetyBuffering(buffering) + if buffering.use_cases == ["cyber"] && buffering.reasons == ["user_risk"] + ); + assert_matches!(&events[2], ResponseEvent::OutputTextDelta(delta) if delta == "hello"); + assert_matches!( + &events[3], + ResponseEvent::SafetyBuffering(buffering) + if buffering.use_cases == ["cyber"] && buffering.reasons == ["user_risk"] + ); + assert_matches!(&events[4], ResponseEvent::OutputTextDelta(delta) if delta == " world"); + assert_matches!( + &events[5], + ResponseEvent::SafetyBuffering(buffering) + if buffering.use_cases == ["cyber"] && buffering.reasons == ["user_risk"] + ); + assert_matches!(&events[6], ResponseEvent::Completed { response_id, .. } if response_id == "resp-1"); + } + #[test] fn responses_stream_event_response_model_reads_top_level_headers() { let ev: ResponsesStreamEvent = serde_json::from_value(json!({ diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 909048ce66..2630ba1cc9 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -100,6 +100,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::PlanDeltaEvent; use codex_protocol::protocol::ReasoningContentDeltaEvent; use codex_protocol::protocol::ReasoningRawContentDeltaEvent; +use codex_protocol::protocol::SafetyBufferingEvent; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::protocol::WarningEvent; use codex_protocol::user_input::UserInput; @@ -1527,6 +1528,7 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option<(String, Option< | EventMsg::ModelReroute(_) | EventMsg::ModelVerification(_) | EventMsg::TurnModerationMetadata(_) + | EventMsg::SafetyBuffering(_) | EventMsg::ContextCompacted(_) | EventMsg::ThreadRolledBack(_) | EventMsg::TurnStarted(_) @@ -2199,6 +2201,17 @@ async fn try_run_sampling_request( sess.emit_turn_moderation_metadata(&turn_context, metadata) .await; } + ResponseEvent::SafetyBuffering(buffering) => { + sess.send_event( + &turn_context, + EventMsg::SafetyBuffering(SafetyBufferingEvent { + model: turn_context.model_info.slug.clone(), + use_cases: buffering.use_cases, + reasons: buffering.reasons, + }), + ) + .await; + } ResponseEvent::ServerReasoningIncluded(included) => { sess.set_server_reasoning_included(included).await; } diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 8a93a91833..e7a90800b0 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -339,6 +339,7 @@ fn response_event_records_turn_ttft(event: &ResponseEvent) -> bool { | ResponseEvent::ServerModel(_) | ResponseEvent::ModelVerifications(_) | ResponseEvent::TurnModerationMetadata(_) + | ResponseEvent::SafetyBuffering(_) | ResponseEvent::ServerReasoningIncluded(_) | ResponseEvent::ToolCallInputDelta { .. } | ResponseEvent::Completed { .. } diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 492f309795..a9298ec6d9 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -104,6 +104,7 @@ mod review; mod rmcp_client; mod rollout_budget; mod rollout_list_find; +mod safety_buffering; mod safety_check_downgrade; mod search_tool; mod shell_command; diff --git a/codex-rs/core/tests/suite/safety_buffering.rs b/codex-rs/core/tests/suite/safety_buffering.rs new file mode 100644 index 0000000000..6c2df1b6da --- /dev/null +++ b/codex-rs/core/tests/suite/safety_buffering.rs @@ -0,0 +1,63 @@ +use anyhow::Ok; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SafetyBufferingEvent; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use core_test_support::wait_for_event_match; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn emits_safety_buffering_with_the_requested_model() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut created = ev_response_created("resp-1"); + created["safety_buffering"] = json!({ + "use_cases": ["cyber"], + "reasons": ["policy-check"], + }); + mount_sse_once(&server, sse(vec![created, ev_completed("resp-1")])).await; + + let test = test_codex().build(&server).await?; + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "Check this request".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + + let event = wait_for_event_match(&test.codex, |event| match event { + EventMsg::SafetyBuffering(event) => Some(event.clone()), + _ => None, + }) + .await; + assert_eq!( + event, + SafetyBufferingEvent { + model: test.session_configured.model.clone(), + use_cases: vec!["cyber".to_string()], + reasons: vec!["policy-check".to_string()], + } + ); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + Ok(()) +} diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index d605b640ca..cbc113ff79 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -266,6 +266,7 @@ async fn run_codex_tool_session_inner( EventMsg::Warning(_) | EventMsg::GuardianWarning(_) | EventMsg::ModelVerification(_) + | EventMsg::SafetyBuffering(_) | EventMsg::TurnModerationMetadata(_) => { continue; } diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index 8f0471088f..c8e35b9f7d 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -1209,6 +1209,7 @@ impl SessionTelemetry { ResponseEvent::ServerModel(_) => "server_model".into(), ResponseEvent::ModelVerifications(_) => "model_verifications".into(), ResponseEvent::TurnModerationMetadata(_) => "turn_moderation_metadata".into(), + ResponseEvent::SafetyBuffering(_) => "safety_buffering".into(), ResponseEvent::ServerReasoningIncluded(_) => "server_reasoning_included".into(), ResponseEvent::RateLimits(_) => "rate_limits".into(), ResponseEvent::ModelsEtag(_) => "models_etag".into(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 081649b7a4..483618f060 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1251,6 +1251,9 @@ pub enum EventMsg { /// Backend moderation metadata intended for first-party turn presentation. TurnModerationMetadata(TurnModerationMetadataEvent), + /// Backend indicates that response output is waiting on a safety review. + SafetyBuffering(SafetyBufferingEvent), + /// Conversation history was compacted (either automatically or manually). ContextCompacted(ContextCompactedEvent), @@ -1929,6 +1932,13 @@ pub struct TurnModerationMetadataEvent { pub metadata: Value, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct SafetyBufferingEvent { + pub model: String, + pub use_cases: Vec, + pub reasons: Vec, +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ContextCompactedEvent; diff --git a/codex-rs/rollout-trace/src/protocol_event.rs b/codex-rs/rollout-trace/src/protocol_event.rs index a998c98fc0..42e0db04d2 100644 --- a/codex-rs/rollout-trace/src/protocol_event.rs +++ b/codex-rs/rollout-trace/src/protocol_event.rs @@ -344,6 +344,7 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option Option<&'static s EventMsg::Warning(_) => Some("warning"), EventMsg::ShutdownComplete => Some("shutdown_complete"), EventMsg::GuardianWarning(_) + | EventMsg::SafetyBuffering(_) | EventMsg::RealtimeConversationStarted(_) | EventMsg::RealtimeConversationRealtime(_) | EventMsg::RealtimeConversationClosed(_) diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index 169f4360d3..b342d699b7 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -121,6 +121,7 @@ pub fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::RealtimeConversationSdp(_) | EventMsg::RealtimeConversationRealtime(_) | EventMsg::RealtimeConversationClosed(_) + | EventMsg::SafetyBuffering(_) | EventMsg::ModelReroute(_) | EventMsg::ModelVerification(_) | EventMsg::TurnModerationMetadata(_) diff --git a/codex-rs/tui/src/app/app_server_event_targets.rs b/codex-rs/tui/src/app/app_server_event_targets.rs index 7e84e42271..212c45b4be 100644 --- a/codex-rs/tui/src/app/app_server_event_targets.rs +++ b/codex-rs/tui/src/app/app_server_event_targets.rs @@ -123,6 +123,9 @@ pub(super) fn server_notification_thread_target( ServerNotification::ModelVerification(notification) => { Some(notification.thread_id.as_str()) } + ServerNotification::ModelSafetyBufferingUpdated(notification) => { + Some(notification.thread_id.as_str()) + } ServerNotification::TurnModerationMetadata(notification) => { Some(notification.thread_id.as_str()) } diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index e2cf31b034..085255a1cf 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -208,6 +208,7 @@ impl ChatWidget { | ServerNotification::ExternalAgentConfigImportProgress(_) | ServerNotification::ExternalAgentConfigImportCompleted(_) | ServerNotification::FsChanged(_) + | ServerNotification::ModelSafetyBufferingUpdated(_) | ServerNotification::TurnModerationMetadata(_) | ServerNotification::FuzzyFileSearchSessionUpdated(_) | ServerNotification::FuzzyFileSearchSessionCompleted(_) From 98845e484070a1f93fa24842db0e429c7cec9f81 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Sun, 21 Jun 2026 22:11:42 -0700 Subject: [PATCH 099/112] chore: fix merge race (auto-compaction feature access) (#29393) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - read the `AutoCompaction` feature flag through `TurnContext::config` - fix both the mid-turn and pre-sampling compaction checks ## Why #28260 was validated against an older base where `TurnContext` exposed a direct `features` field. It was then merged after that field had moved under `config`, leaving the merge result unable to compile with `E0609` on `turn_context.features`. This restores compilation for Bazel, SDK, and argument-comment-lint jobs that build `codex-core`. Behavior is unchanged: disabling `auto_compaction` still skips automatic compaction. ## Validation - `just fmt` - `CODEX_HOME=/private/tmp/codex-fix-auto-compaction-test-home just test -p codex-core auto_compaction_feature_disabled` — 4 passed - `just test -p codex-core` — `codex-core` compiled; 2,722 passed and 89 unrelated local-environment failures remained because the sandbox could not write the default Codex SQLite/proxy paths and some first-party test binaries were unavailable --- codex-rs/core/src/session/turn.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 2630ba1cc9..041da729ea 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -334,7 +334,10 @@ pub(crate) async fn run_turn( } // as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop. - if turn_context.features.enabled(Feature::AutoCompaction) + if turn_context + .config + .features + .enabled(Feature::AutoCompaction) && token_limit_reached && needs_follow_up { @@ -848,7 +851,11 @@ async fn run_pre_sampling_compact( turn_context: &Arc, client_session: &mut ModelClientSession, ) -> CodexResult<()> { - if !turn_context.features.enabled(Feature::AutoCompaction) { + if !turn_context + .config + .features + .enabled(Feature::AutoCompaction) + { return Ok(()); } From 6d15bb3d17f2b2a7d2a11ddd0689dc9244bf744c Mon Sep 17 00:00:00 2001 From: jif Date: Mon, 22 Jun 2026 08:36:08 +0100 Subject: [PATCH 100/112] Persist session IDs across thread resume (#29327) ## Summary A cold-resumed subagent kept its durable thread ID but could receive a new session ID, splitting one agent tree across multiple sessions after a restart. Persist the root session ID in every rollout `SessionMeta`, carry it through thread creation, and restore it before initializing the resumed `Session` and `AgentControl`. ## Behavior For a nested agent tree: ```text root session R parent thread P child thread C ``` The child rollout stores: ```text session_id: R parent_thread_id: P id: C ``` After a cold resume, the child still belongs to root session `R` while its immediate parent remains `P`. The integration coverage uses distinct values for all three IDs so it catches restoring the session from `parent_thread_id`. ## Legacy rollouts Previous rollouts have `id` but no `session_id`. `SessionMetaLine` deserialization treats a missing `session_id` as `id`, keeping those files readable, listable, and resumable. When a legacy subagent is resumed through its root, that synthesized child ID no longer overrides the inherited root-scoped `AgentControl`. New rollouts always persist the explicit root session ID. --- .../external_agent_session_import.rs | 1 + .../thread_processor_tests.rs | 3 ++ .../thread_summary_tests.rs | 1 + codex-rs/app-server/tests/common/rollout.rs | 10 ++++- .../tests/suite/conversation_summary.rs | 1 + .../tests/suite/v2/client_metadata.rs | 10 ++++- .../tests/suite/v2/remote_thread_store.rs | 1 + .../app-server/tests/suite/v2/thread_list.rs | 1 + .../app-server/tests/suite/v2/thread_read.rs | 1 + .../tests/suite/v2/thread_resume.rs | 2 + .../tests/suite/v2/thread_unarchive.rs | 1 + codex-rs/cli/src/doctor/thread_inventory.rs | 4 +- .../core/src/personality_migration_tests.rs | 1 + codex-rs/core/src/session/session.rs | 39 +++++++++++++----- codex-rs/core/src/session/tests.rs | 17 ++++++-- codex-rs/core/tests/suite/client.rs | 9 ++++- .../core/tests/suite/personality_migration.rs | 2 + codex-rs/core/tests/suite/review.rs | 1 + .../core/tests/suite/rollout_list_find.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/protocol/src/protocol.rs | 40 ++++++++++++++++++- codex-rs/rollout/src/compression_tests.rs | 1 + codex-rs/rollout/src/metadata_tests.rs | 3 ++ codex-rs/rollout/src/recorder.rs | 22 +++++++--- codex-rs/rollout/src/recorder_tests.rs | 27 +++++++++---- codex-rs/rollout/src/session_index_tests.rs | 1 + codex-rs/rollout/src/state_db_tests.rs | 1 + codex-rs/rollout/src/tests.rs | 8 +++- codex-rs/state/src/extract.rs | 2 + codex-rs/state/src/runtime/threads.rs | 2 + codex-rs/thread-store/src/in_memory.rs | 2 + .../thread-store/src/local/create_thread.rs | 1 + codex-rs/thread-store/src/local/mod.rs | 1 + .../thread-store/src/local/read_thread.rs | 3 ++ .../thread-store/src/local/test_support.rs | 1 + .../thread-store/src/thread_metadata_sync.rs | 1 + codex-rs/thread-store/src/types.rs | 3 ++ codex-rs/tui/src/lib.rs | 1 + 38 files changed, 193 insertions(+), 34 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/external_agent_session_import.rs b/codex-rs/app-server/src/request_processors/external_agent_session_import.rs index d4abb155ec..0e17cb5737 100644 --- a/codex-rs/app-server/src/request_processors/external_agent_session_import.rs +++ b/codex-rs/app-server/src/request_processors/external_agent_session_import.rs @@ -200,6 +200,7 @@ impl ExternalAgentSessionImporter { }; let now = Utc::now(); let create_params = CreateThreadParams { + session_id: thread_id.into(), thread_id, extra_config: None, forked_from_id: None, diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 0ac9b7fa24..1f5ae12f44 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -1004,6 +1004,7 @@ mod thread_processor_behavior_tests { let timestamp = "2025-09-05T16:53:11.850Z".to_string(); let session_meta = SessionMeta { + session_id: conversation_id.into(), id: conversation_id, timestamp: timestamp.clone(), model_provider: None, @@ -1060,6 +1061,7 @@ mod thread_processor_behavior_tests { let timestamp = "2025-09-05T16:53:11.850Z".to_string(); let session_meta = SessionMeta { + session_id: parent_thread_id.into(), id: conversation_id, timestamp: timestamp.clone(), source: SessionSource::SubAgent(SubAgentSource::ThreadSpawn { @@ -1110,6 +1112,7 @@ mod thread_processor_behavior_tests { let timestamp = "2025-09-05T16:53:11.850Z".to_string(); let session_meta = SessionMeta { + session_id: conversation_id.into(), id: conversation_id, forked_from_id: Some(forked_from_id), timestamp: timestamp.clone(), diff --git a/codex-rs/app-server/src/request_processors/thread_summary_tests.rs b/codex-rs/app-server/src/request_processors/thread_summary_tests.rs index f8902e132d..8bead5529b 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary_tests.rs @@ -13,6 +13,7 @@ fn extract_conversation_summary_prefers_plain_user_messages() -> Result<()> { let head = vec![ json!({ + "session_id": conversation_id.to_string(), "id": conversation_id.to_string(), "timestamp": timestamp, "cwd": "/", diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index 6d446e1c42..7b389e8fc1 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GitInfo; @@ -127,11 +128,12 @@ pub fn create_fake_rollout_with_source( model_provider, git_info, source, + /*session_id*/ None, /*parent_thread_id*/ None, ) } -/// Create a minimal rollout file with an explicit session source and control parent. +/// Create a minimal rollout file with an explicit root session and control parent. #[allow(clippy::too_many_arguments)] pub fn create_fake_parented_rollout_with_source( codex_home: &Path, @@ -141,6 +143,7 @@ pub fn create_fake_parented_rollout_with_source( model_provider: Option<&str>, git_info: Option, source: SessionSource, + session_id: SessionId, parent_thread_id: ThreadId, ) -> Result { create_fake_rollout_with_source_and_parent_thread_id( @@ -151,6 +154,7 @@ pub fn create_fake_parented_rollout_with_source( model_provider, git_info, source, + Some(session_id), Some(parent_thread_id), ) } @@ -164,11 +168,13 @@ fn create_fake_rollout_with_source_and_parent_thread_id( model_provider: Option<&str>, git_info: Option, source: SessionSource, + session_id: Option, parent_thread_id: Option, ) -> Result { let uuid = Uuid::new_v4(); let uuid_str = uuid.to_string(); let conversation_id = ThreadId::from_string(&uuid_str)?; + let session_id = session_id.unwrap_or_else(|| conversation_id.into()); let file_path = rollout_path(codex_home, filename_ts, &uuid_str); let dir = file_path @@ -178,6 +184,7 @@ fn create_fake_rollout_with_source_and_parent_thread_id( // Build JSONL lines let meta = SessionMeta { + session_id, id: conversation_id, forked_from_id: None, parent_thread_id, @@ -264,6 +271,7 @@ pub fn create_fake_rollout_with_text_elements( // Build JSONL lines let meta = SessionMeta { + session_id: conversation_id.into(), id: conversation_id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index 6ad9f1c633..17f73db3ed 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -121,6 +121,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() -> let thread_id = ThreadId::from_string("00000000-0000-4000-8000-000000000125")?; store .create_thread(CreateThreadParams { + session_id: thread_id.into(), thread_id, extra_config: None, forked_from_id: None, diff --git a/codex-rs/app-server/tests/suite/v2/client_metadata.rs b/codex-rs/app-server/tests/suite/v2/client_metadata.rs index 6b95c036d0..e55cd0c24d 100644 --- a/codex-rs/app-server/tests/suite/v2/client_metadata.rs +++ b/codex-rs/app-server/tests/suite/v2/client_metadata.rs @@ -300,7 +300,7 @@ async fn review_start_sends_parent_lineage_in_turn_metadata_for_thread_fork_v2() } #[tokio::test] -async fn turn_start_sends_other_subagent_lineage_after_cold_thread_resume_v2() -> Result<()> { +async fn turn_start_sends_nested_subagent_lineage_after_cold_thread_resume_v2() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -321,6 +321,8 @@ async fn turn_start_sends_other_subagent_lineage_after_cold_thread_resume_v2() - /*supports_websockets*/ false, )?; + let root_thread_id = CoreThreadId::new(); + let root_thread_id_str = root_thread_id.to_string(); let parent_thread_id = CoreThreadId::new(); let parent_thread_id_str = parent_thread_id.to_string(); let subagent_thread_id = create_fake_parented_rollout_with_source( @@ -331,6 +333,7 @@ async fn turn_start_sends_other_subagent_lineage_after_cold_thread_resume_v2() - Some("mock_provider"), /*git_info*/ None, SessionSource::SubAgent(SubAgentSource::Other("guardian".to_string())), + root_thread_id.into(), parent_thread_id, )?; @@ -350,6 +353,7 @@ async fn turn_start_sends_other_subagent_lineage_after_cold_thread_resume_v2() - .await??; let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; assert_eq!(thread.id, subagent_thread_id); + assert_eq!(thread.session_id, root_thread_id_str); assert_eq!(thread.parent_thread_id, Some(parent_thread_id_str.clone())); assert_eq!( thread.source, @@ -390,6 +394,10 @@ async fn turn_start_sends_other_subagent_lineage_after_cold_thread_resume_v2() - Some(parent_thread_id_str.as_str()) ); assert_eq!(metadata["subagent_kind"].as_str(), Some("guardian")); + assert_eq!( + metadata["session_id"].as_str(), + Some(thread.session_id.as_str()) + ); assert_eq!(metadata["thread_id"].as_str(), Some(thread.id.as_str())); assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str())); assert!(metadata.get("forked_from_thread_id").is_none()); diff --git a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs index baf11aff5e..6deec70ffe 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs @@ -147,6 +147,7 @@ async fn thread_delete_with_non_local_thread_store_does_not_create_local_persist let unloaded_thread_id = ThreadId::from_string(&Uuid::new_v4().to_string())?; thread_store .create_thread(StoreCreateThreadParams { + session_id: unloaded_thread_id.into(), thread_id: unloaded_thread_id, extra_config: None, forked_from_id: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 9bdff3bb8b..9779513024 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -1252,6 +1252,7 @@ async fn thread_list_filters_by_subagent_variant() -> Result<()> { Some("mock_provider"), /*git_info*/ None, CoreSessionSource::SubAgent(SubAgentSource::Review), + parent_thread_id.into(), parent_thread_id, )?; let compact_id = create_fake_rollout_with_source( diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index a6ed5e4a63..58990aaf04 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -1357,6 +1357,7 @@ async fn seed_pathless_store_thread( ) -> Result<()> { store .create_thread(CreateThreadParams { + session_id: thread_id.into(), thread_id, extra_config: None, forked_from_id: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 07ee66caf2..0d1fcd1e5d 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -2054,6 +2054,7 @@ stream_max_retries = 0 let rollout_dir = rollout_path.parent().expect("rollout parent directory"); std::fs::create_dir_all(rollout_dir)?; let session_meta = SessionMeta { + session_id: conversation_id.into(), id: conversation_id, forked_from_id: None, parent_thread_id: None, @@ -2735,6 +2736,7 @@ async fn thread_resume_rejects_mismatched_path_for_running_thread_id() -> Result "timestamp": "2025-01-01T00:00:00Z", "type": "session_meta", "payload": { + "session_id": thread_uuid, "id": thread_uuid, "timestamp": "2025-01-01T00:00:00Z", "cwd": codex_home.path(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index 7267ab073a..46a989545c 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -208,6 +208,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> { let parent_thread_id = ThreadId::from_string("00000000-0000-4000-8000-000000000127")?; store .create_thread(CreateThreadParams { + session_id: thread_id.into(), thread_id, extra_config: None, forked_from_id: Some(parent_thread_id), diff --git a/codex-rs/cli/src/doctor/thread_inventory.rs b/codex-rs/cli/src/doctor/thread_inventory.rs index 9a8eef6ee4..c50e6debdc 100644 --- a/codex-rs/cli/src/doctor/thread_inventory.rs +++ b/codex-rs/cli/src/doctor/thread_inventory.rs @@ -816,11 +816,13 @@ mod tests { }; std::fs::create_dir_all(&root).expect("rollout dir"); let path = root.join(format!("rollout-{timestamp}-{thread_id}.jsonl")); + let parsed_thread_id = ThreadId::from_string(thread_id).expect("thread id"); let rollout_line = RolloutLine { timestamp: timestamp.to_string(), item: RolloutItem::SessionMeta(codex_protocol::protocol::SessionMetaLine { meta: codex_protocol::protocol::SessionMeta { - id: ThreadId::from_string(thread_id).expect("thread id"), + session_id: parsed_thread_id.into(), + id: parsed_thread_id, timestamp: timestamp.to_string(), cwd: self.codex_home.path().to_path_buf(), originator: "test".to_string(), diff --git a/codex-rs/core/src/personality_migration_tests.rs b/codex-rs/core/src/personality_migration_tests.rs index 4cf8ab9d8f..57f06317a1 100644 --- a/codex-rs/core/src/personality_migration_tests.rs +++ b/codex-rs/core/src/personality_migration_tests.rs @@ -43,6 +43,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R let session_meta = SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 0c07f5e951..9317428340 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -525,6 +525,33 @@ impl Session { } InitialHistory::Resumed(resumed_history) => resumed_history.conversation_id, }; + let resumed_session_id = match &initial_history { + InitialHistory::Resumed(resumed) => { + resumed.history.iter().find_map(|item| match item { + RolloutItem::SessionMeta(meta_line) => Some(meta_line.meta.session_id), + _ => None, + }) + } + InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => None, + }; + // Legacy subagent rollouts synthesize session_id from their own thread id. + let resumed_session_id = resumed_session_id.filter(|session_id| { + !session_configuration.session_source.is_non_root_agent() + || *session_id != SessionId::from(thread_id) + }); + let session_id = resumed_session_id.unwrap_or_else(|| { + if session_configuration.session_source.is_non_root_agent() { + agent_control.session_id() + } else { + SessionId::from(thread_id) + } + }); + let agent_control = agent_control.with_session_id( + session_id, + config + .effective_agent_max_threads(MultiAgentVersion::V2) + .unwrap_or(usize::MAX), + ); let time_provider = crate::current_time::resolve_time_provider( config.current_time_reminder.as_ref(), external_time_provider, @@ -546,6 +573,7 @@ impl Session { let live_thread = match &initial_history { InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => { let params = CreateThreadParams { + session_id, thread_id, extra_config: config.extra_config.clone(), forked_from_id, @@ -952,17 +980,6 @@ impl Session { config.analytics_enabled, ) }); - let session_id = if session_configuration.session_source.is_non_root_agent() { - agent_control.session_id() - } else { - SessionId::from(thread_id) - }; - let agent_control = agent_control.with_session_id( - session_id, - config - .effective_agent_max_threads(MultiAgentVersion::V2) - .unwrap_or(usize::MAX), - ); // Keep one stable manager handle for the session so extension resource clients // automatically observe the manager installed at startup and on later refreshes. let mcp_connection_manager = Arc::new(arc_swap::ArcSwap::from_pointee( diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 6f84da3c79..f3f96b74a7 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1900,6 +1900,7 @@ fn session_meta_item( ) -> RolloutItem { RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, multi_agent_version, ..SessionMeta::default() @@ -3750,6 +3751,7 @@ async fn attach_thread_persistence(session: &mut Session) -> PathBuf { let live_thread = LiveThread::create( Arc::clone(&session.services.thread_store), CreateThreadParams { + session_id: session.session_id(), thread_id: session.thread_id, extra_config: None, forked_from_id: None, @@ -5453,7 +5455,7 @@ async fn resumed_root_session_uses_thread_id_as_session_id() { } #[tokio::test] -async fn resumed_subagent_session_keeps_inherited_session_id() { +async fn resumed_subagent_session_restores_persisted_session_id() { let parent_thread_id = ThreadId::new(); let parent_session_id = SessionId::from(parent_thread_id); let thread_id = ThreadId::new(); @@ -5467,11 +5469,19 @@ async fn resumed_subagent_session_keeps_inherited_session_id() { let (session, rx_event) = make_session_with_history_source_and_agent_control_and_rx( InitialHistory::Resumed(ResumedHistory { conversation_id: thread_id, - history: Vec::new(), + history: vec![RolloutItem::SessionMeta(SessionMetaLine { + meta: SessionMeta { + session_id: parent_session_id, + id: thread_id, + source: session_source.clone(), + ..SessionMeta::default() + }, + git: None, + })], rollout_path: None, }), session_source, - AgentControl::default().with_session_id(parent_session_id, /*max_threads*/ usize::MAX), + AgentControl::default(), ) .await .expect("resume should succeed"); @@ -6590,6 +6600,7 @@ async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() { let live_thread = LiveThread::create( Arc::clone(&thread_store), CreateThreadParams { + session_id: session.session_id(), thread_id: session.thread_id, extra_config: None, forked_from_id: None, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index fe543a875b..f9b2fe42ef 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -516,6 +516,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { "timestamp": "2024-01-01T00:00:00.000Z", "type": "session_meta", "payload": { + "session_id": convo_id, "id": convo_id, "timestamp": "2024-01-01T00:00:00Z", "instructions": "be nice", @@ -726,12 +727,14 @@ async fn resume_replays_legacy_js_repl_image_rollout_shapes() { metadata: None, }; let legacy_image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; + let thread_id = ThreadId::default(); let rollout = vec![ RolloutLine { timestamp: "2024-01-01T00:00:00.000Z".to_string(), item: RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { - id: ThreadId::default(), + session_id: thread_id.into(), + id: thread_id, parent_thread_id: None, timestamp: "2024-01-01T00:00:00Z".to_string(), cwd: ".".into(), @@ -860,12 +863,14 @@ async fn resume_replays_image_tool_outputs_with_detail() { let image_url = "data:image/webp;base64,UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEAAUAmJaACdLoB+AADsAD+8ut//NgVzXPv9//S4P0uD9Lg/9KQAAA="; let function_call_id = "view-image-call"; let custom_call_id = "js-repl-call"; + let thread_id = ThreadId::default(); let rollout = vec![ RolloutLine { timestamp: "2024-01-01T00:00:00.000Z".to_string(), item: RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { - id: ThreadId::default(), + session_id: thread_id.into(), + id: thread_id, parent_thread_id: None, timestamp: "2024-01-01T00:00:00Z".to_string(), cwd: ".".into(), diff --git a/codex-rs/core/tests/suite/personality_migration.rs b/codex-rs/core/tests/suite/personality_migration.rs index 5302274153..bbab961817 100644 --- a/codex-rs/core/tests/suite/personality_migration.rs +++ b/codex-rs/core/tests/suite/personality_migration.rs @@ -59,6 +59,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R let session_meta = SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, @@ -109,6 +110,7 @@ async fn write_rollout_with_meta_only(dir: &Path, thread_id: ThreadId) -> io::Re let session_meta = SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 6b0f8552a2..99794939dc 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -511,6 +511,7 @@ async fn review_input_isolated_from_parent_history() { "timestamp": "2024-01-01T00:00:00.000Z", "type": "session_meta", "payload": { + "session_id": convo_id, "id": convo_id, "timestamp": "2024-01-01T00:00:00Z", "cwd": ".", diff --git a/codex-rs/core/tests/suite/rollout_list_find.rs b/codex-rs/core/tests/suite/rollout_list_find.rs index 680301d9f9..1461c1af35 100644 --- a/codex-rs/core/tests/suite/rollout_list_find.rs +++ b/codex-rs/core/tests/suite/rollout_list_find.rs @@ -42,6 +42,7 @@ fn write_minimal_rollout_with_id_at_path(file: &Path, id: Uuid) { "timestamp": "2024-01-01T00:00:00.000Z", "type": "session_meta", "payload": { + "session_id": id, "id": id, "timestamp": "2024-01-01T00:00:00Z", "cwd": ".", diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index eae82de9e1..36a51f80c3 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -356,6 +356,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { fs::create_dir_all(parent).expect("should create rollout directory"); let session_meta_line = SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 483618f060..5900ffe3cf 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -57,7 +57,9 @@ use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use schemars::JsonSchema; use serde::Deserialize; +use serde::Deserializer; use serde::Serialize; +use serde::de::Error as _; use serde_json::Value; use serde_with::serde_as; use strum_macros::Display; @@ -2914,6 +2916,7 @@ pub enum MultiAgentVersion { /// and should be used when there is no config override. #[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] pub struct SessionMeta { + pub session_id: SessionId, pub id: ThreadId, #[serde(skip_serializing_if = "Option::is_none")] pub forked_from_id: Option, @@ -2956,8 +2959,10 @@ pub struct SessionMeta { impl Default for SessionMeta { fn default() -> Self { + let id = ThreadId::default(); SessionMeta { - id: ThreadId::default(), + session_id: id.into(), + id, forked_from_id: None, parent_thread_id: None, timestamp: String::new(), @@ -2978,7 +2983,7 @@ impl Default for SessionMeta { } } -#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] +#[derive(Serialize, Debug, Clone, JsonSchema, TS)] pub struct SessionMetaLine { #[serde(flatten)] pub meta: SessionMeta, @@ -2986,6 +2991,35 @@ pub struct SessionMetaLine { pub git: Option, } +impl<'de> Deserialize<'de> for SessionMetaLine { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct SessionMetaLineFields { + #[serde(flatten)] + meta: SessionMeta, + git: Option, + } + + let mut value = Value::deserialize(deserializer)?; + let fields = value + .as_object_mut() + .ok_or_else(|| D::Error::custom("session metadata must be an object"))?; + if !fields.contains_key("session_id") { + let thread_id = fields + .get("id") + .cloned() + .ok_or_else(|| D::Error::missing_field("id"))?; + fields.insert("session_id".to_string(), thread_id); + } + let SessionMetaLineFields { meta, git } = + serde_json::from_value(value).map_err(D::Error::custom)?; + Ok(Self { meta, git }) + } +} + #[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] #[serde(tag = "type", content = "payload", rename_all = "snake_case")] pub enum RolloutItem { @@ -5390,6 +5424,7 @@ mod tests { let thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?; let older_meta = SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, multi_agent_version: Some(MultiAgentVersion::V2), ..Default::default() @@ -5398,6 +5433,7 @@ mod tests { }; let newer_meta_without_version = SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, multi_agent_version: None, ..Default::default() diff --git a/codex-rs/rollout/src/compression_tests.rs b/codex-rs/rollout/src/compression_tests.rs index c2a8b7d7d3..60f47d078f 100644 --- a/codex-rs/rollout/src/compression_tests.rs +++ b/codex-rs/rollout/src/compression_tests.rs @@ -456,6 +456,7 @@ fn write_rollout(path: &std::path::Path, thread_id: ThreadId, message: &str) -> fs::create_dir_all(parent)?; let session_meta_line = SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/rollout/src/metadata_tests.rs b/codex-rs/rollout/src/metadata_tests.rs index e6771e37c3..5f923f16d0 100644 --- a/codex-rs/rollout/src/metadata_tests.rs +++ b/codex-rs/rollout/src/metadata_tests.rs @@ -33,6 +33,7 @@ async fn extract_metadata_from_rollout_uses_session_meta() { .join(format!("rollout-2026-01-27T12-34-56-{uuid}.jsonl")); let session_meta = SessionMeta { + session_id: id.into(), id, forked_from_id: None, parent_thread_id: None, @@ -88,6 +89,7 @@ async fn extract_metadata_from_rollout_returns_latest_memory_mode() { .join(format!("rollout-2026-01-27T12-34-56-{uuid}.jsonl")); let session_meta = SessionMeta { + session_id: id.into(), id, forked_from_id: None, parent_thread_id: None, @@ -355,6 +357,7 @@ fn write_rollout_in_sessions_with_cwd( std::fs::create_dir_all(sessions_dir.as_path()).expect("create sessions dir"); let path = sessions_dir.join(format!("rollout-{filename_ts}-{thread_uuid}.jsonl")); let session_meta = SessionMeta { + session_id: id.into(), id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index 7ba111e944..084acb0d73 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -10,6 +10,7 @@ use std::sync::Arc; use std::sync::Mutex; use chrono::SecondsFormat; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::BaseInstructions; @@ -81,10 +82,11 @@ pub struct RolloutRecorder { #[derive(Clone)] pub enum RolloutRecorderParams { Create { + session_id: SessionId, conversation_id: ThreadId, forked_from_id: Option, parent_thread_id: Option, - source: SessionSource, + source: Box, thread_source: Option, base_instructions: BaseInstructions, dynamic_tools: Vec, @@ -167,10 +169,11 @@ impl RolloutRecorderParams { dynamic_tools: Vec, ) -> Self { Self::Create { + session_id: conversation_id.into(), conversation_id, forked_from_id, parent_thread_id, - source, + source: Box::new(source), thread_source, base_instructions, dynamic_tools, @@ -178,6 +181,13 @@ impl RolloutRecorderParams { } } + pub fn with_session_id(mut self, session_id: SessionId) -> Self { + if let Self::Create { session_id: id, .. } = &mut self { + *id = session_id; + } + self + } + pub fn with_multi_agent_version( mut self, multi_agent_version: Option, @@ -696,6 +706,7 @@ impl RolloutRecorder { ) -> std::io::Result { let (file, deferred_log_file_info, rollout_path, meta) = match params { RolloutRecorderParams::Create { + session_id, conversation_id, forked_from_id, parent_thread_id, @@ -707,7 +718,7 @@ impl RolloutRecorder { } => { let log_file_info = precompute_log_file_info(config, conversation_id)?; let path = log_file_info.path.clone(); - let session_id = log_file_info.conversation_id; + let thread_id = log_file_info.conversation_id; let started_at = log_file_info.timestamp; let timestamp_format: &[FormatItem] = format_description!( @@ -719,7 +730,8 @@ impl RolloutRecorder { .map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?; let session_meta = SessionMeta { - id: session_id, + session_id, + id: thread_id, forked_from_id, parent_thread_id, timestamp, @@ -729,7 +741,7 @@ impl RolloutRecorder { agent_nickname: source.get_nickname(), agent_role: source.get_agent_role(), agent_path: source.get_agent_path().map(Into::into), - source, + source: *source, thread_source, model_provider: Some(config.model_provider_id().to_string()), base_instructions: Some(base_instructions), diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 8701578ebf..738fde8992 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -3,6 +3,7 @@ use super::*; use crate::config::RolloutConfig; use chrono::TimeZone; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AgentMessageEvent; @@ -45,6 +46,7 @@ fn write_session_file(root: &Path, ts: &str, uuid: Uuid) -> std::io::Result anyhow::Result<()> { let session_meta_line = SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, @@ -145,7 +148,7 @@ async fn state_db_init_backfills_before_returning() -> anyhow::Result<()> { } #[tokio::test] -async fn load_rollout_items_skips_legacy_ghost_snapshot_lines() -> std::io::Result<()> { +async fn load_rollout_items_defaults_legacy_session_id() -> std::io::Result<()> { let home = TempDir::new().expect("temp dir"); let rollout_path = home.path().join("rollout.jsonl"); let mut file = File::create(&rollout_path)?; @@ -210,7 +213,10 @@ async fn load_rollout_items_skips_legacy_ghost_snapshot_lines() -> std::io::Resu assert_eq!(loaded_thread_id, Some(thread_id)); assert_eq!(parse_errors, 0); assert_eq!(items.len(), 2); - assert!(matches!(items[0], RolloutItem::SessionMeta(_))); + let RolloutItem::SessionMeta(session_meta) = &items[0] else { + panic!("expected session metadata"); + }; + assert_eq!(session_meta.meta.session_id, SessionId::from(thread_id)); assert!(matches!( items[1], RolloutItem::ResponseItem(ResponseItem::Message { .. }) @@ -234,6 +240,7 @@ async fn load_rollout_items_preserves_legacy_guardian_assessment_lines() -> std: "timestamp": ts, "type": "session_meta", "payload": { + "session_id": thread_id, "id": thread_id, "timestamp": ts, "cwd": ".", @@ -297,6 +304,7 @@ async fn load_rollout_items_filters_legacy_ghost_snapshots_from_compaction_histo "timestamp": ts, "type": "session_meta", "payload": { + "session_id": thread_id, "id": thread_id, "timestamp": ts, "cwd": ".", @@ -365,6 +373,7 @@ async fn load_rollout_items_filters_legacy_ghost_snapshots_from_compaction_histo async fn recorder_materializes_on_flush_with_pending_items() -> std::io::Result<()> { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); + let session_id = SessionId::default(); let thread_id = ThreadId::new(); let recorder = RolloutRecorder::new( &config, @@ -376,7 +385,8 @@ async fn recorder_materializes_on_flush_with_pending_items() -> std::io::Result< /*thread_source*/ None, BaseInstructions::default(), Vec::new(), - ), + ) + .with_session_id(session_id), ) .await?; @@ -421,10 +431,12 @@ async fn recorder_materializes_on_flush_with_pending_items() -> std::io::Result< assert!(rollout_path.exists(), "rollout file should be materialized"); let text = std::fs::read_to_string(&rollout_path)?; - assert!( - text.contains("\"type\":\"session_meta\""), - "expected session metadata in rollout" - ); + let first_line = text.lines().next().expect("session metadata line"); + let session_meta: RolloutLine = serde_json::from_str(first_line)?; + let RolloutItem::SessionMeta(session_meta) = session_meta.item else { + panic!("expected session metadata in rollout"); + }; + assert_eq!(session_meta.meta.session_id, session_id); let buffered_idx = text .find("buffered-event") .expect("buffered event in rollout"); @@ -732,6 +744,7 @@ async fn list_threads_state_db_only_skips_jsonl_repair_scan() -> std::io::Result "timestamp": ts, "type": "session_meta", "payload": { + "session_id": uuid, "id": uuid, "timestamp": ts, "cwd": home.path().display().to_string(), diff --git a/codex-rs/rollout/src/session_index_tests.rs b/codex-rs/rollout/src/session_index_tests.rs index 97496d394e..33e4ae4a59 100644 --- a/codex-rs/rollout/src/session_index_tests.rs +++ b/codex-rs/rollout/src/session_index_tests.rs @@ -25,6 +25,7 @@ fn write_rollout_with_metadata(path: &Path, thread_id: ThreadId) -> std::io::Res timestamp: timestamp.clone(), item: RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/rollout/src/state_db_tests.rs b/codex-rs/rollout/src/state_db_tests.rs index ba92ca27f1..4b2ae4f25c 100644 --- a/codex-rs/rollout/src/state_db_tests.rs +++ b/codex-rs/rollout/src/state_db_tests.rs @@ -158,6 +158,7 @@ fn write_rollout_with_user_message( timestamp: "2026-06-01T14:26:25Z".to_string(), item: RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index 0e3ae912dd..21d712e0d1 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -297,6 +297,7 @@ fn write_session_file_with_provider( let mut file = File::create(file_path)?; let mut payload = serde_json::json!({ + "session_id": uuid, "id": uuid, "timestamp": ts_str, "cwd": ".", @@ -370,6 +371,7 @@ fn write_goal_started_session_file( "timestamp": ts_str, "type": "session_meta", "payload": { + "session_id": uuid, "id": uuid, "timestamp": ts_str, "cwd": ".", @@ -451,6 +453,7 @@ fn write_session_file_with_delayed_user_event( Uuid::from_u128(100 + i as u128) }; let payload = serde_json::json!({ + "session_id": uuid, "id": id, "timestamp": ts_str, "cwd": ".", @@ -483,8 +486,9 @@ fn write_session_file_with_meta_payload( root: &Path, ts_str: &str, uuid: Uuid, - payload: serde_json::Value, + mut payload: serde_json::Value, ) -> std::io::Result<()> { + payload["session_id"] = serde_json::json!(uuid); let format: &[FormatItem] = format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); let dt = PrimitiveDateTime::parse(ts_str, format) @@ -1088,6 +1092,7 @@ async fn test_get_thread_contents() { "timestamp": ts, "type": "session_meta", "payload": { + "session_id": uuid, "id": uuid, "timestamp": ts, "cwd": ".", @@ -1267,6 +1272,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { timestamp: ts.to_string(), item: RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + session_id: conversation_id.into(), id: conversation_id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index e156cc9113..b419ef3286 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -321,6 +321,7 @@ mod tests { &mut metadata, &RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: Some( ThreadId::from_string(&Uuid::now_v7().to_string()).expect("thread id"), @@ -513,6 +514,7 @@ mod tests { &mut metadata, &RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index 576f760264..a448dce56b 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -1957,6 +1957,7 @@ mod tests { ); let items = vec![RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, @@ -2018,6 +2019,7 @@ mod tests { ); let items = vec![RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index a60409b740..a93e3644c0 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -110,6 +110,7 @@ mod tests { ] { store .create_thread(CreateThreadParams { + session_id: thread_id.into(), thread_id, extra_config: None, forked_from_id: None, @@ -231,6 +232,7 @@ impl InMemoryThreadStore { let mut state = self.state.lock().await; state.calls.create_thread += 1; let session_meta = SessionMeta { + session_id: params.session_id, id: params.thread_id, forked_from_id: params.forked_from_id, parent_thread_id: params.parent_thread_id, diff --git a/codex-rs/thread-store/src/local/create_thread.rs b/codex-rs/thread-store/src/local/create_thread.rs index 05924881d5..51a96b0815 100644 --- a/codex-rs/thread-store/src/local/create_thread.rs +++ b/codex-rs/thread-store/src/local/create_thread.rs @@ -36,6 +36,7 @@ pub(super) async fn create_thread( params.base_instructions, params.dynamic_tools, ) + .with_session_id(params.session_id) .with_multi_agent_version(params.multi_agent_version), ) .await diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index 19a0d2955b..e158c901ab 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -1123,6 +1123,7 @@ mod tests { fn create_thread_params(thread_id: ThreadId) -> CreateThreadParams { CreateThreadParams { + session_id: thread_id.into(), thread_id, extra_config: None, forked_from_id: None, diff --git a/codex-rs/thread-store/src/local/read_thread.rs b/codex-rs/thread-store/src/local/read_thread.rs index a1e1c59190..1271e4fcf5 100644 --- a/codex-rs/thread-store/src/local/read_thread.rs +++ b/codex-rs/thread-store/src/local/read_thread.rs @@ -821,6 +821,7 @@ mod tests { "timestamp": "2025-01-03T12:00:00Z", "type": "session_meta", "payload": { + "session_id": uuid, "id": uuid, "timestamp": "2025-01-03T12:00:00Z", "cwd": rollout_cwd, @@ -926,6 +927,7 @@ mod tests { "timestamp": "2025-01-03T12-00-00", "type": "session_meta", "payload": { + "session_id": uuid, "id": uuid, "timestamp": "2025-01-03T12-00-00", "cwd": home.path(), @@ -1091,6 +1093,7 @@ mod tests { "timestamp": "2025-01-03T12:00:00Z", "type": "session_meta", "payload": { + "session_id": uuid, "id": uuid, "timestamp": "2025-01-03T12:00:00Z", "cwd": home.path(), diff --git a/codex-rs/thread-store/src/local/test_support.rs b/codex-rs/thread-store/src/local/test_support.rs index 98321880ff..10a55c4fd6 100644 --- a/codex-rs/thread-store/src/local/test_support.rs +++ b/codex-rs/thread-store/src/local/test_support.rs @@ -77,6 +77,7 @@ pub(super) fn write_session_file_with_fork( "timestamp": ts, "type": "session_meta", "payload": { + "session_id": uuid, "id": uuid, "forked_from_id": forked_from_id, "timestamp": ts, diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index 0336006fb8..7940f1c80f 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -579,6 +579,7 @@ mod tests { fn session_meta(thread_id: ThreadId) -> SessionMetaLine { SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, timestamp: "2025-01-03T12:00:00Z".to_string(), source: SessionSource::Exec, diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index 2e3c60723f..74412bb9cd 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use chrono::DateTime; use chrono::Utc; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::BaseInstructions; @@ -63,6 +64,8 @@ pub struct ExtraConfig {} /// Parameters required to create a persisted thread. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct CreateThreadParams { + /// Session id shared by the root thread and all of its subagents. + pub session_id: SessionId, /// Thread id generated by Codex before opening persistence. pub thread_id: ThreadId, /// Optional extra configuration fields for the thread. diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index f189b23149..82d9a72bd8 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -2041,6 +2041,7 @@ mod tests { std::fs::create_dir_all(parent)?; let session_meta = codex_protocol::protocol::SessionMeta { + session_id: thread_id.into(), id: thread_id, timestamp: meta_rfc3339.to_string(), cwd: cwd.to_path_buf(), From c03742ca0a78a8e54cd881032a2327363678b5aa Mon Sep 17 00:00:00 2001 From: jif Date: Mon, 22 Jun 2026 09:05:36 +0100 Subject: [PATCH 101/112] Simplify multi-agent mode controls (#29324) ## Why Multi-agent delegation policy was split across `multiAgentMode`, `features.multi_agent_mode`, and `usage_hint_enabled`. These controls could disagree: a requested mode could be downgraded by the feature flag, and disabling usage hints also disabled mode instructions. Some clients also need multi-agent tools without adding delegation-policy text to model context. The previous two-mode API could not express that directly. ## What changed `multiAgentMode` is now the only live delegation-policy control: | Mode | Behavior | | --- | --- | | `none` | Keep multi-agent tools available without adding mode instructions. | | `explicitRequestOnly` | Only delegate after an explicit user request. | | `proactive` | Delegate when parallel work materially improves speed or quality. | - new threads default to `explicitRequestOnly`; omitting the mode on later turns keeps the current value - thread start, resume, fork, and settings responses always report the concrete current mode instead of `null` - mode selection remains sticky across turns and resume - usage-hint text no longer controls whether mode instructions apply - `features.multi_agent_mode` and `usage_hint_enabled` remain accepted as ignored compatibility settings so existing configs continue to load - app-server documentation and generated schemas describe the three-mode API ## Tests - `just test -p codex-core multi_agent_mode` - `just test -p codex-core multi_agent_v2_config_from_feature_table` - `just test -p codex-core spawn_agent_description` - `just test -p codex-features` - `just test -p codex-app-server-protocol` - `just test -p codex-app-server multi_agent_mode` --- .../schema/json/ClientRequest.json | 3 +- .../schema/json/ServerNotification.json | 3 +- .../codex_app_server_protocol.schemas.json | 3 +- .../codex_app_server_protocol.v2.schemas.json | 3 +- .../schema/json/v2/ThreadForkResponse.json | 3 +- .../schema/json/v2/ThreadResumeResponse.json | 3 +- .../v2/ThreadSettingsUpdatedNotification.json | 3 +- .../schema/json/v2/ThreadStartParams.json | 3 +- .../schema/json/v2/ThreadStartResponse.json | 3 +- .../schema/json/v2/TurnStartParams.json | 3 +- .../schema/typescript/MultiAgentMode.ts | 8 +- .../src/protocol/common.rs | 5 +- .../src/protocol/v2/tests.rs | 7 +- .../src/protocol/v2/thread.rs | 22 +- .../src/protocol/v2/turn.rs | 5 +- codex-rs/app-server/README.md | 8 +- .../tests/suite/v2/thread_settings_update.rs | 7 +- .../app-server/tests/suite/v2/turn_start.rs | 30 +-- codex-rs/core/config.schema.json | 1 + codex-rs/core/src/agent/control/spawn.rs | 1 - codex-rs/core/src/agent/control_tests.rs | 54 ++-- codex-rs/core/src/codex_thread.rs | 2 +- codex-rs/core/src/config/config_tests.rs | 2 - codex-rs/core/src/config/mod.rs | 8 - .../context/multi_agent_mode_instructions.rs | 2 + codex-rs/core/src/context_manager/updates.rs | 5 +- codex-rs/core/src/session/config_lock.rs | 1 - codex-rs/core/src/session/mod.rs | 21 +- codex-rs/core/src/session/multi_agents.rs | 35 +-- codex-rs/core/src/session/session.rs | 4 +- codex-rs/core/src/session/tests.rs | 7 +- codex-rs/core/src/session/turn_context.rs | 4 +- codex-rs/core/src/thread_manager.rs | 2 +- .../src/tools/handlers/multi_agents_spec.rs | 11 - .../tools/handlers/multi_agents_spec_tests.rs | 4 - .../src/tools/handlers/multi_agents_tests.rs | 11 +- .../tools/handlers/multi_agents_v2/spawn.rs | 4 - codex-rs/core/src/tools/spec_plan.rs | 2 - codex-rs/core/tests/suite/multi_agent_mode.rs | 247 +++++++++--------- codex-rs/features/src/feature_configs.rs | 1 + codex-rs/features/src/lib.rs | 4 +- codex-rs/features/src/tests.rs | 39 --- codex-rs/protocol/src/config_types.rs | 7 +- codex-rs/protocol/src/protocol.rs | 2 +- 44 files changed, 253 insertions(+), 350 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 4ce6ac1a82..18c147ba36 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1761,8 +1761,9 @@ "type": "object" }, "MultiAgentMode": { - "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", "enum": [ + "none", "explicitRequestOnly", "proactive" ], diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index f1fbee5c57..74fb9e739c 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2645,8 +2645,9 @@ "type": "object" }, "MultiAgentMode": { - "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", "enum": [ + "none", "explicitRequestOnly", "proactive" ], diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index bf7f0e5b30..ec0321d9b1 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -12693,8 +12693,9 @@ "type": "object" }, "MultiAgentMode": { - "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", "enum": [ + "none", "explicitRequestOnly", "proactive" ], diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 7a080b56bd..cd0f46bd47 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -9097,8 +9097,9 @@ "type": "object" }, "MultiAgentMode": { - "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", "enum": [ + "none", "explicitRequestOnly", "proactive" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 11efbb7e82..c19251667a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -663,8 +663,9 @@ ] }, "MultiAgentMode": { - "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", "enum": [ + "none", "explicitRequestOnly", "proactive" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index de242b62b3..1a3b22f12c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -663,8 +663,9 @@ ] }, "MultiAgentMode": { - "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", "enum": [ + "none", "explicitRequestOnly", "proactive" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json index 67a0f5c5d9..ca2b63634a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json @@ -109,8 +109,9 @@ "type": "string" }, "MultiAgentMode": { - "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", "enum": [ + "none", "explicitRequestOnly", "proactive" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index eebb5da960..9932456f1b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -195,8 +195,9 @@ "type": "string" }, "MultiAgentMode": { - "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", "enum": [ + "none", "explicitRequestOnly", "proactive" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 4ad961c22c..a754c6bc0f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -663,8 +663,9 @@ ] }, "MultiAgentMode": { - "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", "enum": [ + "none", "explicitRequestOnly", "proactive" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index 9460f80688..bec8b7d89a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -142,8 +142,9 @@ "type": "string" }, "MultiAgentMode": { - "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", "enum": [ + "none", "explicitRequestOnly", "proactive" ], diff --git a/codex-rs/app-server-protocol/schema/typescript/MultiAgentMode.ts b/codex-rs/app-server-protocol/schema/typescript/MultiAgentMode.ts index 93b790a592..b59be94f8e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/MultiAgentMode.ts +++ b/codex-rs/app-server-protocol/schema/typescript/MultiAgentMode.ts @@ -3,7 +3,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. /** - * Controls whether the model should only spawn sub-agents after an explicit - * user request or may delegate proactively when doing so would help. + * Controls whether the model receives multi-agent delegation instructions and, + * when it does, whether it should only spawn sub-agents after an explicit user + * request or may delegate proactively when doing so would help. `none` leaves + * the multi-agent tools available without injecting delegation instructions. */ -export type MultiAgentMode = "explicitRequestOnly" | "proactive"; +export type MultiAgentMode = "none" | "explicitRequestOnly" | "proactive"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index eb3e6c82ff..0b1e11133d 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1701,6 +1701,7 @@ mod tests { use codex_protocol::ThreadId; use codex_protocol::account::AmazonBedrockCredentialSource; use codex_protocol::account::PlanType; + use codex_protocol::config_types::MultiAgentMode; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::RealtimeConversationVersion; @@ -2584,7 +2585,7 @@ mod tests { sandbox: v2::SandboxPolicy::DangerFullAccess, active_permission_profile: None, reasoning_effort: None, - multi_agent_mode: None, + multi_agent_mode: MultiAgentMode::ExplicitRequestOnly, }, }; @@ -2633,7 +2634,7 @@ mod tests { }, "activePermissionProfile": null, "reasoningEffort": null, - "multiAgentMode": null + "multiAgentMode": "explicitRequestOnly" } }), serde_json::to_value(&response)?, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 0161cd4ef9..74e534be00 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -1,5 +1,6 @@ use super::*; use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::items::AgentMessageContent; use codex_protocol::items::AgentMessageItem; use codex_protocol::items::FileChangeItem; @@ -3708,7 +3709,11 @@ fn thread_lifecycle_responses_default_missing_optional_fields() { resume.multi_agent_mode, fork.multi_agent_mode, ), - (None, None, None) + ( + MultiAgentMode::ExplicitRequestOnly, + MultiAgentMode::ExplicitRequestOnly, + MultiAgentMode::ExplicitRequestOnly, + ) ); let foreign_source: LegacyAppPathString = diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 3c527d6219..6a51f71e51 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -94,9 +94,9 @@ pub struct ThreadStartParams { pub developer_instructions: Option, #[ts(optional = nullable)] pub personality: Option, - /// Set the initial multi-agent mode for this thread. - /// Omitted leaves the thread without a selected mode. Eligible multi-agent - /// v2 turns still default to `explicitRequestOnly`. + /// Set the initial multi-agent mode for this thread. `none` leaves the + /// multi-agent tools available without injecting mode instructions. + /// Omitted defaults to `explicitRequestOnly`. #[experimental("thread/start.multiAgentMode")] #[ts(optional = nullable)] pub multi_agent_mode: Option, @@ -186,10 +186,10 @@ pub struct ThreadStartResponse { #[serde(default)] pub active_permission_profile: Option, pub reasoning_effort: Option, - /// Current selected multi-agent mode for this thread, if one was selected. + /// Current multi-agent mode for this thread. #[experimental("thread/start.multiAgentMode")] #[serde(default)] - pub multi_agent_mode: Option, + pub multi_agent_mode: MultiAgentMode, } impl ThreadStartResponse { @@ -279,10 +279,10 @@ pub struct ThreadSettings { pub effort: Option, pub summary: Option, pub collaboration_mode: CollaborationMode, - /// Current selected multi-agent mode for this thread, if one was selected. + /// Current multi-agent mode for this thread. #[experimental("thread/settings.multiAgentMode")] #[serde(default)] - pub multi_agent_mode: Option, + pub multi_agent_mode: MultiAgentMode, pub personality: Option, } @@ -419,10 +419,10 @@ pub struct ThreadResumeResponse { #[serde(default)] pub active_permission_profile: Option, pub reasoning_effort: Option, - /// Current selected multi-agent mode for this thread, if one was selected. + /// Current multi-agent mode for this thread. #[experimental("thread/resume.multiAgentMode")] #[serde(default)] - pub multi_agent_mode: Option, + pub multi_agent_mode: MultiAgentMode, /// `thread/turns/list` page returned when requested by `initialTurnsPage`. #[experimental("thread/resume.initialTurnsPage")] #[serde(default)] @@ -578,10 +578,10 @@ pub struct ThreadForkResponse { #[serde(default)] pub active_permission_profile: Option, pub reasoning_effort: Option, - /// Current selected multi-agent mode for this thread, if one was selected. + /// Current multi-agent mode for this thread. #[experimental("thread/fork.multiAgentMode")] #[serde(default)] - pub multi_agent_mode: Option, + pub multi_agent_mode: MultiAgentMode, } impl ThreadForkResponse { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs index 7ecb9f94b7..1fc35d1515 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -151,8 +151,9 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub collaboration_mode: Option, - /// Controls whether multi-agent v2 delegation requires an explicit user request. - /// Omitted keeps the loaded session's current mode. + /// Controls multi-agent v2 delegation instructions. `none` leaves the + /// multi-agent tools available without injecting mode instructions. Omitted + /// keeps the loaded session's current mode. #[experimental("turn/start.multiAgentMode")] #[ts(optional = nullable)] pub multi_agent_mode: Option, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 8de6e3b8c7..e823eb1007 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -137,17 +137,17 @@ Example with notification opt-out: ## API Overview -- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `multiAgentMode` selects the initial thread mode; omission leaves the selected mode unset, while eligible multi-agent v2 turns still default to `explicitRequestOnly`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are also started in that environment; HTTP MCP declarations remain inactive. +- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `multiAgentMode` selects the initial thread mode and defaults to `explicitRequestOnly` when omitted; use `none` to keep multi-agent tools available without injecting mode instructions. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are also started in that environment; HTTP MCP declarations remain inactive. - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. Multi-agent mode restores the last effective mode from rollout history when available; clients can select another mode on the first `turn/start`. - `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`. -- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. `instructionSources` lists loaded instruction files using each source environment's native absolute path syntax, including files loaded from remote environments. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. Their experimental `multiAgentMode` field, and the corresponding thread setting, report the thread's current selected mode or `null` when no mode was selected. Turn construction separately determines whether that mode is applicable to the selected model and runtime configuration. +- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. `instructionSources` lists loaded instruction files using each source environment's native absolute path syntax, including files loaded from remote environments. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. Their experimental `multiAgentMode` field, and the corresponding thread setting, report the thread's current mode. Turn construction separately determines whether that mode is applicable to the selected model and runtime configuration. - `thread/list` — page through stored threads; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Experimental clients can use `parentThreadId` to filter direct spawned children represented by persisted spawn-edge state. Review and Guardian threads are not included because they do not participate in that spawn-edge lifecycle. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. Subagent threads also include `parentThreadId` when the immediate parent is known. - `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. - `thread/turns/list` — experimental; page through a stored thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `itemsView`, `nextCursor`, and `backwardsCursor`. - `thread/turns/items/list` — experimental; reserved for paging full items for one turn. The API shape is present, but app-server currently returns an unsupported-method JSON-RPC error. - `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`. -- `thread/settings/update` — experimental; queue a partial update to a loaded thread’s next-turn settings without starting a turn or adding transcript items. Omitted fields leave settings unchanged; `serviceTier: null` clears the tier; `multiAgentMode` selects `explicitRequestOnly` or `proactive` for subsequent turns; `sandboxPolicy` and `permissions` cannot be combined. Returns `{}` when the update is accepted and emits `thread/settings/updated` with the full effective settings only if they actually change. `turn/start` settings overrides emit the same notification when they change the stored settings. +- `thread/settings/update` — experimental; queue a partial update to a loaded thread’s next-turn settings without starting a turn or adding transcript items. Omitted fields leave settings unchanged; `serviceTier: null` clears the tier; `multiAgentMode` selects `none`, `explicitRequestOnly`, or `proactive` for subsequent turns; `sandboxPolicy` and `permissions` cannot be combined. Returns `{}` when the update is accepted and emits `thread/settings/updated` with the full effective settings only if they actually change. `turn/start` settings overrides emit the same notification when they change the stored settings. - `thread/memoryMode/set` — experimental; set a thread’s persisted memory eligibility to `"enabled"` or `"disabled"` for either a loaded thread or a stored rollout; returns `{}` on success. - `memory/reset` — experimental; clear the current `CODEX_HOME/memories` directory and reset persisted memory stage data in sqlite while preserving existing thread memory modes; returns `{}` on success. - `thread/goal/set` — create or update the single persisted goal for a materialized thread; returns the current goal and emits `thread/goal/updated`. @@ -168,7 +168,7 @@ Example with notification opt-out: - `thread/backgroundTerminals/list` — list running background terminals for a loaded thread (experimental; requires `capabilities.experimentalApi`); returns `data` with the running terminal ids. - `thread/backgroundTerminals/terminate` — terminate one running background terminal by app-server `processId` (experimental; requires `capabilities.experimentalApi`); returns whether a process was terminated. - `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. -- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". Experimental `multiAgentMode` accepts `explicitRequestOnly` or `proactive`; omission keeps the loaded session's current selected mode, including an unset mode. The requested mode is retained for the loaded session without rejecting unsupported configurations. Eligible multi-agent v2 turns default an unset mode to explicit-request-only, use the selected mode when `features.multi_agent_mode` is enabled, and otherwise use explicit-request-only developer instructions. +- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". Experimental `multiAgentMode` accepts `none`, `explicitRequestOnly`, or `proactive`; `none` keeps the tools available without injecting mode instructions, and omission keeps the loaded session's current mode. The requested mode is retained for the loaded session without rejecting unsupported configurations, and eligible multi-agent v2 turns use it directly. - `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success. - `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Review and manual compaction turns reject `turn/steer`. - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. diff --git a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs index efdf13aae9..9ad25ca992 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs @@ -117,10 +117,7 @@ async fn thread_settings_update_multi_agent_mode_applies_to_future_turns() -> Re write_mock_responses_config_toml( codex_home.path(), &server.uri(), - &BTreeMap::from([ - (Feature::MultiAgentV2, true), - (Feature::MultiAgentMode, true), - ]), + &BTreeMap::from([(Feature::MultiAgentV2, true)]), /*auto_compact_limit*/ 200_000, /*requires_openai_auth*/ None, "mock_provider", @@ -158,7 +155,7 @@ async fn thread_settings_update_multi_agent_mode_applies_to_future_turns() -> Re assert_eq!(updated.thread_id, thread.id); assert_eq!( updated.thread_settings.multi_agent_mode, - Some(MultiAgentMode::Proactive) + MultiAgentMode::Proactive ); start_text_turn(&mut mcp, thread.id).await?; diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 10d1599967..3edd8da6fb 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1768,10 +1768,7 @@ async fn turn_start_accepts_multi_agent_mode_v2() -> Result<()> { codex_home.path(), &server.uri(), "never", - &BTreeMap::from([ - (Feature::MultiAgentV2, true), - (Feature::MultiAgentMode, true), - ]), + &BTreeMap::from([(Feature::MultiAgentV2, true)]), )?; let mut mcp = TestAppServer::new(codex_home.path()).await?; @@ -1845,10 +1842,7 @@ async fn thread_start_multi_agent_mode_initializes_first_turn() -> Result<()> { codex_home.path(), &server.uri(), "never", - &BTreeMap::from([ - (Feature::MultiAgentV2, true), - (Feature::MultiAgentMode, true), - ]), + &BTreeMap::from([(Feature::MultiAgentV2, true)]), )?; let mut mcp = TestAppServer::new(codex_home.path()).await?; @@ -1871,7 +1865,7 @@ async fn thread_start_multi_agent_mode_initializes_first_turn() -> Result<()> { multi_agent_mode, .. } = to_response::(thread_resp)?; - assert_eq!(multi_agent_mode, Some(MultiAgentMode::Proactive)); + assert_eq!(multi_agent_mode, MultiAgentMode::Proactive); let turn_req = mcp .send_turn_start_request(TurnStartParams { @@ -1911,27 +1905,29 @@ async fn thread_start_multi_agent_mode_initializes_first_turn() -> Result<()> { } #[tokio::test] -async fn thread_start_reports_selected_multi_agent_mode() -> Result<()> { +async fn thread_start_reports_multi_agent_mode() -> Result<()> { skip_if_no_network!(Ok(())); let cases = [ ( BTreeMap::from([(Feature::MultiAgentV2, true)]), Some(MultiAgentMode::Proactive), - Some(MultiAgentMode::Proactive), + MultiAgentMode::Proactive, + ), + ( + BTreeMap::from([(Feature::MultiAgentV2, true)]), + Some(MultiAgentMode::None), + MultiAgentMode::None, ), ( BTreeMap::new(), Some(MultiAgentMode::Proactive), - Some(MultiAgentMode::Proactive), + MultiAgentMode::Proactive, ), ( - BTreeMap::from([ - (Feature::MultiAgentV2, true), - (Feature::MultiAgentMode, true), - ]), - None, + BTreeMap::from([(Feature::MultiAgentV2, true)]), None, + MultiAgentMode::ExplicitRequestOnly, ), ]; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 003ca63393..b6736c9407 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1725,6 +1725,7 @@ "type": "string" }, "usage_hint_enabled": { + "description": "Deprecated compatibility field. Its value is ignored.", "type": "boolean" }, "usage_hint_text": { diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index 3f746ef790..05fac6aaf3 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -498,7 +498,6 @@ impl AgentControl { } if preserve_reference_context_item && multi_agent_version == MultiAgentVersion::V2 - && config.multi_agent_v2.usage_hint_enabled && let Some(subagent_usage_hint_text) = config.multi_agent_v2.subagent_usage_hint_text.clone() && let Some(subagent_usage_hint_message) = diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 8dbcab3380..7eac69a88f 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -850,10 +850,7 @@ async fn spawn_thread_subagent_uses_supplied_initial_multi_agent_mode_without_hi .config_snapshot() .await; - assert_eq!( - child_snapshot.multi_agent_mode, - Some(MultiAgentMode::Proactive) - ); + assert_eq!(child_snapshot.multi_agent_mode, MultiAgentMode::Proactive); } #[tokio::test] @@ -987,7 +984,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { .expect("child thread should be registered"); assert_eq!( child_thread.config_snapshot().await.multi_agent_mode, - Some(MultiAgentMode::Proactive) + MultiAgentMode::Proactive ); assert_ne!(child_thread_id, parent_thread_id); let history = child_thread.codex.session.clone_history().await; @@ -1025,18 +1022,13 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { "full-history forked child should preserve the parent diff baseline" ); - let mut disabled_hint_child_config = harness.config.clone(); - let _ = disabled_hint_child_config - .features - .enable(Feature::MultiAgentV2); - disabled_hint_child_config.multi_agent_v2.usage_hint_enabled = false; - disabled_hint_child_config - .multi_agent_v2 - .subagent_usage_hint_text = Some("Disabled child subagent guidance.".to_string()); - let disabled_hint_child_thread_id = harness + let mut no_hint_child_config = harness.config.clone(); + let _ = no_hint_child_config.features.enable(Feature::MultiAgentV2); + no_hint_child_config.multi_agent_v2.subagent_usage_hint_text = None; + let no_hint_child_thread_id = harness .control .spawn_agent_with_metadata( - disabled_hint_child_config, + no_hint_child_config, text_input("child task without hints"), Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, @@ -1052,24 +1044,17 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { }, ) .await - .expect("forked spawn should honor disabled usage hints") + .expect("forked spawn should honor an empty subagent usage hint") .thread_id; - let disabled_hint_child_thread = harness + let no_hint_child_thread = harness .manager - .get_thread(disabled_hint_child_thread_id) + .get_thread(no_hint_child_thread_id) .await - .expect("disabled-hint child thread should be registered"); - let disabled_hint_history = disabled_hint_child_thread - .codex - .session - .clone_history() - .await; + .expect("no-hint child thread should be registered"); + let no_hint_history = no_hint_child_thread.codex.session.clone_history().await; assert!( - !history_contains_text( - disabled_hint_history.raw_items(), - "Disabled child subagent guidance.", - ), - "full-history forked child should not add subagent guidance when usage hints are disabled" + !history_contains_text(no_hint_history.raw_items(), "Child subagent guidance."), + "full-history forked child should not add empty subagent guidance" ); let expected = ( @@ -1099,9 +1084,9 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { .expect("child shutdown should submit"); let _ = harness .control - .shutdown_live_agent(disabled_hint_child_thread_id) + .shutdown_live_agent(no_hint_child_thread_id) .await - .expect("disabled-hint child shutdown should submit"); + .expect("no-hint child shutdown should submit"); let _ = parent_thread .submit(Op::Shutdown {}) .await @@ -1406,7 +1391,7 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() { .expect("child thread should be registered"); assert_eq!( child_thread.config_snapshot().await.multi_agent_mode, - Some(MultiAgentMode::Proactive) + MultiAgentMode::Proactive ); let history = child_thread.codex.session.clone_history().await; @@ -2422,10 +2407,7 @@ async fn resume_thread_subagent_restores_stored_metadata_and_effective_multi_age assert_eq!(resumed_agent_path, Some(agent_path)); assert_eq!(resumed_nickname, Some(original_nickname)); assert_eq!(resumed_role, Some("explorer".to_string())); - assert_eq!( - resumed_snapshot.multi_agent_mode, - Some(MultiAgentMode::Proactive) - ); + assert_eq!(resumed_snapshot.multi_agent_mode, MultiAgentMode::Proactive); let _ = harness .control diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 0f63323d48..85ca1e1a61 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -72,7 +72,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_summary: Option, pub personality: Option, pub collaboration_mode: CollaborationMode, - pub multi_agent_mode: Option, + pub multi_agent_mode: MultiAgentMode, pub session_source: SessionSource, pub forked_from_thread_id: Option, pub parent_thread_id: Option, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index ce93740166..be332cd603 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -10114,7 +10114,6 @@ max_concurrent_threads_per_session = 5 min_wait_timeout_ms = 2500 max_wait_timeout_ms = 120000 default_wait_timeout_ms = 30000 -usage_hint_enabled = false usage_hint_text = "Custom delegation guidance." root_agent_usage_hint_text = "Root guidance." subagent_usage_hint_text = "Subagent guidance." @@ -10142,7 +10141,6 @@ non_code_mode_only = true ), (None, Some(4)) ); - assert!(!config.multi_agent_v2.usage_hint_enabled); assert_eq!( config.multi_agent_v2.usage_hint_text.as_deref(), Some("Custom delegation guidance.") diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index d5986501a4..dba933b111 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -241,8 +241,6 @@ You may also see them addressed as to=/root/..., which indicates your identity i "#; const DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT: &str = r#"Note that collaboration tools cannot be called from inside `functions.exec`. Call `spawn_agent`, `send_message`, `followup_task`, `wait_agent`, `interrupt_agent`, and `list_agents` only as direct tool calls using the recipient shown in their tool definitions, such as `to=functions.spawn_agent` without a configured namespace or `to=functions.agents.spawn_agent` with `tool_namespace = "agents"`, since they are intentionally absent from the `functions.exec` `tools.*` namespace. Available tools in `functions.exec` are explicitly described with a `tools` namespace in the developer message. -The goal is to correctly solve the problem in as little time as possible. Therefore, if at any point you can parallelize work by delegating tasks to another agent, you should do so to save time. - All agents share the same directory. In detail: - All agents have access to the same container and filesystem as you. - All agents use the same current working directory. @@ -1128,7 +1126,6 @@ pub struct MultiAgentV2Config { pub min_wait_timeout_ms: i64, pub max_wait_timeout_ms: i64, pub default_wait_timeout_ms: i64, - pub usage_hint_enabled: bool, pub usage_hint_text: Option, pub root_agent_usage_hint_text: Option, pub subagent_usage_hint_text: Option, @@ -1144,7 +1141,6 @@ impl MultiAgentV2Config { min_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS, max_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS, default_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS, - usage_hint_enabled: true, usage_hint_text: None, root_agent_usage_hint_text: Some(default_multi_agent_v2_usage_hint_text( DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT, @@ -2492,9 +2488,6 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config let default_wait_timeout_ms = base .and_then(|config| config.default_wait_timeout_ms) .unwrap_or(default.default_wait_timeout_ms); - let usage_hint_enabled = base - .and_then(|config| config.usage_hint_enabled) - .unwrap_or(default.usage_hint_enabled); let usage_hint_text = base .and_then(|config| config.usage_hint_text.as_ref()) .cloned() @@ -2523,7 +2516,6 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config min_wait_timeout_ms, max_wait_timeout_ms, default_wait_timeout_ms, - usage_hint_enabled, usage_hint_text, root_agent_usage_hint_text, subagent_usage_hint_text, diff --git a/codex-rs/core/src/context/multi_agent_mode_instructions.rs b/codex-rs/core/src/context/multi_agent_mode_instructions.rs index a272e7c2bb..fc4debe5a2 100644 --- a/codex-rs/core/src/context/multi_agent_mode_instructions.rs +++ b/codex-rs/core/src/context/multi_agent_mode_instructions.rs @@ -4,6 +4,7 @@ use codex_protocol::protocol::MULTI_AGENT_MODE_CLOSE_TAG; use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG; const EXPLICIT_REQUEST_ONLY_MULTI_AGENT_MODE_TEXT: &str = "Do not spawn sub-agents unless the user explicitly asks for sub-agents, delegation, or parallel agent work."; +const NO_MULTI_AGENT_MODE_TEXT: &str = "Multi-agent delegation mode instructions are inactive. Any earlier multi-agent mode developer message no longer applies."; const PROACTIVE_MULTI_AGENT_MODE_TEXT: &str = "Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Use sub-agents when parallel work would materially improve speed or quality. This mode remains active until a later multi-agent mode developer message changes it."; #[derive(Debug, Clone, PartialEq, Eq)] @@ -32,6 +33,7 @@ impl ContextualUserFragment for MultiAgentModeInstructions { fn body(&self) -> String { match self.multi_agent_mode { + MultiAgentMode::None => NO_MULTI_AGENT_MODE_TEXT.to_string(), MultiAgentMode::ExplicitRequestOnly => { EXPLICIT_REQUEST_ONLY_MULTI_AGENT_MODE_TEXT.to_string() } diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 7cf877a728..22f2dbe6fe 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -103,10 +103,8 @@ fn build_multi_agent_mode_update_item( ) -> Option { let effective_multi_agent_mode = crate::session::multi_agents::effective_multi_agent_mode( next.multi_agent_version, - &next.config.multi_agent_v2, &next.session_source, next.multi_agent_mode, - next.config.features.enabled(Feature::MultiAgentMode), ); let previous = previous?; if previous.multi_agent_mode == effective_multi_agent_mode { @@ -114,6 +112,9 @@ fn build_multi_agent_mode_update_item( } match effective_multi_agent_mode { + Some(MultiAgentMode::None) => { + Some(MultiAgentModeInstructions::new(MultiAgentMode::None).render()) + } Some(multi_agent_mode) => Some(MultiAgentModeInstructions::new(multi_agent_mode).render()), None if previous.multi_agent_mode == Some(MultiAgentMode::Proactive) => { Some(MultiAgentModeInstructions::new(MultiAgentMode::ExplicitRequestOnly).render()) diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index 2553f71504..e91c4f4895 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -327,7 +327,6 @@ mod tests { min_wait_timeout_ms: Some(_), max_wait_timeout_ms: Some(_), default_wait_timeout_ms: Some(_), - usage_hint_enabled: Some(_), hide_spawn_agent_metadata: Some(_), .. }) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index c85898555b..f3c7a950dd 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -586,7 +586,7 @@ impl Codex { .await; let multi_agent_version = resolve_multi_agent_version(&conversation_history, inherited_multi_agent_version); - let multi_agent_mode = initial_multi_agent_mode; + let multi_agent_mode = initial_multi_agent_mode.unwrap_or_default(); config .validate_multi_agent_v2_config() .map_err(|err| CodexErr::InvalidRequest(err.to_string()))?; @@ -3290,19 +3290,20 @@ impl Session { { items.push(usage_hint_message); } - if let Some(multi_agent_mode) = multi_agents::effective_multi_agent_mode( + match multi_agents::effective_multi_agent_mode( turn_context.multi_agent_version, - &turn_context.config.multi_agent_v2, &session_source, turn_context.multi_agent_mode, - turn_context - .config - .features - .enabled(Feature::MultiAgentMode), ) { - items.push(ContextualUserFragment::into( - MultiAgentModeInstructions::new(multi_agent_mode), - )); + Some( + multi_agent_mode + @ (MultiAgentMode::ExplicitRequestOnly | MultiAgentMode::Proactive), + ) => { + items.push(ContextualUserFragment::into( + MultiAgentModeInstructions::new(multi_agent_mode), + )); + } + Some(MultiAgentMode::None) | None => {} } if let Some(contextual_user_message) = crate::context_manager::updates::build_contextual_user_message(contextual_user_sections) diff --git a/codex-rs/core/src/session/multi_agents.rs b/codex-rs/core/src/session/multi_agents.rs index 38651380eb..82fcfd1282 100644 --- a/codex-rs/core/src/session/multi_agents.rs +++ b/codex-rs/core/src/session/multi_agents.rs @@ -14,10 +14,6 @@ pub(super) fn usage_hint_text<'a>( } let multi_agent_v2 = &turn_context.config.multi_agent_v2; - if !multi_agent_v2.usage_hint_enabled { - return None; - } - configured_usage_hint_text_for_source(multi_agent_v2, session_source) } @@ -39,30 +35,23 @@ fn configured_usage_hint_text_for_source<'a>( } } -fn multi_agent_mode_is_applicable( - multi_agent_version: MultiAgentVersion, - multi_agent_v2: &MultiAgentV2Config, - session_source: &SessionSource, -) -> bool { - multi_agent_version == MultiAgentVersion::V2 - && multi_agent_v2.usage_hint_enabled - && configured_usage_hint_text_for_source(multi_agent_v2, session_source).is_some() -} - pub(crate) fn effective_multi_agent_mode( multi_agent_version: MultiAgentVersion, - multi_agent_v2: &MultiAgentV2Config, session_source: &SessionSource, - requested_multi_agent_mode: Option, - multi_agent_mode_enabled: bool, + multi_agent_mode: MultiAgentMode, ) -> Option { - if !multi_agent_mode_is_applicable(multi_agent_version, multi_agent_v2, session_source) { + if multi_agent_version != MultiAgentVersion::V2 { return None; } - Some(if multi_agent_mode_enabled { - requested_multi_agent_mode.unwrap_or_default() - } else { - MultiAgentMode::ExplicitRequestOnly - }) + match session_source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. }) + | SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => Some(multi_agent_mode), + SessionSource::Internal(_) | SessionSource::SubAgent(_) => None, + } } diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 9317428340..759d159cc2 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -52,7 +52,7 @@ pub(crate) struct SessionConfiguration { pub(super) provider: ModelProviderInfo, pub(super) collaboration_mode: CollaborationMode, - pub(super) multi_agent_mode: Option, + pub(super) multi_agent_mode: MultiAgentMode, pub(super) model_reasoning_summary: Option, pub(super) service_tier: Option, @@ -231,7 +231,7 @@ impl SessionConfiguration { next_configuration.collaboration_mode = collaboration_mode; } if let Some(multi_agent_mode) = updates.multi_agent_mode { - next_configuration.multi_agent_mode = Some(multi_agent_mode); + next_configuration.multi_agent_mode = multi_agent_mode; } if let Some(summary) = updates.reasoning_summary { next_configuration.model_reasoning_summary = Some(summary); diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index f3f96b74a7..edd5ace9ed 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -7895,15 +7895,14 @@ async fn build_initial_context_omits_multi_agent_v2_usage_hints_when_feature_dis } #[tokio::test] -async fn build_initial_context_omits_multi_agent_v2_usage_hints_when_hint_disabled() { +async fn build_initial_context_omits_multi_agent_v2_usage_hints_when_hint_is_empty() { let (session, turn_context, _rx_event) = make_session_and_context_with_auth_and_config_and_rx( CodexAuth::from_api_key("Test API Key"), Vec::new(), |config| { let _ = config.features.enable(Feature::MultiAgentV2); - config.multi_agent_v2.usage_hint_enabled = false; - config.multi_agent_v2.root_agent_usage_hint_text = Some("Root guidance.".to_string()); - config.multi_agent_v2.subagent_usage_hint_text = Some("Subagent guidance.".to_string()); + config.multi_agent_v2.root_agent_usage_hint_text = None; + config.multi_agent_v2.subagent_usage_hint_text = None; }, ) .await; diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index bb434fa2c9..2fb02e7fc1 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -125,7 +125,7 @@ pub struct TurnContext { pub(crate) developer_instructions: Option, pub(crate) user_instructions: Option, pub(crate) collaboration_mode: CollaborationMode, - pub(crate) multi_agent_mode: Option, + pub(crate) multi_agent_mode: MultiAgentMode, pub(crate) multi_agent_version: MultiAgentVersion, pub(crate) personality: Option, pub(crate) approval_policy: Constrained, @@ -386,10 +386,8 @@ impl TurnContext { multi_agent_version: Some(self.multi_agent_version), multi_agent_mode: super::multi_agents::effective_multi_agent_mode( self.multi_agent_version, - &self.config.multi_agent_v2, &self.session_source, self.multi_agent_mode, - self.config.features.enabled(Feature::MultiAgentMode), ), realtime_active: Some(self.realtime_active), effort: self.reasoning_effort.clone(), diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 3a0e374750..2cce3bb8a8 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -976,7 +976,7 @@ impl ThreadManager { }; let initial_multi_agent_mode = match source_thread_id { Some(thread_id) => match self.get_thread(thread_id).await { - Ok(thread) => thread.config_snapshot().await.multi_agent_mode, + Ok(thread) => Some(thread.config_snapshot().await.multi_agent_mode), Err(_) => history.get_latest_effective_multi_agent_mode(), }, None => history.get_latest_effective_multi_agent_mode(), diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs index 70f71f3114..08f8ca071f 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -24,7 +24,6 @@ pub struct SpawnAgentToolOptions { pub available_models: Vec, pub agent_type_description: String, pub hide_agent_type_model_reasoning: bool, - pub include_usage_hint: bool, pub usage_hint_text: Option, } @@ -66,7 +65,6 @@ pub fn create_spawn_agent_tool_v1(options: SpawnAgentToolOptions) -> ToolSpec { available_models_description.as_deref(), inherited_model_guidance, return_value_description, - options.include_usage_hint, options.usage_hint_text, ), strict: false, @@ -99,7 +97,6 @@ pub fn create_spawn_agent_tool_v2(options: SpawnAgentToolOptions) -> ToolSpec { description: spawn_agent_tool_description_v2( available_models_description.as_deref(), inherited_model_guidance, - options.include_usage_hint, options.usage_hint_text, ), strict: false, @@ -648,7 +645,6 @@ fn spawn_agent_tool_description( available_models_description: Option<&str>, inherited_model_guidance: Option<&str>, return_value_description: &str, - include_usage_hint: bool, usage_hint_text: Option, ) -> String { let agent_role_guidance = available_models_description.unwrap_or_default(); @@ -660,9 +656,6 @@ fn spawn_agent_tool_description( Spawn a sub-agent for a well-scoped task. {return_value_description} {inherited_model_guidance}"# ); - if !include_usage_hint { - return tool_description; - } if let Some(usage_hint_text) = usage_hint_text { return format!( r#" @@ -711,7 +704,6 @@ Do not spawn sub-agents unless the user explicitly asks for sub-agents, delegati fn spawn_agent_tool_description_v2( available_models_description: Option<&str>, inherited_model_guidance: Option<&str>, - include_usage_hint: bool, usage_hint_text: Option, ) -> String { let agent_role_guidance = available_models_description.unwrap_or_default(); @@ -731,9 +723,6 @@ The new agent's canonical task name will be provided to it along with the messag Note that passing `fork_turns="none"` will not pass any surrounding context to the spawned subagent, which may cause the agent to lack the context it needs to complete its task, whereas `fork_turns="all"` will provide the subagent with all surrounding context."# ); - if !include_usage_hint { - return tool_description; - } if let Some(usage_hint_text) = usage_hint_text { return format!( r#" diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs index 1ef93b77f0..0228f7acb9 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs @@ -45,7 +45,6 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() { ], agent_type_description: "role help".to_string(), hide_agent_type_model_reasoning: false, - include_usage_hint: true, usage_hint_text: None, }); @@ -121,7 +120,6 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() { available_models: Vec::new(), agent_type_description: "role help".to_string(), hide_agent_type_model_reasoning: false, - include_usage_hint: true, usage_hint_text: None, }); @@ -178,7 +176,6 @@ fn spawn_agent_tool_caps_visible_model_summaries() { ], agent_type_description: "role help".to_string(), hide_agent_type_model_reasoning: false, - include_usage_hint: true, usage_hint_text: None, }); @@ -222,7 +219,6 @@ fn spawn_agent_tool_hides_service_tier_with_spawn_metadata() { available_models: vec![model_preset("visible", /*show_in_picker*/ true)], agent_type_description: "role help".to_string(), hide_agent_type_model_reasoning: true, - include_usage_hint: true, usage_hint_text: None, }); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index eecdfc178d..76881f5a06 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -1148,11 +1148,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - config - .features - .enable(Feature::MultiAgentMode) - .expect("test config should allow feature update"); - turn.multi_agent_mode = Some(MultiAgentMode::Proactive); + turn.multi_agent_mode = MultiAgentMode::Proactive; set_turn_config(&mut turn, config); let session = Arc::new(session); @@ -1191,10 +1187,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat child_snapshot.session_source.get_agent_path().as_deref(), Some("/root/test_process") ); - assert_eq!( - child_snapshot.multi_agent_mode, - Some(MultiAgentMode::Proactive) - ); + assert_eq!(child_snapshot.multi_agent_mode, MultiAgentMode::Proactive); assert!(manager.captured_ops().iter().any(|(id, op)| { *id == child_thread_id && matches!( diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index ed63102d6a..c04d46c6e4 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -52,12 +52,8 @@ async fn handle_spawn_agent( let fork_mode = args.fork_mode()?; let multi_agent_mode = crate::session::multi_agents::effective_multi_agent_mode( turn.multi_agent_version, - &turn.config.multi_agent_v2, &turn.session_source, turn.multi_agent_mode, - turn.config - .features - .enabled(codex_features::Feature::MultiAgentMode), ); let role_name = args .agent_type diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 3e2268d92d..c83422f1a0 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -788,7 +788,6 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu .config .multi_agent_v2 .hide_spawn_agent_metadata, - include_usage_hint: turn_context.config.multi_agent_v2.usage_hint_enabled, usage_hint_text: turn_context.config.multi_agent_v2.usage_hint_text.clone(), }), tool_namespace, @@ -832,7 +831,6 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu available_models: turn_context.available_models.clone(), agent_type_description, hide_agent_type_model_reasoning: false, - include_usage_hint: turn_context.config.multi_agent_v2.usage_hint_enabled, usage_hint_text: turn_context.config.multi_agent_v2.usage_hint_text.clone(), }), exposure, diff --git a/codex-rs/core/tests/suite/multi_agent_mode.rs b/codex-rs/core/tests/suite/multi_agent_mode.rs index ba91f2fd31..21ef31c492 100644 --- a/codex-rs/core/tests/suite/multi_agent_mode.rs +++ b/codex-rs/core/tests/suite/multi_agent_mode.rs @@ -19,6 +19,7 @@ use pretty_assertions::assert_eq; use serde_json::Value; const NO_SPAWN_TEXT: &str = "Do not spawn sub-agents unless the user explicitly asks for sub-agents, delegation, or parallel agent work."; +const NO_MODE_TEXT: &str = "Multi-agent delegation mode instructions are inactive."; const PROACTIVE_TEXT: &str = "Proactive multi-agent delegation is active."; fn developer_texts(input: &[Value]) -> Vec<&str> { @@ -66,7 +67,7 @@ async fn multi_agent_mode_is_sticky_and_emits_only_on_change() -> Result<()> { let server = start_mock_server().await; let responses = mount_sse_sequence( &server, - (1..=3) + (1..=5) .map(|index| { sse(vec![ ev_response_created(&format!("resp-{index}")), @@ -82,18 +83,24 @@ async fn multi_agent_mode_is_sticky_and_emits_only_on_change() -> Result<()> { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - config - .features - .enable(Feature::MultiAgentMode) - .expect("test config should allow feature update"); }) .build(&server) .await?; submit_turn(&test.codex, "turn one", /*mode*/ None).await?; - assert_eq!(test.codex.config_snapshot().await.multi_agent_mode, None); + assert_eq!( + test.codex.config_snapshot().await.multi_agent_mode, + MultiAgentMode::ExplicitRequestOnly + ); submit_turn(&test.codex, "turn two", Some(MultiAgentMode::Proactive)).await?; submit_turn(&test.codex, "turn three", /*mode*/ None).await?; + submit_turn(&test.codex, "turn four", Some(MultiAgentMode::None)).await?; + submit_turn(&test.codex, "turn five", /*mode*/ None).await?; + + assert_eq!( + test.codex.config_snapshot().await.multi_agent_mode, + MultiAgentMode::None + ); let requests = responses.requests(); let inputs = requests @@ -103,6 +110,8 @@ async fn multi_agent_mode_is_sticky_and_emits_only_on_change() -> Result<()> { let first = developer_texts(&inputs[0]); let second = developer_texts(&inputs[1]); let third = developer_texts(&inputs[2]); + let fourth = developer_texts(&inputs[3]); + let fifth = developer_texts(&inputs[4]); assert_eq!( ( @@ -128,12 +137,102 @@ async fn multi_agent_mode_is_sticky_and_emits_only_on_change() -> Result<()> { ), (2, 1, 1) ); + assert_eq!( + ( + count_containing(&fourth, MULTI_AGENT_MODE_OPEN_TAG), + count_containing(&fourth, NO_SPAWN_TEXT), + count_containing(&fourth, PROACTIVE_TEXT), + count_containing(&fourth, NO_MODE_TEXT), + ), + (3, 1, 1, 1) + ); + assert_eq!( + ( + count_containing(&fifth, MULTI_AGENT_MODE_OPEN_TAG), + count_containing(&fifth, NO_SPAWN_TEXT), + count_containing(&fifth, PROACTIVE_TEXT), + count_containing(&fifth, NO_MODE_TEXT), + ), + (3, 1, 1, 1) + ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn multi_agent_mode_feature_uses_explicit_mode_when_disabled() -> Result<()> { +async fn multi_agent_mode_none_omits_instructions_and_survives_resume() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let responses = mount_sse_sequence( + &server, + (1..=2) + .map(|index| { + sse(vec![ + ev_response_created(&format!("resp-{index}")), + ev_completed(&format!("resp-{index}")), + ]) + }) + .collect(), + ) + .await; + let initial = test_codex() + .with_config(|config| { + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + }) + .build(&server) + .await?; + let home = initial.home.clone(); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); + + submit_turn(&initial.codex, "before resume", Some(MultiAgentMode::None)).await?; + assert_eq!( + initial.codex.config_snapshot().await.multi_agent_mode, + MultiAgentMode::None + ); + drop(initial); + + let mut resume_builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + }); + let resumed = resume_builder.resume(&server, home, rollout_path).await?; + submit_turn(&resumed.codex, "after resume", /*mode*/ None).await?; + + assert_eq!( + resumed.codex.config_snapshot().await.multi_agent_mode, + MultiAgentMode::None + ); + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + for request in requests { + let input = request.input(); + let texts = developer_texts(&input); + assert_eq!( + ( + count_containing(&texts, MULTI_AGENT_MODE_OPEN_TAG), + count_containing(&texts, NO_SPAWN_TEXT), + count_containing(&texts, PROACTIVE_TEXT), + count_containing(&texts, NO_MODE_TEXT), + ), + (0, 0, 0, 0) + ); + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn multi_agent_mode_applies_without_usage_hint_text() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -148,21 +247,21 @@ async fn multi_agent_mode_feature_uses_explicit_mode_when_disabled() -> Result<( .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); + config.multi_agent_v2.root_agent_usage_hint_text = None; }) .build(&server) .await?; - submit_turn(&test.codex, "hello", /*mode*/ None).await?; + submit_turn(&test.codex, "hello", Some(MultiAgentMode::Proactive)).await?; let input = responses.single_request().input(); let texts = developer_texts(&input); assert_eq!( ( count_containing(&texts, MULTI_AGENT_MODE_OPEN_TAG), - count_containing(&texts, NO_SPAWN_TEXT), count_containing(&texts, PROACTIVE_TEXT), ), - (1, 1, 0) + (1, 1) ); Ok(()) @@ -175,7 +274,7 @@ async fn resume_compares_against_previous_effective_multi_agent_mode() -> Result let server = start_mock_server().await; let responses = mount_sse_sequence( &server, - (1..=4) + (1..=2) .map(|index| { sse(vec![ ev_response_created(&format!("resp-{index}")), @@ -214,18 +313,14 @@ async fn resume_compares_against_previous_effective_multi_agent_mode() -> Result .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - config - .features - .enable(Feature::MultiAgentMode) - .expect("test config should allow feature update"); }); let resumed = resume_builder.resume(&server, home, rollout_path).await?; - submit_turn( - &resumed.codex, - "after resume", - Some(MultiAgentMode::Proactive), - ) - .await?; + submit_turn(&resumed.codex, "after resume", /*mode*/ None).await?; + + assert_eq!( + resumed.codex.config_snapshot().await.multi_agent_mode, + MultiAgentMode::Proactive + ); let requests = responses.requests(); let resumed_input = requests[1].input(); @@ -236,108 +331,14 @@ async fn resume_compares_against_previous_effective_multi_agent_mode() -> Result count_containing(&texts, NO_SPAWN_TEXT), count_containing(&texts, PROACTIVE_TEXT), ), - (2, 1, 1) - ); - - let resumed_rollout_path = resumed - .session_configured - .rollout_path - .clone() - .expect("resumed rollout path"); - let resumed_home = resumed.home.clone(); - drop(resumed); - let mut same_mode_resume_builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::MultiAgentV2) - .expect("test config should allow feature update"); - config - .features - .enable(Feature::MultiAgentMode) - .expect("test config should allow feature update"); - }); - let resumed_same_mode = same_mode_resume_builder - .resume(&server, resumed_home, resumed_rollout_path) - .await?; - submit_turn( - &resumed_same_mode.codex, - "after same-mode resume", - /*mode*/ None, - ) - .await?; - - assert_eq!( - resumed_same_mode - .codex - .config_snapshot() - .await - .multi_agent_mode, - Some(MultiAgentMode::Proactive) - ); - let requests = responses.requests(); - let resumed_same_mode_input = requests[2].input(); - let texts = developer_texts(&resumed_same_mode_input); - assert_eq!( - ( - count_containing(&texts, MULTI_AGENT_MODE_OPEN_TAG), - count_containing(&texts, NO_SPAWN_TEXT), - count_containing(&texts, PROACTIVE_TEXT), - ), - (2, 1, 1) - ); - - let resumed_same_mode_rollout_path = resumed_same_mode - .session_configured - .rollout_path - .clone() - .expect("same-mode resumed rollout path"); - let resumed_same_mode_home = resumed_same_mode.home.clone(); - drop(resumed_same_mode); - let mut disabled_mode_resume_builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::MultiAgentV2) - .expect("test config should allow feature update"); - }); - let resumed_disabled_mode = disabled_mode_resume_builder - .resume( - &server, - resumed_same_mode_home, - resumed_same_mode_rollout_path, - ) - .await?; - submit_turn( - &resumed_disabled_mode.codex, - "after disabled-mode resume", - /*mode*/ None, - ) - .await?; - - assert_eq!( - resumed_disabled_mode - .codex - .config_snapshot() - .await - .multi_agent_mode, - Some(MultiAgentMode::Proactive) - ); - let requests = responses.requests(); - let resumed_disabled_mode_input = requests[3].input(); - let texts = developer_texts(&resumed_disabled_mode_input); - assert_eq!( - ( - count_containing(&texts, MULTI_AGENT_MODE_OPEN_TAG), - count_containing(&texts, NO_SPAWN_TEXT), - count_containing(&texts, PROACTIVE_TEXT), - ), - (3, 2, 1) + (1, 0, 1) ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn explicit_multi_agent_mode_is_retained_without_multi_agent_v2() -> Result<()> { +async fn multi_agent_mode_is_retained_without_multi_agent_v2() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -346,21 +347,13 @@ async fn explicit_multi_agent_mode_is_retained_without_multi_agent_v2() -> Resul sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), ) .await; - let test = test_codex() - .with_config(|config| { - config - .features - .enable(Feature::MultiAgentMode) - .expect("test config should allow feature update"); - }) - .build(&server) - .await?; + let test = test_codex().build(&server).await?; submit_turn(&test.codex, "hello", Some(MultiAgentMode::Proactive)).await?; assert_eq!( test.codex.config_snapshot().await.multi_agent_mode, - Some(MultiAgentMode::Proactive) + MultiAgentMode::Proactive ); let input = responses.single_request().input(); let texts = developer_texts(&input); diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index 15a37fe36a..a76aaaa3ce 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -46,6 +46,7 @@ pub struct MultiAgentV2ConfigToml { #[serde(skip_serializing_if = "Option::is_none")] #[schemars(range(min = 0, max = 3600000))] pub default_wait_timeout_ms: Option, + /// Deprecated compatibility field. Its value is ignored. #[serde(skip_serializing_if = "Option::is_none")] pub usage_hint_enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 3a04621fe0..75808e49d0 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -145,7 +145,7 @@ pub enum Feature { Collab, /// Enable task-path-based multi-agent routing. MultiAgentV2, - /// Enable per-turn multi-agent mode selection. + /// Removed compatibility flag retained as a no-op. MultiAgentMode, /// Enable CSV-backed agent job tools. SpawnCsv, @@ -1034,7 +1034,7 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::MultiAgentMode, key: "multi_agent_mode", - stage: Stage::UnderDevelopment, + stage: Stage::Removed, default_enabled: false, }, FeatureSpec { diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 7ee46f4ba2..476c80aedc 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -604,45 +604,6 @@ non_code_mode_only = true ); } -#[test] -fn multi_agent_v2_feature_config_usage_hint_enabled_does_not_enable_feature() { - let features_toml: FeaturesToml = toml::from_str( - r#" -[multi_agent_v2] -usage_hint_enabled = false -"#, - ) - .expect("features table should deserialize"); - let features = Features::from_sources( - FeatureConfigSource { - features: Some(&features_toml), - ..Default::default() - }, - FeatureConfigSource::default(), - FeatureOverrides::default(), - ); - - assert_eq!(features.enabled(Feature::MultiAgentV2), false); - assert_eq!(features_toml.entries(), BTreeMap::new()); - assert_eq!( - features_toml.multi_agent_v2, - Some(crate::FeatureToml::Config(crate::MultiAgentV2ConfigToml { - enabled: None, - max_concurrent_threads_per_session: None, - min_wait_timeout_ms: None, - max_wait_timeout_ms: None, - default_wait_timeout_ms: None, - usage_hint_enabled: Some(false), - usage_hint_text: None, - root_agent_usage_hint_text: None, - subagent_usage_hint_text: None, - tool_namespace: None, - hide_spawn_agent_metadata: None, - non_code_mode_only: None, - })) - ); -} - #[test] fn materialize_resolved_enabled_writes_all_features_and_preserves_custom_config() { let mut features = Features::with_defaults(); diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 5c250648a9..83bcbdc0ae 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -296,8 +296,10 @@ pub enum Personality { Pragmatic, } -/// Controls whether the model should only spawn sub-agents after an explicit -/// user request or may delegate proactively when doing so would help. +/// Controls whether the model receives multi-agent delegation instructions and, +/// when it does, whether it should only spawn sub-agents after an explicit user +/// request or may delegate proactively when doing so would help. `none` leaves +/// the multi-agent tools available without injecting delegation instructions. #[derive( Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS, Default, )] @@ -305,6 +307,7 @@ pub enum Personality { #[ts(rename_all = "camelCase")] #[strum(serialize_all = "camelCase")] pub enum MultiAgentMode { + None, #[default] ExplicitRequestOnly, Proactive, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5900ffe3cf..ea1df417a4 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2005,7 +2005,7 @@ pub struct ThreadSettingsSnapshot { pub personality: Option, pub collaboration_mode: CollaborationMode, #[serde(default)] - pub multi_agent_mode: Option, + pub multi_agent_mode: MultiAgentMode, } #[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq, JsonSchema, TS)] From 9c3b10e5d4fcd1881c191b26467a6b0aa5b50682 Mon Sep 17 00:00:00 2001 From: jif Date: Mon, 22 Jun 2026 11:45:37 +0100 Subject: [PATCH 102/112] Apply sandbox intent inside remote exec servers (#29113) ## Why PR #29108 lets the orchestrator send sandbox intent with `process/start` without wrapping the command for its own operating system. This PR completes that boundary by making the executor interpret and enforce the intent using its own filesystem paths and sandbox implementation. For example, a macOS TUI targeting a Linux devbox sends `/bin/bash -lc pwd`. The Linux executor turns that into its own `codex-linux-sandbox ... /bin/bash -lc pwd` launch. ## What changes - Keep `process/start` unchanged when no sandbox intent is present. - Convert sandbox `PathUri` values into native paths on the executor. - Bind symbolic `:workspace_roots` permissions to the executor's native sandbox cwd. - Select the sandbox implementation on the executor and wrap the original command immediately before spawning it. - Reject sandbox-required execution before spawning when the executor cannot enforce the intent. - Pass exec-server runtime paths into process creation so Linux can locate `codex-linux-sandbox`. The boundary is therefore: ```text orchestrator executor original argv + sandbox intent -> select and enforce local sandbox ``` This PR intentionally treats a denied remote command as an ordinary command failure. Draft follow-up #29424 carries a semantic `sandboxDenied` result back to unified exec for the existing approval and retry flow. ## Platform scope Linux and macOS use their existing direct-spawn sandbox transforms. Windows sandboxed remote process launch is intentionally unsupported in this PR. The current Windows direct-spawn wrapper does not correctly preserve arbitrary argv, TTY behavior, or pass the full child environment out of band. The executor rejects the request instead of running it incorrectly or unsandboxed. ## Known follow-ups - The transported permission profile can still contain orchestrator-materialized helper or explicit paths. A `TODO(jif)` marks where the executor boundary should receive pre-host-materialization permission intent. - The sandbox wrapper currently replaces a requested custom inner `arg0`. A `TODO(jif)` marks where this must be preserved or rejected explicitly. - Draft PR #29424 contains the deferred sandbox-denial classification and approval/retry behavior. ## Rollout assumption This executor-sandbox stack is unreleased and its client and executor are expected to move together. This PR does not add mixed-version negotiation with older exec servers. --- codex-rs/core/src/tools/sandboxing.rs | 6 +- codex-rs/core/src/tools/sandboxing_tests.rs | 2 +- codex-rs/exec-server/src/environment.rs | 48 ++++++- codex-rs/exec-server/src/lib.rs | 1 + codex-rs/exec-server/src/local_process.rs | 63 ++++++--- codex-rs/exec-server/src/process_sandbox.rs | 132 ++++++++++++++++++ .../exec-server/src/process_sandbox_tests.rs | 109 +++++++++++++++ codex-rs/exec-server/src/server/handler.rs | 10 +- .../exec-server/src/server/process_handler.rs | 8 +- .../src/server/session_registry.rs | 4 +- 10 files changed, 348 insertions(+), 35 deletions(-) create mode 100644 codex-rs/exec-server/src/process_sandbox.rs create mode 100644 codex-rs/exec-server/src/process_sandbox_tests.rs diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index f9d21dccfa..77f68d96b5 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -496,11 +496,7 @@ impl<'a> SandboxAttempt<'a> { exec_request.exec_server_sandbox = Some(FileSystemSandboxContext { permissions: exec_server_permissions.into(), cwd: Some(exec_request.windows_sandbox_policy_cwd.clone()), - workspace_roots: self - .workspace_roots - .iter() - .map(PathUri::from_abs_path) - .collect(), + workspace_roots: Vec::new(), windows_sandbox_level: self.windows_sandbox_level, windows_sandbox_private_desktop: self.windows_sandbox_private_desktop, use_legacy_landlock: self.use_legacy_landlock, diff --git a/codex-rs/core/src/tools/sandboxing_tests.rs b/codex-rs/core/src/tools/sandboxing_tests.rs index 47b0b4e891..c647e1c401 100644 --- a/codex-rs/core/src/tools/sandboxing_tests.rs +++ b/codex-rs/core/src/tools/sandboxing_tests.rs @@ -258,7 +258,7 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() { Some(codex_exec_server::FileSystemSandboxContext { permissions: exec_server_permissions.into(), cwd: Some(cwd_uri), - workspace_roots: vec![PathUri::from_abs_path(&cwd)], + workspace_roots: Vec::new(), windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, use_legacy_landlock: false, diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index d3e7e5e981..5990d8811d 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -488,7 +488,9 @@ impl Environment { exec_server_url: None, remote_client: None, startup_task: Arc::new(Mutex::new(None)), - exec_backend: Arc::new(LocalProcess::default()), + exec_backend: Arc::new(LocalProcess::with_local_runtime_paths( + local_runtime_paths.clone(), + )), filesystem: Arc::new(LocalFileSystem::with_runtime_paths( local_runtime_paths.clone(), )), @@ -1165,6 +1167,50 @@ mod tests { assert_eq!(response.process.process_id().as_str(), "default-env-proc"); } + #[tokio::test] + async fn local_environment_passes_runtime_paths_to_exec_backend() { + let environment = Environment::local(test_runtime_paths()); + #[cfg(unix)] + let uri = "file://server/share/checkout"; + #[cfg(windows)] + let uri = "file:///usr/local/checkout"; + let sandbox_cwd = PathUri::parse(uri).expect("non-native sandbox cwd URI"); + let source = sandbox_cwd + .to_abs_path() + .expect_err("sandbox cwd should not be native to this host"); + let sandbox = crate::FileSystemSandboxContext::from_permission_profile_with_cwd( + codex_protocol::models::PermissionProfile::workspace_write(), + sandbox_cwd.clone(), + ); + + let result = environment + .get_exec_backend() + .start(crate::ExecParams { + process_id: ProcessId::from("local-sandbox-proc"), + argv: vec!["true".to_string()], + cwd: PathUri::from_path(std::env::current_dir().expect("read current dir")) + .expect("cwd URI"), + env_policy: None, + env: Default::default(), + tty: false, + pipe_stdin: false, + arg0: None, + sandbox: Some(sandbox), + enforce_managed_network: false, + }) + .await; + let Err(err) = result else { + panic!("sandbox cwd should be rejected after resolving runtime paths"); + }; + + assert_eq!( + err.to_string(), + format!( + "exec-server rejected request (-32602): sandbox cwd URI `{sandbox_cwd}` is not valid on this exec-server host: {source}" + ) + ); + } + #[tokio::test] async fn test_environment_rejects_sandboxed_filesystem_without_runtime_paths() { let environment = Environment::default_for_tests(); diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index c96c443447..d5c71674ac 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -16,6 +16,7 @@ mod noise_channel; mod noise_relay; mod process; mod process_id; +mod process_sandbox; mod protocol; mod regular_file; mod relay; diff --git a/codex-rs/exec-server/src/local_process.rs b/codex-rs/exec-server/src/local_process.rs index 1a6f2cf00f..802c8e69cf 100644 --- a/codex-rs/exec-server/src/local_process.rs +++ b/codex-rs/exec-server/src/local_process.rs @@ -26,9 +26,11 @@ use crate::ExecProcessEvent; use crate::ExecProcessEventReceiver; use crate::ExecProcessFuture; use crate::ExecServerError; +use crate::ExecServerRuntimePaths; use crate::ProcessId; use crate::StartedExecProcess; use crate::process::ExecProcessEventLog; +use crate::process_sandbox::prepare_exec_request; use crate::protocol::EXEC_CLOSED_METHOD; use crate::protocol::ExecClosedNotification; use crate::protocol::ExecEnvPolicy; @@ -133,6 +135,7 @@ struct Inner { #[derive(Clone)] pub(crate) struct LocalProcess { inner: Arc, + runtime_paths: Option, } struct LocalExecProcess { @@ -144,20 +147,39 @@ struct LocalExecProcess { impl Default for LocalProcess { fn default() -> Self { - let (outgoing_tx, mut outgoing_rx) = - mpsc::channel::(NOTIFICATION_CHANNEL_CAPACITY); - tokio::spawn(async move { while outgoing_rx.recv().await.is_some() {} }); - Self::new(RpcNotificationSender::new(outgoing_tx)) + Self::with_discarded_notifications(/*runtime_paths*/ None) } } impl LocalProcess { - pub(crate) fn new(notifications: RpcNotificationSender) -> Self { + pub(crate) fn with_local_runtime_paths(runtime_paths: ExecServerRuntimePaths) -> Self { + Self::with_discarded_notifications(Some(runtime_paths)) + } + + fn with_discarded_notifications(runtime_paths: Option) -> Self { + let (outgoing_tx, mut outgoing_rx) = + mpsc::channel::(NOTIFICATION_CHANNEL_CAPACITY); + tokio::spawn(async move { while outgoing_rx.recv().await.is_some() {} }); + Self::with_runtime_paths(RpcNotificationSender::new(outgoing_tx), runtime_paths) + } + + pub(crate) fn new( + notifications: RpcNotificationSender, + runtime_paths: ExecServerRuntimePaths, + ) -> Self { + Self::with_runtime_paths(notifications, Some(runtime_paths)) + } + + fn with_runtime_paths( + notifications: RpcNotificationSender, + runtime_paths: Option, + ) -> Self { Self { inner: Arc::new(Inner { notifications: std::sync::RwLock::new(Some(notifications)), processes: Mutex::new(HashMap::new()), }), + runtime_paths, } } @@ -191,16 +213,12 @@ impl LocalProcess { params: ExecParams, ) -> Result<(ExecResponse, watch::Sender, ExecProcessEventLog), JSONRPCErrorError> { let process_id = params.process_id.clone(); - let (program, args) = params - .argv + let prepared = + prepare_exec_request(¶ms, child_env(¶ms), self.runtime_paths.as_ref())?; + let (program, args) = prepared + .command .split_first() .ok_or_else(|| invalid_params("argv must not be empty".to_string()))?; - let native_cwd = params.cwd.to_abs_path().map_err(|err| { - invalid_params(format!( - "cwd URI `{}` is not valid on this exec-server host: {err}", - params.cwd - )) - })?; let start = Arc::new(ProcessStart); { @@ -216,14 +234,13 @@ impl LocalProcess { ); } - let env = child_env(¶ms); let spawned_result = if params.tty { codex_utils_pty::spawn_pty_process( program, args, - native_cwd.as_path(), - &env, - ¶ms.arg0, + prepared.cwd.as_path(), + &prepared.env, + &prepared.arg0, TerminalSize::default(), ) .await @@ -231,18 +248,18 @@ impl LocalProcess { codex_utils_pty::spawn_pipe_process( program, args, - native_cwd.as_path(), - &env, - ¶ms.arg0, + prepared.cwd.as_path(), + &prepared.env, + &prepared.arg0, ) .await } else { codex_utils_pty::spawn_pipe_process_no_stdin( program, args, - native_cwd.as_path(), - &env, - ¶ms.arg0, + prepared.cwd.as_path(), + &prepared.env, + &prepared.arg0, ) .await }; diff --git a/codex-rs/exec-server/src/process_sandbox.rs b/codex-rs/exec-server/src/process_sandbox.rs new file mode 100644 index 0000000000..be95b92d34 --- /dev/null +++ b/codex-rs/exec-server/src/process_sandbox.rs @@ -0,0 +1,132 @@ +use std::collections::HashMap; + +use codex_app_server_protocol::JSONRPCErrorError; +use codex_protocol::models::PermissionProfile; +use codex_sandboxing::SandboxCommand; +use codex_sandboxing::SandboxDirectSpawnTransformRequest; +use codex_sandboxing::SandboxManager; +use codex_sandboxing::SandboxTransformRequest; +use codex_sandboxing::SandboxType; +use codex_sandboxing::SandboxablePreference; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; + +use crate::ExecServerRuntimePaths; +use crate::protocol::ExecParams; +use crate::rpc::invalid_params; + +pub(crate) struct PreparedExecRequest { + pub(crate) command: Vec, + pub(crate) cwd: AbsolutePathBuf, + pub(crate) env: HashMap, + pub(crate) arg0: Option, +} + +pub(crate) fn prepare_exec_request( + params: &ExecParams, + env: HashMap, + runtime_paths: Option<&ExecServerRuntimePaths>, +) -> Result { + let Some(sandbox_context) = params.sandbox.as_ref() else { + return Ok(PreparedExecRequest { + command: params.argv.clone(), + cwd: native_path(¶ms.cwd, "cwd")?, + env, + arg0: params.arg0.clone(), + }); + }; + let runtime_paths = runtime_paths + .ok_or_else(|| invalid_params("sandbox runtime paths are not configured".to_string()))?; + // TODO(jif): Transport permissions before orchestrator-local paths are materialized, + // then resolve executor-local helper and workspace paths here. + let permissions: PermissionProfile = sandbox_context + .permissions + .clone() + .try_into() + .map_err(|err| invalid_params(format!("invalid sandbox permission path URI: {err}")))?; + let sandbox_policy_cwd = sandbox_context.cwd.as_ref().unwrap_or(¶ms.cwd); + let native_sandbox_policy_cwd = native_path(sandbox_policy_cwd, "sandbox cwd")?; + let native_workspace_roots = sandbox_context + .workspace_roots + .iter() + .map(|root| native_path(root, "sandbox workspace root")) + .collect::, _>>()?; + let workspace_roots = if native_workspace_roots.is_empty() { + std::slice::from_ref(&native_sandbox_policy_cwd) + } else { + native_workspace_roots.as_slice() + }; + let permissions = permissions.materialize_project_roots_with_workspace_roots(workspace_roots); + let (file_system_policy, network_policy) = permissions.to_runtime_permissions(); + let sandbox_manager = SandboxManager::new(); + let sandbox = sandbox_manager.select_initial( + &file_system_policy, + network_policy, + SandboxablePreference::Require, + sandbox_context.windows_sandbox_level, + params.enforce_managed_network, + ); + match sandbox { + SandboxType::None => { + return Err(invalid_params( + "sandbox intent cannot be enforced on this executor".to_string(), + )); + } + SandboxType::WindowsRestrictedToken => { + // TODO(jif): Launch generic remote commands through the Windows sandbox session API + // while preserving argv and TTY behavior and passing the child environment out of band. + return Err(invalid_params( + "sandboxed remote process launch is not supported on Windows".to_string(), + )); + } + SandboxType::MacosSeatbelt | SandboxType::LinuxSeccomp => {} + } + let (program, args) = params + .argv + .split_first() + .ok_or_else(|| invalid_params("argv must not be empty".to_string()))?; + let request = sandbox_manager + .transform_for_direct_spawn(SandboxDirectSpawnTransformRequest { + workspace_roots, + transform: SandboxTransformRequest { + // TODO(jif): Preserve params.arg0 for the inner command across the sandbox + // wrapper, or reject sandboxed requests with a custom arg0. + command: SandboxCommand { + program: program.into(), + args: args.to_vec(), + cwd: params.cwd.clone(), + env, + additional_permissions: None, + }, + permissions: &permissions, + sandbox, + enforce_managed_network: params.enforce_managed_network, + environment_id: None, + network: None, + sandbox_policy_cwd, + codex_linux_sandbox_exe: runtime_paths.codex_linux_sandbox_exe.as_deref(), + use_legacy_landlock: sandbox_context.use_legacy_landlock, + windows_sandbox_level: sandbox_context.windows_sandbox_level, + windows_sandbox_private_desktop: sandbox_context.windows_sandbox_private_desktop, + }, + }) + .map_err(|err| invalid_params(format!("failed to prepare process sandbox: {err}")))?; + Ok(PreparedExecRequest { + command: request.command, + cwd: native_path(&request.cwd, "cwd")?, + env: request.env, + arg0: request.arg0, + }) +} + +fn native_path(path: &PathUri, label: &str) -> Result { + path.to_abs_path().map_err(|err| { + invalid_params(format!( + "{label} URI `{path}` is not valid on this exec-server host: {err}" + )) + }) +} + +#[cfg(test)] +#[path = "process_sandbox_tests.rs"] +mod tests; diff --git a/codex-rs/exec-server/src/process_sandbox_tests.rs b/codex-rs/exec-server/src/process_sandbox_tests.rs new file mode 100644 index 0000000000..1b0408afd8 --- /dev/null +++ b/codex-rs/exec-server/src/process_sandbox_tests.rs @@ -0,0 +1,109 @@ +use std::collections::HashMap; + +#[cfg(unix)] +use codex_protocol::models::PermissionProfile; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; +use pretty_assertions::assert_eq; + +use super::prepare_exec_request; +use crate::ExecParams; +#[cfg(unix)] +use crate::ExecServerRuntimePaths; +#[cfg(unix)] +use crate::FileSystemSandboxContext; +use crate::ProcessId; + +#[cfg(unix)] +#[test] +fn sandbox_request_wraps_native_argv_on_executor() { + let cwd: AbsolutePathBuf = std::env::current_dir() + .expect("current directory") + .try_into() + .expect("absolute cwd"); + let cwd_uri = PathUri::from_abs_path(&cwd); + let self_exe = std::env::current_exe().expect("current executable"); + let runtime_paths = + ExecServerRuntimePaths::new(self_exe.clone(), Some(self_exe)).expect("runtime paths"); + let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + PermissionProfile::workspace_write(), + cwd_uri.clone(), + ); + let params = ExecParams { + process_id: ProcessId::from("process-1"), + argv: vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "pwd".to_string(), + ], + cwd: cwd_uri, + env_policy: None, + env: HashMap::new(), + tty: false, + pipe_stdin: false, + arg0: None, + sandbox: Some(sandbox), + enforce_managed_network: false, + }; + + let prepared = prepare_exec_request(¶ms, HashMap::new(), Some(&runtime_paths)) + .expect("prepare sandboxed request"); + + assert_ne!(prepared.command, params.argv); + assert_eq!(prepared.cwd, cwd); + #[cfg(target_os = "linux")] + { + assert_eq!( + prepared.command.first(), + Some(&runtime_paths.codex_self_exe.to_string_lossy().into_owned()) + ); + let permission_profile_json = prepared + .command + .iter() + .position(|arg| arg == "--permission-profile") + .and_then(|index| prepared.command.get(index + 1)) + .expect("sandbox wrapper permission profile"); + let permission_profile: PermissionProfile = + serde_json::from_str(permission_profile_json).expect("permission profile JSON"); + assert_eq!( + permission_profile, + PermissionProfile::workspace_write() + .materialize_project_roots_with_workspace_roots(std::slice::from_ref(&cwd)) + ); + } + #[cfg(target_os = "macos")] + assert_eq!( + prepared.command.first().map(String::as_str), + Some("/usr/bin/sandbox-exec") + ); +} + +#[test] +fn native_request_preserves_native_launch_fields() { + let cwd: AbsolutePathBuf = std::env::current_dir() + .expect("current directory") + .try_into() + .expect("absolute cwd"); + let cwd_uri = PathUri::from_abs_path(&cwd); + let env = HashMap::from([("TEST_ENV".to_string(), "value".to_string())]); + let params = ExecParams { + process_id: ProcessId::from("process-1"), + argv: vec!["echo".to_string(), "hello".to_string()], + cwd: cwd_uri, + env_policy: None, + env: HashMap::new(), + tty: false, + pipe_stdin: false, + arg0: Some("custom-arg0".to_string()), + sandbox: None, + enforce_managed_network: false, + }; + + let prepared = prepare_exec_request(¶ms, env.clone(), /*runtime_paths*/ None) + .expect("prepare native request"); + + assert_eq!(prepared.command, params.argv); + assert_eq!(prepared.cwd, cwd); + assert_eq!(prepared.env, env); + assert_eq!(prepared.arg0, params.arg0); +} diff --git a/codex-rs/exec-server/src/server/handler.rs b/codex-rs/exec-server/src/server/handler.rs index e0ede94c77..07575a1c42 100644 --- a/codex-rs/exec-server/src/server/handler.rs +++ b/codex-rs/exec-server/src/server/handler.rs @@ -66,6 +66,7 @@ pub(crate) struct ExecServerHandler { background_task_shutdown: CancellationToken, background_tasks: TaskTracker, file_system: FileSystemHandler, + runtime_paths: ExecServerRuntimePaths, initialize_requested: AtomicBool, initialized: AtomicBool, } @@ -83,7 +84,8 @@ impl ExecServerHandler { active_body_stream_ids: Mutex::new(HashSet::new()), background_task_shutdown: CancellationToken::new(), background_tasks: TaskTracker::new(), - file_system: FileSystemHandler::new(runtime_paths), + file_system: FileSystemHandler::new(runtime_paths.clone()), + runtime_paths, initialize_requested: AtomicBool::new(false), initialized: AtomicBool::new(false), } @@ -116,7 +118,11 @@ impl ExecServerHandler { let session = match self .session_registry - .attach(params.resume_session_id.clone(), self.notifications.clone()) + .attach( + params.resume_session_id.clone(), + self.notifications.clone(), + self.runtime_paths.clone(), + ) .await { Ok(session) => session, diff --git a/codex-rs/exec-server/src/server/process_handler.rs b/codex-rs/exec-server/src/server/process_handler.rs index 9fced9c166..f628b242b0 100644 --- a/codex-rs/exec-server/src/server/process_handler.rs +++ b/codex-rs/exec-server/src/server/process_handler.rs @@ -1,5 +1,6 @@ use codex_app_server_protocol::JSONRPCErrorError; +use crate::ExecServerRuntimePaths; use crate::local_process::LocalProcess; use crate::protocol::ExecParams; use crate::protocol::ExecResponse; @@ -19,9 +20,12 @@ pub(crate) struct ProcessHandler { } impl ProcessHandler { - pub(crate) fn new(notifications: RpcNotificationSender) -> Self { + pub(crate) fn new( + notifications: RpcNotificationSender, + runtime_paths: ExecServerRuntimePaths, + ) -> Self { Self { - process: LocalProcess::new(notifications), + process: LocalProcess::new(notifications, runtime_paths), } } diff --git a/codex-rs/exec-server/src/server/session_registry.rs b/codex-rs/exec-server/src/server/session_registry.rs index 59da3b50a7..73b14ff7cd 100644 --- a/codex-rs/exec-server/src/server/session_registry.rs +++ b/codex-rs/exec-server/src/server/session_registry.rs @@ -7,6 +7,7 @@ use codex_app_server_protocol::JSONRPCErrorError; use tokio::sync::Mutex; use uuid::Uuid; +use crate::ExecServerRuntimePaths; use crate::rpc::RpcNotificationSender; use crate::rpc::invalid_request; use crate::rpc::session_already_attached; @@ -60,6 +61,7 @@ impl SessionRegistry { self: &Arc, resume_session_id: Option, notifications: RpcNotificationSender, + runtime_paths: ExecServerRuntimePaths, ) -> Result { enum AttachOutcome { Attached(Arc), @@ -95,7 +97,7 @@ impl SessionRegistry { let session_id = Uuid::new_v4().to_string(); let entry = Arc::new(SessionEntry::new( session_id.clone(), - ProcessHandler::new(notifications), + ProcessHandler::new(notifications, runtime_paths), connection_id, )); sessions.insert(session_id, Arc::clone(&entry)); From 21d36296f137c0954df24ea86abe9619318915e6 Mon Sep 17 00:00:00 2001 From: xli-oai Date: Mon, 22 Jun 2026 04:25:07 -0700 Subject: [PATCH 103/112] Add workspace messages app-server API (#29001) ## Summary - Add backend-client types and fetch support for active workspace messages. - Add the app-server v2 `account/workspaceMessages/read` method, generated schemas, and README documentation. - Delegate workspace-message eligibility to the Codex backend feature gate; map a backend 404 to `featureEnabled: false`. ## Testing - `just write-app-server-schema` - `just test -p codex-backend-client` - `just test -p codex-app-server-protocol` - `just test -p codex-app-server workspace_messages` - `just fix -p codex-backend-client -p codex-app-server-protocol -p codex-app-server` - `just fmt` ## Stack - Base PR for #28232, which adds the TUI status-line integration. --- .../schema/json/ClientRequest.json | 23 +++ .../codex_app_server_protocol.schemas.json | 88 ++++++++ .../codex_app_server_protocol.v2.schemas.json | 88 ++++++++ .../json/v2/GetWorkspaceMessagesResponse.json | 67 ++++++ .../schema/typescript/ClientRequest.ts | 2 +- .../v2/GetWorkspaceMessagesResponse.ts | 14 ++ .../schema/typescript/v2/WorkspaceMessage.ts | 14 ++ .../typescript/v2/WorkspaceMessageType.ts | 5 + .../schema/typescript/v2/index.ts | 3 + .../src/protocol/common.rs | 24 +++ .../src/protocol/v2/account.rs | 35 ++++ codex-rs/app-server/README.md | 14 +- codex-rs/app-server/src/message_processor.rs | 3 + codex-rs/app-server/src/request_processors.rs | 7 + .../request_processors/account_processor.rs | 160 ++++++++++++++ codex-rs/backend-client/src/client.rs | 38 ++++ codex-rs/backend-client/src/lib.rs | 3 + codex-rs/backend-client/src/types.rs | 83 ++++++++ codex-rs/tui/src/app/background_requests.rs | 38 ++++ codex-rs/tui/src/app/event_dispatch.rs | 11 + codex-rs/tui/src/app_event.rs | 10 + .../tui/src/bottom_pane/status_line_setup.rs | 7 + .../tui/src/bottom_pane/status_line_style.rs | 2 +- .../src/bottom_pane/status_surface_preview.rs | 3 + codex-rs/tui/src/chatwidget.rs | 11 + codex-rs/tui/src/chatwidget/constructor.rs | 5 + codex-rs/tui/src/chatwidget/settings.rs | 5 + ...s_line_setup_popup_workspace_headline.snap | 20 ++ .../tui/src/chatwidget/status_surfaces.rs | 95 +++++++++ .../src/chatwidget/tests/status_and_layout.rs | 195 ++++++++++++++++++ .../tests/status_surface_previews.rs | 12 ++ codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/workspace_messages.rs | 29 +++ codex-rs/tui/src/workspace_messages_tests.rs | 51 +++++ 34 files changed, 1163 insertions(+), 3 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/GetWorkspaceMessagesResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/GetWorkspaceMessagesResponse.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceMessage.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceMessageType.ts create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_workspace_headline.snap create mode 100644 codex-rs/tui/src/workspace_messages.rs create mode 100644 codex-rs/tui/src/workspace_messages_tests.rs diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 18c147ba36..7ea246f0ea 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -6462,6 +6462,29 @@ "title": "Account/usage/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/workspaceMessages/read" + ], + "title": "Account/workspaceMessages/readRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Account/workspaceMessages/readRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index ec0321d9b1..9c2abf525a 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -1904,6 +1904,29 @@ "title": "Account/usage/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "account/workspaceMessages/read" + ], + "title": "Account/workspaceMessages/readRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Account/workspaceMessages/readRequest", + "type": "object" + }, { "properties": { "id": { @@ -10354,6 +10377,28 @@ "title": "GetAccountTokenUsageResponse", "type": "object" }, + "GetWorkspaceMessagesResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "featureEnabled": { + "description": "Whether the workspace-message backend route is available for this client.", + "type": "boolean" + }, + "messages": { + "description": "Active workspace messages returned by the backend.", + "items": { + "$ref": "#/definitions/v2/WorkspaceMessage" + }, + "type": "array" + } + }, + "required": [ + "featureEnabled", + "messages" + ], + "title": "GetWorkspaceMessagesResponse", + "type": "object" + }, "GitInfo": { "properties": { "branch": { @@ -20564,6 +20609,49 @@ "title": "WindowsWorldWritableWarningNotification", "type": "object" }, + "WorkspaceMessage": { + "properties": { + "archivedAt": { + "description": "Unix timestamp (in seconds) when the message was archived.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the message was created.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "messageBody": { + "type": "string" + }, + "messageId": { + "type": "string" + }, + "messageType": { + "$ref": "#/definitions/v2/WorkspaceMessageType" + } + }, + "required": [ + "messageBody", + "messageId", + "messageType" + ], + "type": "object" + }, + "WorkspaceMessageType": { + "enum": [ + "headline", + "announcement", + "unknown" + ], + "type": "string" + }, "WriteStatus": { "enum": [ "ok", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index cd0f46bd47..c8b172c7dc 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -2913,6 +2913,29 @@ "title": "Account/usage/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/workspaceMessages/read" + ], + "title": "Account/workspaceMessages/readRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Account/workspaceMessages/readRequest", + "type": "object" + }, { "properties": { "id": { @@ -6705,6 +6728,28 @@ "title": "GetAccountTokenUsageResponse", "type": "object" }, + "GetWorkspaceMessagesResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "featureEnabled": { + "description": "Whether the workspace-message backend route is available for this client.", + "type": "boolean" + }, + "messages": { + "description": "Active workspace messages returned by the backend.", + "items": { + "$ref": "#/definitions/WorkspaceMessage" + }, + "type": "array" + } + }, + "required": [ + "featureEnabled", + "messages" + ], + "title": "GetWorkspaceMessagesResponse", + "type": "object" + }, "GitInfo": { "properties": { "branch": { @@ -18343,6 +18388,49 @@ "title": "WindowsWorldWritableWarningNotification", "type": "object" }, + "WorkspaceMessage": { + "properties": { + "archivedAt": { + "description": "Unix timestamp (in seconds) when the message was archived.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the message was created.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "messageBody": { + "type": "string" + }, + "messageId": { + "type": "string" + }, + "messageType": { + "$ref": "#/definitions/WorkspaceMessageType" + } + }, + "required": [ + "messageBody", + "messageId", + "messageType" + ], + "type": "object" + }, + "WorkspaceMessageType": { + "enum": [ + "headline", + "announcement", + "unknown" + ], + "type": "string" + }, "WriteStatus": { "enum": [ "ok", diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetWorkspaceMessagesResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetWorkspaceMessagesResponse.json new file mode 100644 index 0000000000..4d1246a1b2 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/GetWorkspaceMessagesResponse.json @@ -0,0 +1,67 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "WorkspaceMessage": { + "properties": { + "archivedAt": { + "description": "Unix timestamp (in seconds) when the message was archived.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the message was created.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "messageBody": { + "type": "string" + }, + "messageId": { + "type": "string" + }, + "messageType": { + "$ref": "#/definitions/WorkspaceMessageType" + } + }, + "required": [ + "messageBody", + "messageId", + "messageType" + ], + "type": "object" + }, + "WorkspaceMessageType": { + "enum": [ + "headline", + "announcement", + "unknown" + ], + "type": "string" + } + }, + "properties": { + "featureEnabled": { + "description": "Whether the workspace-message backend route is available for this client.", + "type": "boolean" + }, + "messages": { + "description": "Active workspace messages returned by the backend.", + "items": { + "$ref": "#/definitions/WorkspaceMessage" + }, + "type": "array" + } + }, + "required": [ + "featureEnabled", + "messages" + ], + "title": "GetWorkspaceMessagesResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index e014cc98b8..6af04ef401 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -88,4 +88,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/delete", id: RequestId, params: ThreadDeleteParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/goal/set", id: RequestId, params: ThreadGoalSetParams, } | { "method": "thread/goal/get", id: RequestId, params: ThreadGoalGetParams, } | { "method": "thread/goal/clear", id: RequestId, params: ThreadGoalClearParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/extraRoots/set", id: RequestId, params: SkillsExtraRootsSetParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/rateLimitResetCredit/consume", id: RequestId, params: ConsumeAccountRateLimitResetCreditParams, } | { "method": "account/usage/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "externalAgentConfig/import/readHistories", id: RequestId, params: undefined, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/delete", id: RequestId, params: ThreadDeleteParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/goal/set", id: RequestId, params: ThreadGoalSetParams, } | { "method": "thread/goal/get", id: RequestId, params: ThreadGoalGetParams, } | { "method": "thread/goal/clear", id: RequestId, params: ThreadGoalClearParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/extraRoots/set", id: RequestId, params: SkillsExtraRootsSetParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/rateLimitResetCredit/consume", id: RequestId, params: ConsumeAccountRateLimitResetCreditParams, } | { "method": "account/usage/read", id: RequestId, params: undefined, } | { "method": "account/workspaceMessages/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "externalAgentConfig/import/readHistories", id: RequestId, params: undefined, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/GetWorkspaceMessagesResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GetWorkspaceMessagesResponse.ts new file mode 100644 index 0000000000..949ad433a1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GetWorkspaceMessagesResponse.ts @@ -0,0 +1,14 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { WorkspaceMessage } from "./WorkspaceMessage"; + +export type GetWorkspaceMessagesResponse = { +/** + * Whether the workspace-message backend route is available for this client. + */ +featureEnabled: boolean, +/** + * Active workspace messages returned by the backend. + */ +messages: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceMessage.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceMessage.ts new file mode 100644 index 0000000000..b024ce1163 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceMessage.ts @@ -0,0 +1,14 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { WorkspaceMessageType } from "./WorkspaceMessageType"; + +export type WorkspaceMessage = { messageId: string, messageType: WorkspaceMessageType, messageBody: string, +/** + * Unix timestamp (in seconds) when the message was created. + */ +createdAt: number | null, +/** + * Unix timestamp (in seconds) when the message was archived. + */ +archivedAt: number | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceMessageType.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceMessageType.ts new file mode 100644 index 0000000000..9d9438d964 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceMessageType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type WorkspaceMessageType = "headline" | "announcement" | "unknown"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 419cf8c3c6..3e769d3fab 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -157,6 +157,7 @@ export type { GetAccountParams } from "./GetAccountParams"; export type { GetAccountRateLimitsResponse } from "./GetAccountRateLimitsResponse"; export type { GetAccountResponse } from "./GetAccountResponse"; export type { GetAccountTokenUsageResponse } from "./GetAccountTokenUsageResponse"; +export type { GetWorkspaceMessagesResponse } from "./GetWorkspaceMessagesResponse"; export type { GitInfo } from "./GitInfo"; export type { GrantedPermissionProfile } from "./GrantedPermissionProfile"; export type { GuardianApprovalReview } from "./GuardianApprovalReview"; @@ -494,4 +495,6 @@ export type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode"; export type { WindowsSandboxSetupStartParams } from "./WindowsSandboxSetupStartParams"; export type { WindowsSandboxSetupStartResponse } from "./WindowsSandboxSetupStartResponse"; export type { WindowsWorldWritableWarningNotification } from "./WindowsWorldWritableWarningNotification"; +export type { WorkspaceMessage } from "./WorkspaceMessage"; +export type { WorkspaceMessageType } from "./WorkspaceMessageType"; export type { WriteStatus } from "./WriteStatus"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 0b1e11133d..9bcc6a86d0 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1022,6 +1022,12 @@ client_request_definitions! { response: v2::GetAccountTokenUsageResponse, }, + GetWorkspaceMessages => "account/workspaceMessages/read" { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + serialization: None, + response: v2::GetWorkspaceMessagesResponse, + }, + SendAddCreditsNudgeEmail => "account/sendAddCreditsNudgeEmail" { params: v2::SendAddCreditsNudgeEmailParams, serialization: global("account-auth"), @@ -2541,6 +2547,24 @@ mod tests { Ok(()) } + #[test] + fn serialize_get_workspace_messages() -> Result<()> { + let request = ClientRequest::GetWorkspaceMessages { + request_id: RequestId::Integer(1), + params: None, + }; + assert_eq!(request.id(), &RequestId::Integer(1)); + assert_eq!(request.method(), "account/workspaceMessages/read"); + assert_eq!( + json!({ + "method": "account/workspaceMessages/read", + "id": 1, + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + #[test] fn serialize_client_response() -> Result<()> { let cwd = absolute_path("/tmp"); diff --git a/codex-rs/app-server-protocol/src/protocol/v2/account.rs b/codex-rs/app-server-protocol/src/protocol/v2/account.rs index 2a71a092c9..348d6810fc 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/account.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -314,6 +314,41 @@ pub struct GetAccountTokenUsageResponse { pub daily_usage_buckets: Option>, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GetWorkspaceMessagesResponse { + /// Whether the workspace-message backend route is available for this client. + pub feature_enabled: bool, + /// Active workspace messages returned by the backend. + pub messages: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WorkspaceMessage { + pub message_id: String, + pub message_type: WorkspaceMessageType, + pub message_body: String, + /// Unix timestamp (in seconds) when the message was created. + #[ts(type = "number | null")] + pub created_at: Option, + /// Unix timestamp (in seconds) when the message was archived. + #[ts(type = "number | null")] + pub archived_at: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/", rename_all = "snake_case")] +pub enum WorkspaceMessageType { + Headline, + Announcement, + #[serde(other)] + Unknown, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index e823eb1007..e70db8bba1 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1920,6 +1920,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco - `account/rateLimits/read` — fetch ChatGPT rate limits, an optional effective monthly credit limit, and the number of earned rate-limit resets currently available. Rate-limit updates arrive via `account/rateLimits/updated` (notify); the reset count is snapshot-only. - `account/rateLimitResetCredit/consume` — consume one earned reset using a caller-provided idempotency key. - `account/usage/read` — fetch ChatGPT account token-activity summary and daily buckets. +- `account/workspaceMessages/read` — fetch active workspace messages, including workspace notification headlines when available. - `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. - `account/sendAddCreditsNudgeEmail` — ask ChatGPT to email the workspace owner about depleted credits or a reached usage limit. - `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, success, error? }`. @@ -2046,7 +2047,18 @@ Field notes: - `noCredit` means the account has no earned reset credits available. - Refetch `account/rateLimits/read` after consuming a reset instead of inferring updated windows from this response. -### 9) Notify a workspace owner about a limit +### 9) Workspace messages (ChatGPT) + +```json +{ "method": "account/workspaceMessages/read", "id": 9 } +{ "id": 9, "result": { "featureEnabled": true, "messages": [ + { "messageId": "msg_123", "messageType": "headline", "messageBody": "Workspace maintenance starts at 5pm.", "createdAt": 1781395200, "archivedAt": null } +] } } +``` + +When the upstream workspace-message feature is disabled, `featureEnabled` is `false` and `messages` is empty. + +### 10) Notify a workspace owner about a limit ```json { "method": "account/sendAddCreditsNudgeEmail", "id": 9, "params": { "creditType": "credits" } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index ad29e05010..33cf142fe8 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1427,6 +1427,9 @@ impl MessageProcessor { ClientRequest::GetAccountTokenUsage { .. } => { self.account_processor.get_account_token_usage().await } + ClientRequest::GetWorkspaceMessages { .. } => { + self.account_processor.get_workspace_messages().await + } ClientRequest::SendAddCreditsNudgeEmail { params, .. } => { self.account_processor .send_add_credits_nudge_email(params) diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 8c202a806d..c8bd48c474 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -76,6 +76,7 @@ use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetAuthStatusResponse; use codex_app_server_protocol::GetConversationSummaryParams; use codex_app_server_protocol::GetConversationSummaryResponse; +use codex_app_server_protocol::GetWorkspaceMessagesResponse; use codex_app_server_protocol::GitDiffToRemoteParams; use codex_app_server_protocol::GitDiffToRemoteResponse; use codex_app_server_protocol::GitInfo as ApiGitInfo; @@ -282,10 +283,16 @@ use codex_app_server_protocol::WindowsSandboxSetupCompletedNotification; use codex_app_server_protocol::WindowsSandboxSetupMode; use codex_app_server_protocol::WindowsSandboxSetupStartParams; use codex_app_server_protocol::WindowsSandboxSetupStartResponse; +use codex_app_server_protocol::WorkspaceMessage; +use codex_app_server_protocol::WorkspaceMessageType; use codex_arg0::Arg0DispatchPaths; use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCreditType; use codex_backend_client::Client as BackendClient; +use codex_backend_client::CodexWorkspaceMessage as BackendWorkspaceMessage; +use codex_backend_client::CodexWorkspaceMessageType as BackendWorkspaceMessageType; +use codex_backend_client::CodexWorkspaceMessagesResponse as BackendWorkspaceMessagesResponse; use codex_backend_client::ConsumeRateLimitResetCreditCode as BackendConsumeRateLimitResetCreditCode; +use codex_backend_client::RequestError as BackendRequestError; use codex_backend_client::TokenUsageProfile; use codex_chatgpt::connectors; use codex_chatgpt::workspace_settings; diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index 5d234f7002..4c8538e2b1 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -1,10 +1,13 @@ use super::*; +use chrono::DateTime; mod rate_limit_resets; // Duration before a browser ChatGPT login attempt is abandoned. const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60); const ACCOUNT_TOKEN_USAGE_FETCH_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10); +const ACCOUNT_WORKSPACE_MESSAGES_FETCH_TIMEOUT: Duration = + Duration::from_millis(/*millis*/ 1000); // The override is intentionally available only in debug builds, matching the login path below. #[cfg(debug_assertions)] const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER"; @@ -142,6 +145,14 @@ impl AccountRequestProcessor { .map(|response| Some(response.into())) } + pub(crate) async fn get_workspace_messages( + &self, + ) -> Result, JSONRPCErrorError> { + self.get_workspace_messages_response() + .await + .map(|response| Some(response.into())) + } + pub(crate) async fn send_add_credits_nudge_email( &self, params: SendAddCreditsNudgeEmailParams, @@ -943,6 +954,48 @@ impl AccountRequestProcessor { Ok(Self::account_token_usage_response(profile)) } + async fn get_workspace_messages_response( + &self, + ) -> Result { + let Some(auth) = self.auth_manager.auth().await else { + return Err(invalid_request( + "codex account authentication required to read workspace messages", + )); + }; + + if !auth.uses_codex_backend() { + return Err(invalid_request( + "chatgpt authentication required to read workspace messages", + )); + } + + let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth) + .map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?; + let messages = tokio::time::timeout( + ACCOUNT_WORKSPACE_MESSAGES_FETCH_TIMEOUT, + client.list_workspace_messages(), + ) + .await + .map_err(|_| internal_error("workspace messages fetch timed out"))?; + + match messages { + Ok(messages) => { + Self::workspace_messages_response(messages, /*feature_enabled*/ true) + } + Err(err) if workspace_messages_feature_disabled(&err) => { + Self::workspace_messages_response( + BackendWorkspaceMessagesResponse { + messages: Vec::new(), + }, + /*feature_enabled*/ false, + ) + } + Err(err) => Err(internal_error(format!( + "failed to fetch workspace messages: {err}" + ))), + } + } + fn account_token_usage_response(profile: TokenUsageProfile) -> GetAccountTokenUsageResponse { let stats = profile.stats; GetAccountTokenUsageResponse { @@ -965,6 +1018,20 @@ impl AccountRequestProcessor { } } + fn workspace_messages_response( + messages: BackendWorkspaceMessagesResponse, + feature_enabled: bool, + ) -> Result { + Ok(GetWorkspaceMessagesResponse { + feature_enabled, + messages: messages + .messages + .into_iter() + .map(workspace_message_from_backend) + .collect::, _>>()?, + }) + } + async fn send_add_credits_nudge_email_response( &self, params: SendAddCreditsNudgeEmailParams, @@ -1015,6 +1082,48 @@ impl AccountRequestProcessor { } } +fn workspace_message_from_backend( + message: BackendWorkspaceMessage, +) -> Result { + Ok(WorkspaceMessage { + message_id: message.message_id, + message_type: workspace_message_type_from_backend(message.message_type), + message_body: message.message_body, + created_at: workspace_message_timestamp_from_backend(message.created_at)?, + archived_at: workspace_message_timestamp_from_backend(message.archived_at)?, + }) +} + +fn workspace_message_timestamp_from_backend( + timestamp: Option, +) -> Result, JSONRPCErrorError> { + timestamp + .map(|timestamp| { + DateTime::parse_from_rfc3339(×tamp) + .map(|timestamp| timestamp.timestamp()) + .map_err(|err| { + internal_error(format!( + "failed to parse workspace message timestamp `{timestamp}`: {err}" + )) + }) + }) + .transpose() +} + +fn workspace_message_type_from_backend( + message_type: BackendWorkspaceMessageType, +) -> WorkspaceMessageType { + match message_type { + BackendWorkspaceMessageType::Headline => WorkspaceMessageType::Headline, + BackendWorkspaceMessageType::Announcement => WorkspaceMessageType::Announcement, + BackendWorkspaceMessageType::Unknown => WorkspaceMessageType::Unknown, + } +} + +fn workspace_messages_feature_disabled(err: &BackendRequestError) -> bool { + err.status().is_some_and(|status| status.as_u16() == 404) +} + #[cfg(test)] mod tests { use super::*; @@ -1055,4 +1164,55 @@ mod tests { } ); } + + #[test] + fn workspace_messages_response_maps_backend_messages() { + let response = AccountRequestProcessor::workspace_messages_response( + BackendWorkspaceMessagesResponse { + messages: vec![BackendWorkspaceMessage { + message_id: "headline-id".to_string(), + message_type: BackendWorkspaceMessageType::Headline, + message_body: "Headline body".to_string(), + created_at: Some("2026-06-14T00:00:00Z".to_string()), + archived_at: Some("2026-06-15T00:00:00Z".to_string()), + }], + }, + /*feature_enabled*/ true, + ) + .expect("workspace message timestamps should parse"); + + assert_eq!( + response, + GetWorkspaceMessagesResponse { + feature_enabled: true, + messages: vec![WorkspaceMessage { + message_id: "headline-id".to_string(), + message_type: WorkspaceMessageType::Headline, + message_body: "Headline body".to_string(), + created_at: Some(1_781_395_200), + archived_at: Some(1_781_481_600), + }], + } + ); + } + + #[test] + fn workspace_messages_feature_disabled_only_for_not_found() { + let cases = [ + (reqwest::StatusCode::NOT_FOUND, true), + (reqwest::StatusCode::UNAUTHORIZED, false), + (reqwest::StatusCode::FORBIDDEN, false), + ]; + + for (status, expected) in cases { + let err = BackendRequestError::UnexpectedStatus { + method: "GET".to_string(), + url: "https://example.test/api/codex/workspace-messages".to_string(), + status, + content_type: "application/json".to_string(), + body: "{}".to_string(), + }; + assert_eq!(workspace_messages_feature_disabled(&err), expected); + } + } } diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index e8e4f5fb52..be444b6247 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -1,5 +1,6 @@ use crate::types::AccountsCheckResponse; use crate::types::CodeTaskDetailsResponse; +use crate::types::CodexWorkspaceMessagesResponse; use crate::types::ConfigBundleResponse; use crate::types::PaginatedListTaskListItem; use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind; @@ -19,6 +20,7 @@ use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; use codex_protocol::protocol::SpendControlLimitSnapshot; use reqwest::StatusCode; +use reqwest::header::CACHE_CONTROL; use reqwest::header::CONTENT_TYPE; use reqwest::header::HeaderMap; use reqwest::header::HeaderName; @@ -430,6 +432,20 @@ impl Client { .map_err(RequestError::from) } + pub async fn list_workspace_messages( + &self, + ) -> std::result::Result { + let url = self.workspace_messages_url(); + let req = self + .http + .get(&url) + .headers(self.headers()) + .header(CACHE_CONTROL, HeaderValue::from_static("no-store")); + let (body, ct) = self.exec_request_detailed(req, "GET", &url).await?; + self.decode_json::(&url, &ct, &body) + .map_err(RequestError::from) + } + /// Create a new task (user turn) by POSTing to the appropriate backend path /// based on `path_style`. Returns the created task id. pub async fn create_task(&self, request_body: serde_json::Value) -> Result { @@ -570,6 +586,13 @@ impl Client { } } + fn workspace_messages_url(&self) -> String { + match self.path_style { + PathStyle::CodexApi => format!("{}/api/codex/workspace-messages", self.base_url), + PathStyle::ChatGptApi => format!("{}/wham/workspace-messages", self.base_url), + } + } + fn map_rate_limit_window( window: Option>>, ) -> Option { @@ -936,6 +959,21 @@ mod tests { ); } + #[test] + fn workspace_messages_uses_expected_paths() { + let codex_client = test_client("https://example.test", PathStyle::CodexApi); + assert_eq!( + codex_client.workspace_messages_url(), + "https://example.test/api/codex/workspace-messages" + ); + + let chatgpt_client = test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi); + assert_eq!( + chatgpt_client.workspace_messages_url(), + "https://chatgpt.com/backend-api/wham/workspace-messages" + ); + } + fn test_client(base_url: &str, path_style: PathStyle) -> Client { Client { base_url: base_url.to_string(), diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index 9731bc82b3..6cbeaf30b2 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -8,6 +8,9 @@ pub use types::AccountEntry; pub use types::AccountsCheckResponse; pub use types::CodeTaskDetailsResponse; pub use types::CodeTaskDetailsResponseExt; +pub use types::CodexWorkspaceMessage; +pub use types::CodexWorkspaceMessageType; +pub use types::CodexWorkspaceMessagesResponse; pub use types::ConfigBundleResponse; pub use types::ConsumeRateLimitResetCreditCode; pub use types::ConsumeRateLimitResetCreditResponse; diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index d469713891..18ab323986 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -36,6 +36,23 @@ pub(crate) struct RateLimitStatusWithResetCredits { pub rate_limit_reset_credits: Option, } +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct CodexWorkspaceMessagesResponse { + #[serde(default)] + pub messages: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct CodexWorkspaceMessage { + pub message_id: String, + pub message_type: CodexWorkspaceMessageType, + pub message_body: String, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub archived_at: Option, +} + #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ConsumeRateLimitResetCreditCode { @@ -52,6 +69,15 @@ pub struct ConsumeRateLimitResetCreditResponse { pub windows_reset: i64, } +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CodexWorkspaceMessageType { + Headline, + Announcement, + #[serde(other)] + Unknown, +} + #[derive(Clone, Debug)] pub struct AccountsCheckResponse { pub accounts: Vec, @@ -520,4 +546,61 @@ Second line" .expect("error should be present"); assert_eq!(msg, "APPLY_FAILED: Patch could not be applied"); } + + #[test] + fn workspace_messages_response_deserializes_messages() { + let response: CodexWorkspaceMessagesResponse = serde_json::from_value(serde_json::json!({ + "messages": [ + { + "message_id": "headline-id", + "message_type": "headline", + "message_body": "Headline body", + "created_at": "2026-06-14T00:00:00Z", + "archived_at": null + }, + { + "message_id": "announcement-id", + "message_type": "announcement", + "message_body": "Announcement body", + "created_at": "2026-06-14T01:00:00Z", + "archived_at": null + }, + { + "message_id": "unknown-id", + "message_type": "unknown", + "message_body": "Unknown body" + } + ] + })) + .expect("workspace messages response should deserialize"); + + assert_eq!( + response, + CodexWorkspaceMessagesResponse { + messages: vec![ + CodexWorkspaceMessage { + message_id: "headline-id".to_string(), + message_type: CodexWorkspaceMessageType::Headline, + message_body: "Headline body".to_string(), + created_at: Some("2026-06-14T00:00:00Z".to_string()), + archived_at: None, + }, + CodexWorkspaceMessage { + message_id: "announcement-id".to_string(), + message_type: CodexWorkspaceMessageType::Announcement, + message_body: "Announcement body".to_string(), + created_at: Some("2026-06-14T01:00:00Z".to_string()), + archived_at: None, + }, + CodexWorkspaceMessage { + message_id: "unknown-id".to_string(), + message_type: CodexWorkspaceMessageType::Unknown, + message_body: "Unknown body".to_string(), + created_at: None, + archived_at: None, + }, + ], + } + ); + } } diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index bb05e1a3f4..08e7f9b071 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -31,6 +31,8 @@ const TOKEN_ACTIVITY_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(/*secs*/ 15); const RATE_LIMIT_RESET_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(/*secs*/ 15); +const WORKSPACE_HEADLINE_FETCH_TIMEOUT: std::time::Duration = + std::time::Duration::from_millis(/*millis*/ 2000); impl App { pub(super) fn fetch_mcp_inventory( @@ -158,6 +160,29 @@ impl App { }); } + pub(super) fn refresh_status_line_workspace_headline( + &mut self, + app_server: &AppServerSession, + request_id: u64, + ) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = tokio::time::timeout( + WORKSPACE_HEADLINE_FETCH_TIMEOUT, + fetch_workspace_messages(request_handle), + ) + .await + .map_err(|_| "account/workspaceMessages/read timed out in TUI".to_string()) + .and_then(|result| { + result + .map(crate::workspace_messages::workspace_headline_from_response) + .map_err(|err| err.to_string()) + }); + app_event_tx.send(AppEvent::StatusLineWorkspaceHeadlineUpdated { request_id, result }); + }); + } + pub(super) fn send_add_credits_nudge_email( &mut self, app_server: &AppServerSession, @@ -796,6 +821,19 @@ pub(super) async fn consume_rate_limit_reset_credit_request( .wrap_err("account/rateLimitResetCredit/consume failed in TUI") } +pub(super) async fn fetch_workspace_messages( + request_handle: AppServerRequestHandle, +) -> Result { + let request_id = RequestId::String(format!("workspace-messages-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::GetWorkspaceMessages { + request_id, + params: None, + }) + .await + .wrap_err("account/workspaceMessages/read failed in TUI") +} + pub(super) async fn send_add_credits_nudge_email( request_handle: AppServerRequestHandle, credit_type: AddCreditsNudgeCreditType, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 0ab569b0eb..181e60d963 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -716,6 +716,9 @@ impl App { AppEvent::RefreshTokenActivity { request_id } => { self.refresh_token_activity(app_server, request_id); } + AppEvent::RefreshStatusLineWorkspaceHeadline { request_id } => { + self.refresh_status_line_workspace_headline(app_server, request_id); + } AppEvent::OpenThreadGoalMenu { thread_id } => { self.open_thread_goal_menu(app_server, thread_id).await; } @@ -2020,6 +2023,14 @@ impl App { self.chat_widget.set_status_line_git_summary(cwd, summary); self.refresh_status_line(); } + AppEvent::StatusLineWorkspaceHeadlineUpdated { request_id, result } => { + if self + .chat_widget + .set_status_line_workspace_headline(request_id, result) + { + tui.frame_requester().schedule_frame(); + } + } AppEvent::StatusLineSetupCancelled => { self.chat_widget.cancel_status_line_setup(); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index fc018aad87..643cd9e2d8 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -341,6 +341,11 @@ pub(crate) enum AppEvent { result: Result, }, + /// Fetch workspace messages for the status-line headline item. + RefreshStatusLineWorkspaceHeadline { + request_id: u64, + }, + /// Commit settled asynchronous usage output after active-output barriers clear. CommitPendingUsageOutput, @@ -982,6 +987,11 @@ pub(crate) enum AppEvent { cwd: PathBuf, summary: crate::chatwidget::StatusLineGitSummary, }, + /// Async update of the workspace notification headline for status line rendering. + StatusLineWorkspaceHeadlineUpdated { + request_id: u64, + result: Result, + }, /// Apply a user-confirmed status-line item ordering/selection. StatusLineSetup { items: Vec, diff --git a/codex-rs/tui/src/bottom_pane/status_line_setup.rs b/codex-rs/tui/src/bottom_pane/status_line_setup.rs index 4dd62a7d20..63ce4237db 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_setup.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_setup.rs @@ -137,6 +137,9 @@ pub(crate) enum StatusLineItem { /// Current thread title (if set by user). ThreadTitle, + /// Current workspace notification headline. + WorkspaceHeadline, + /// Latest checklist task progress from `update_plan` (if available). TaskProgress, } @@ -185,6 +188,9 @@ impl StatusLineItem { StatusLineItem::ThreadTitle => { "Current thread title, or thread identifier when unnamed" } + StatusLineItem::WorkspaceHeadline => { + "Workspace notification headline (Enterprise workspaces only; omitted when unavailable)" + } StatusLineItem::TaskProgress => { "Latest task progress from update_plan (omitted until available)" } @@ -217,6 +223,7 @@ impl StatusLineItem { StatusLineItem::FastMode => StatusSurfacePreviewItem::FastMode, StatusLineItem::RawOutput => StatusSurfacePreviewItem::RawOutput, StatusLineItem::ThreadTitle => StatusSurfacePreviewItem::ThreadTitle, + StatusLineItem::WorkspaceHeadline => StatusSurfacePreviewItem::WorkspaceHeadline, StatusLineItem::TaskProgress => StatusSurfacePreviewItem::TaskProgress, } } diff --git a/codex-rs/tui/src/bottom_pane/status_line_style.rs b/codex-rs/tui/src/bottom_pane/status_line_style.rs index 170c4641d2..4198943a4f 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_style.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_style.rs @@ -49,7 +49,7 @@ impl StatusLineAccent { StatusLineItem::FastMode | StatusLineItem::RawOutput => Self::Mode, StatusLineItem::Permissions => Self::Mode, StatusLineItem::ApprovalMode => Self::Mode, - StatusLineItem::ThreadTitle => Self::Thread, + StatusLineItem::ThreadTitle | StatusLineItem::WorkspaceHeadline => Self::Thread, StatusLineItem::TaskProgress => Self::Progress, } } diff --git a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs index bd0a94a4d4..b8b7a6fbd4 100644 --- a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs +++ b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs @@ -30,6 +30,7 @@ pub(crate) enum StatusSurfacePreviewItem { SessionId, FastMode, RawOutput, + WorkspaceHeadline, Model, ModelWithReasoning, Reasoning, @@ -62,6 +63,7 @@ impl StatusSurfacePreviewItem { StatusSurfacePreviewItem::SessionId => "550e8400-e29b-41d4", StatusSurfacePreviewItem::FastMode => "Fast on", StatusSurfacePreviewItem::RawOutput => "raw output", + StatusSurfacePreviewItem::WorkspaceHeadline => "Workspace headline", StatusSurfacePreviewItem::Model => "gpt-5.2-codex", StatusSurfacePreviewItem::ModelWithReasoning => "gpt-5.2-codex medium", StatusSurfacePreviewItem::Reasoning => "medium", @@ -94,6 +96,7 @@ impl StatusSurfacePreviewItem { Self::SessionId, Self::FastMode, Self::RawOutput, + Self::WorkspaceHeadline, Self::Model, Self::ModelWithReasoning, Self::Reasoning, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index fd3c140e3f..20bef58a1a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -727,6 +727,16 @@ pub(crate) struct ChatWidget { status_line_git_summary_pending: bool, // True once we've attempted a Git summary lookup for the current CWD. status_line_git_summary_lookup_complete: bool, + // Cached workspace notification headline for the status line. + status_line_workspace_headline: Option, + // Request ID for the async workspace headline fetch currently in flight. + status_line_workspace_headline_pending_request_id: Option, + // Request ID to assign to the next workspace headline fetch. + next_status_line_workspace_headline_request_id: u64, + // Last time a workspace headline fetch was requested. + status_line_workspace_headline_last_requested_at: Option, + // Set after the backend reports the workspace-message feature gate is disabled. + status_line_workspace_messages_disabled: bool, // Current thread-goal status shown in the status line when plan mode is inactive. current_goal_status_indicator: Option, current_goal_status: Option, @@ -1188,6 +1198,7 @@ impl ChatWidget { { self.refresh_terminal_title(); } + self.refresh_status_line_if_workspace_headline_due(); } fn flush_active_cell(&mut self) { diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 77fdd74e52..17f2c8c3b0 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -229,6 +229,11 @@ impl ChatWidget { status_line_git_summary_cwd: None, status_line_git_summary_pending: false, status_line_git_summary_lookup_complete: false, + status_line_workspace_headline: None, + status_line_workspace_headline_pending_request_id: None, + next_status_line_workspace_headline_request_id: 0, + status_line_workspace_headline_last_requested_at: None, + status_line_workspace_messages_disabled: false, current_goal_status_indicator: None, current_goal_status: None, external_editor_state: ExternalEditorState::Closed, diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 75f8d1ad4b..75c4cc66e4 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -224,6 +224,10 @@ impl ChatWidget { self.clear_pending_token_activity_refreshes(); self.clear_pending_rate_limit_reset_requests(); } + self.status_line_workspace_headline = None; + self.status_line_workspace_headline_pending_request_id = None; + self.status_line_workspace_headline_last_requested_at = None; + self.status_line_workspace_messages_disabled = false; self.status_account_display = status_account_display; self.plan_type = plan_type; self.has_chatgpt_account = has_chatgpt_account; @@ -232,6 +236,7 @@ impl ChatWidget { .set_connectors_enabled(self.connectors_enabled()); self.bottom_pane .set_token_activity_command_enabled(has_codex_backend_auth); + self.refresh_status_line(); } /// Set the syntax theme override in the widget's config copy. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_workspace_headline.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_workspace_headline.snap new file mode 100644 index 0000000000..32ad18ec65 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_workspace_headline.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests/status_surface_previews.rs +expression: status_line_popup_snapshot(&mut chat) +--- + Configure Status Line + Select which items to display in the status line. + + Type to search + > +› [x] Use theme colors Apply colors from the active /theme + ─────────────────────── + [x] workspace-headline Workspace notification headline (Enterprise workspaces only; omitted … + [ ] model Current model name + [ ] model-with-reasoning Current model name with reasoning level + [ ] reasoning Current reasoning level + [ ] current-dir Current working directory + [ ] project-name Project name (omitted when unavailable) + + Workspace maintenance starts at 5pm + Press space to toggle; ←/→ to move; enter to confirm and close; esc to close diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 592dd04381..5f02dd8d69 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -65,6 +65,11 @@ impl StatusSurfaceSelections { .status_line_items .contains(&StatusLineItem::BranchChanges) } + + fn uses_workspace_headline(&self) -> bool { + self.status_line_items + .contains(&StatusLineItem::WorkspaceHeadline) + } } /// Cached project-root display name keyed by the cwd used for the last lookup. @@ -157,6 +162,15 @@ impl ChatWidget { self.request_status_line_git_summary(cwd); } } + + if !selections.uses_workspace_headline() { + self.status_line_workspace_headline = None; + self.status_line_workspace_headline_pending_request_id = None; + self.status_line_workspace_headline_last_requested_at = None; + self.status_line_workspace_messages_disabled = false; + } else { + self.request_status_line_workspace_headline_if_due(Instant::now()); + } } fn refresh_status_line_from_selections(&mut self, selections: &StatusSurfaceSelections) { @@ -553,6 +567,85 @@ impl ChatWidget { }); } + fn request_status_line_workspace_headline_if_due(&mut self, now: Instant) { + if !self.status_line_workspace_headline_should_fetch(now) { + return; + } + let request_id = self.next_status_line_workspace_headline_request_id; + self.next_status_line_workspace_headline_request_id = self + .next_status_line_workspace_headline_request_id + .wrapping_add(/*rhs*/ 1); + self.status_line_workspace_headline_pending_request_id = Some(request_id); + self.status_line_workspace_headline_last_requested_at = Some(now); + self.app_event_tx + .send(AppEvent::RefreshStatusLineWorkspaceHeadline { request_id }); + } + + fn status_line_workspace_headline_should_fetch(&self, now: Instant) -> bool { + if self + .status_line_workspace_headline_pending_request_id + .is_some() + || self.status_line_workspace_messages_disabled + || !self.has_codex_backend_auth + { + return false; + } + + self.status_line_workspace_headline_last_requested_at + .is_none_or(|last_requested_at| { + now.saturating_duration_since(last_requested_at) + >= crate::workspace_messages::WORKSPACE_HEADLINE_REFRESH_INTERVAL + }) + } + + pub(super) fn refresh_status_line_if_workspace_headline_due(&mut self) { + let now = Instant::now(); + if self.status_line_workspace_headline_should_fetch(now) + && self + .status_line_items_with_invalids() + .0 + .contains(&StatusLineItem::WorkspaceHeadline) + { + self.refresh_status_line(); + } + } + + pub(crate) fn set_status_line_workspace_headline( + &mut self, + request_id: u64, + result: Result, + ) -> bool { + if self.status_line_workspace_headline_pending_request_id != Some(request_id) { + return false; + } + self.status_line_workspace_headline_pending_request_id = None; + match result { + Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(headline)) => { + self.status_line_workspace_messages_disabled = false; + self.status_line_workspace_headline = headline; + } + Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::FeatureDisabled) => { + self.status_line_workspace_messages_disabled = true; + self.status_line_workspace_headline = None; + } + Err(err) => { + tracing::debug!(error = %err, "failed to fetch workspace headline"); + } + } + + if !self.status_line_workspace_messages_disabled + && self + .status_line_items_with_invalids() + .0 + .contains(&StatusLineItem::WorkspaceHeadline) + { + self.frame_requester + .schedule_frame_in(crate::workspace_messages::WORKSPACE_HEADLINE_REFRESH_INTERVAL); + } + self.refresh_status_line(); + true + } + /// Resolves a display string for one configured status-line item. /// /// Returning `None` means "omit this item for now", not "configuration error". Callers rely on @@ -653,6 +746,7 @@ impl ChatWidget { } }, ), + StatusLineItem::WorkspaceHeadline => self.status_line_workspace_headline.clone(), StatusLineItem::TaskProgress => self.terminal_title_task_progress(), } } @@ -693,6 +787,7 @@ impl ChatWidget { StatusSurfacePreviewItem::SessionId => StatusLineItem::SessionId, StatusSurfacePreviewItem::FastMode => StatusLineItem::FastMode, StatusSurfacePreviewItem::RawOutput => StatusLineItem::RawOutput, + StatusSurfacePreviewItem::WorkspaceHeadline => StatusLineItem::WorkspaceHeadline, StatusSurfacePreviewItem::Model => StatusLineItem::ModelName, StatusSurfacePreviewItem::ModelWithReasoning => StatusLineItem::ModelWithReasoning, StatusSurfacePreviewItem::Reasoning => StatusLineItem::Reasoning, diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 583377bace..9e528fb6c2 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -14,6 +14,15 @@ fn enable_test_ambient_pet(chat: &mut ChatWidget) { chat.install_test_ambient_pet_for_tests(/*animations_enabled*/ false); } +fn take_workspace_headline_request_id( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> u64 { + match rx.try_recv() { + Ok(AppEvent::RefreshStatusLineWorkspaceHeadline { request_id }) => request_id, + event => panic!("expected workspace headline refresh, got {event:?}"), + } +} + /// Receiving a token usage update without usage clears the context indicator. #[tokio::test] async fn token_count_none_resets_context_indicator() { @@ -2141,6 +2150,192 @@ async fn status_line_legacy_context_usage_renders_context_used_percent() { ); } +#[tokio::test] +async fn status_line_workspace_headline_renders_cached_value() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + chat.status_line_workspace_headline = Some("Workspace maintenance starts at 5pm".to_string()); + + chat.refresh_status_line(); + + assert_eq!( + status_line_text(&chat), + Some("Workspace maintenance starts at 5pm".to_string()) + ); + assert!( + drain_insert_history(&mut rx).is_empty(), + "workspace-headline should be a valid status line item" + ); +} + +#[tokio::test] +async fn status_line_workspace_headline_omits_when_unavailable() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.config.tui_status_line = Some(vec![ + "workspace-headline".to_string(), + "run-state".to_string(), + ]); + + chat.refresh_status_line(); + + assert_eq!(status_line_text(&chat), Some("Ready".to_string())); + assert!( + drain_insert_history(&mut rx).is_empty(), + "workspace-headline should be omitted without warning when no headline is cached" + ); +} + +#[tokio::test] +async fn workspace_headline_update_applies_feature_disabled_result() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + chat.status_line_workspace_headline = Some("Old headline".to_string()); + let request_id = 3; + chat.status_line_workspace_headline_pending_request_id = Some(request_id); + + assert!(chat.set_status_line_workspace_headline( + request_id, + Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::FeatureDisabled), + )); + + assert_eq!(status_line_text(&chat), None); + assert!(chat.status_line_workspace_messages_disabled); +} + +#[tokio::test] +async fn workspace_headline_update_applies_available_headline() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + let request_id = 4; + chat.status_line_workspace_headline_pending_request_id = Some(request_id); + + assert!(chat.set_status_line_workspace_headline( + request_id, + Ok( + crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(Some( + "Fresh workspace headline".to_string(), + )) + ), + )); + + assert_eq!( + status_line_text(&chat), + Some("Fresh workspace headline".to_string()) + ); + assert!(!chat.status_line_workspace_messages_disabled); +} + +#[tokio::test] +async fn account_update_clears_workspace_headline_state() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + chat.status_line_workspace_headline = Some("Old workspace headline".to_string()); + chat.status_line_workspace_headline_pending_request_id = Some(5); + chat.status_line_workspace_headline_last_requested_at = Some(Instant::now()); + chat.status_line_workspace_messages_disabled = true; + + chat.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ false, + ); + + assert_eq!( + ( + status_line_text(&chat), + chat.status_line_workspace_headline_pending_request_id, + chat.status_line_workspace_headline_last_requested_at, + chat.status_line_workspace_messages_disabled, + ), + (None, None, None, false) + ); +} + +#[tokio::test] +async fn workspace_headline_fetch_allows_backend_auth_without_chatgpt_account() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + + chat.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ true, + ); + + let request_id = take_workspace_headline_request_id(&mut rx); + assert_eq!( + chat.status_line_workspace_headline_pending_request_id, + Some(request_id) + ); +} + +#[tokio::test] +async fn account_update_discards_stale_workspace_headline_results() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + + chat.update_account_state( + Some(StatusAccountDisplay::ChatGpt { + email: Some("first@example.com".to_string()), + plan: None, + }), + /*plan_type*/ None, + /*has_chatgpt_account*/ true, + /*has_codex_backend_auth*/ true, + ); + let stale_request_id = take_workspace_headline_request_id(&mut rx); + + chat.update_account_state( + Some(StatusAccountDisplay::ChatGpt { + email: Some("second@example.com".to_string()), + plan: None, + }), + /*plan_type*/ None, + /*has_chatgpt_account*/ true, + /*has_codex_backend_auth*/ true, + ); + let current_request_id = take_workspace_headline_request_id(&mut rx); + + assert_ne!(stale_request_id, current_request_id); + assert!(!chat.set_status_line_workspace_headline( + stale_request_id, + Ok( + crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(Some( + "First account headline".to_string(), + )) + ), + )); + assert_eq!( + ( + chat.status_line_workspace_headline.clone(), + chat.status_line_workspace_headline_pending_request_id, + chat.status_line_workspace_messages_disabled, + ), + (None, Some(current_request_id), false) + ); + + assert!(chat.set_status_line_workspace_headline( + current_request_id, + Ok( + crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(Some( + "Second account headline".to_string(), + )) + ), + )); + assert!(!chat.set_status_line_workspace_headline( + stale_request_id, + Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::FeatureDisabled), + )); + assert_eq!( + ( + status_line_text(&chat), + chat.status_line_workspace_headline_pending_request_id, + chat.status_line_workspace_messages_disabled, + ), + (Some("Second account headline".to_string()), None, false,) + ); +} + #[tokio::test] async fn status_line_branch_state_resets_when_git_branch_disabled() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs index 8654b37d24..63005cb957 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs @@ -180,6 +180,18 @@ async fn status_line_setup_popup_hardcoded_only_snapshot() { ); } +#[tokio::test] +async fn status_line_setup_popup_workspace_headline_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.status_line_workspace_headline = Some("Workspace maintenance starts at 5pm".to_string()); + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + + assert_chatwidget_snapshot!( + "status_line_setup_popup_workspace_headline", + status_line_popup_snapshot(&mut chat) + ); +} + #[tokio::test] async fn status_surface_preview_lines_mixed_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 82d9a72bd8..9a4a88c86d 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -200,6 +200,7 @@ mod width; #[cfg(any(target_os = "windows", test))] mod windows_sandbox; mod workspace_command; +mod workspace_messages; mod wrapping; diff --git a/codex-rs/tui/src/workspace_messages.rs b/codex-rs/tui/src/workspace_messages.rs new file mode 100644 index 0000000000..4f5695f80c --- /dev/null +++ b/codex-rs/tui/src/workspace_messages.rs @@ -0,0 +1,29 @@ +use codex_app_server_protocol::GetWorkspaceMessagesResponse; +use codex_app_server_protocol::WorkspaceMessageType; +use std::time::Duration; + +pub(crate) const WORKSPACE_HEADLINE_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum WorkspaceHeadlineFetchResult { + Available(Option), + FeatureDisabled, +} + +pub(crate) fn workspace_headline_from_response( + response: GetWorkspaceMessagesResponse, +) -> WorkspaceHeadlineFetchResult { + if !response.feature_enabled { + return WorkspaceHeadlineFetchResult::FeatureDisabled; + } + + WorkspaceHeadlineFetchResult::Available(response.messages.into_iter().find_map(|message| { + (message.message_type == WorkspaceMessageType::Headline) + .then(|| message.message_body.trim().to_string()) + .filter(|headline| !headline.is_empty()) + })) +} + +#[cfg(test)] +#[path = "workspace_messages_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/workspace_messages_tests.rs b/codex-rs/tui/src/workspace_messages_tests.rs new file mode 100644 index 0000000000..962d2ceba1 --- /dev/null +++ b/codex-rs/tui/src/workspace_messages_tests.rs @@ -0,0 +1,51 @@ +use super::*; +use codex_app_server_protocol::WorkspaceMessage; +use pretty_assertions::assert_eq; + +#[test] +fn workspace_headline_from_response_uses_first_non_empty_headline() { + let response = GetWorkspaceMessagesResponse { + feature_enabled: true, + messages: vec![ + WorkspaceMessage { + message_id: "announcement-id".to_string(), + message_type: WorkspaceMessageType::Announcement, + message_body: "Announcement body".to_string(), + created_at: None, + archived_at: None, + }, + WorkspaceMessage { + message_id: "empty-headline-id".to_string(), + message_type: WorkspaceMessageType::Headline, + message_body: " ".to_string(), + created_at: None, + archived_at: None, + }, + WorkspaceMessage { + message_id: "headline-id".to_string(), + message_type: WorkspaceMessageType::Headline, + message_body: " Workspace headline ".to_string(), + created_at: None, + archived_at: None, + }, + ], + }; + + assert_eq!( + workspace_headline_from_response(response), + WorkspaceHeadlineFetchResult::Available(Some("Workspace headline".to_string())) + ); +} + +#[test] +fn workspace_headline_from_response_reports_feature_disabled() { + let response = GetWorkspaceMessagesResponse { + feature_enabled: false, + messages: Vec::new(), + }; + + assert_eq!( + workspace_headline_from_response(response), + WorkspaceHeadlineFetchResult::FeatureDisabled + ); +} From 91e6da943b08677cf1c9346fb438488471fc03a5 Mon Sep 17 00:00:00 2001 From: jif Date: Mon, 22 Jun 2026 16:43:08 +0100 Subject: [PATCH 104/112] Stop logging every Responses WebSocket event (#29432) ## Why Every successful Responses WebSocket event currently produces three local log records: the full payload at TRACE, an OpenTelemetry log event, and an OpenTelemetry trace event. On busy threads these records fill the 1,000-row log partition in seconds and cause continuous SQLite insert-and-prune churn. Related to https://openai.slack.com/archives/C095U48JNL9/p1782128972644209 ## What changed - Stop logging each successful Responses WebSocket payload at TRACE. - Stop emitting `codex.websocket_event` as OpenTelemetry log and trace events. - Keep WebSocket event counters, duration metrics, response timing metrics, parsing, and error handling. --- .../src/endpoint/responses_websocket.rs | 1 - codex-rs/otel/src/events/session_telemetry.rs | 46 ++----------------- 2 files changed, 5 insertions(+), 42 deletions(-) diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index fa01530102..d1f06ee2f8 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -668,7 +668,6 @@ async fn run_websocket_response_stream( match message { Message::Text(text) => { - trace!("websocket event: {text}"); if let Some(wrapped_error) = parse_wrapped_websocket_error_event(&text) && let Some(error) = map_wrapped_websocket_error_event(wrapped_error, text.to_string()) diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index c8e35b9f7d..efff5c24e3 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -707,7 +707,6 @@ impl SessionTelemetry { duration: Duration, ) { let mut kind = None; - let mut error_message = None; let mut success = true; match result { @@ -724,49 +723,26 @@ impl SessionTelemetry { } if kind.as_deref() == Some("response.failed") { success = false; - error_message = value - .get("response") - .and_then(|value| value.get("error")) - .map(serde_json::Value::to_string) - .or_else(|| Some("response.failed event received".to_string())); } } - Err(err) => { + Err(_) => { kind = Some("parse_error".to_string()); - error_message = Some(err.to_string()); success = false; } } } - tokio_tungstenite::tungstenite::Message::Binary(_) => { - success = false; - error_message = Some("unexpected binary websocket event".to_string()); - } tokio_tungstenite::tungstenite::Message::Ping(_) | tokio_tungstenite::tungstenite::Message::Pong(_) => { return; } - tokio_tungstenite::tungstenite::Message::Close(_) => { + tokio_tungstenite::tungstenite::Message::Binary(_) + | tokio_tungstenite::tungstenite::Message::Close(_) + | tokio_tungstenite::tungstenite::Message::Frame(_) => { success = false; - error_message = - Some("websocket closed by server before response.completed".to_string()); - } - tokio_tungstenite::tungstenite::Message::Frame(_) => { - success = false; - error_message = Some("unexpected websocket frame".to_string()); } }, - Ok(Some(Err(err))) => { + Ok(Some(Err(_))) | Ok(None) | Err(_) => { success = false; - error_message = Some(err.to_string()); - } - Ok(None) => { - success = false; - error_message = Some("stream closed before response.completed".to_string()); - } - Err(err) => { - success = false; - error_message = Some(err.to_string()); } } @@ -775,18 +751,6 @@ impl SessionTelemetry { let tags = [("kind", kind_str), ("success", success_str)]; self.counter(WEBSOCKET_EVENT_COUNT_METRIC, /*inc*/ 1, &tags); self.record_duration(WEBSOCKET_EVENT_DURATION_METRIC, duration, &tags); - log_and_trace_event!( - self, - common: { - event.name = "codex.websocket_event", - event.kind = %kind_str, - duration_ms = %duration.as_millis(), - success = success_str, - error.message = error_message.as_deref(), - }, - log: {}, - trace: {}, - ); } pub fn log_sse_event( From 22886f21dee225bc54dcb3fd81771be810c61175 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Mon, 22 Jun 2026 08:47:43 -0700 Subject: [PATCH 105/112] core: refresh environment context before sampling (#29073) ## Why Nonblocking environment snapshots allow a turn to reach the model while a remote environment is still starting. The initial context can describe that environment as still loading, but nothing currently refreshes the model-visible environment context when startup finishes during the same turn. This adds the first request-scoped reconciliation slice on top of #28683. It is gated by `DeferredExecutor` and intentionally updates only model-visible environment context; tools and other environment-derived state will migrate separately. ## What - Add a minimal `StepContext` containing the environment snapshot captured before each sampling request. - Render attached environments with their resolved shell and starting environments with `still loading`. - Track the latest environment state recorded in model history and append a bounded update only when it changes. - Seed that baseline from full initial context so ready-at-start environments are not duplicated. - Clear the in-memory baseline when history is rewritten so replacement history can be refreshed safely. ## Testing - `just test -p codex-core deferred_executor` - `just test -p codex-core environment_context_baseline_deduplicates_until_history_is_replaced` The integration coverage verifies that a pending environment reaches the first request, the ready state reaches the next request, later requests do not duplicate it, and ready-at-start environments remain single-injected.
Live verification - Connected to a real remote executor with startup deliberately delayed and forced three sampling requests in one turn. - Inspected the raw model inputs: request 1 showed the remote environment as `still loading`, request 2 appended its ready shell and cwd, and request 3 contained no duplicate ready update. - With the feature disabled, startup waited for the delayed executor and the first request contained only the ready environment. - With a synchronously ready environment and the feature enabled, the first request contained one environment context with no duplicate. - Executed `pwd` and read a marker file through the remote process runner; the command exited successfully and returned the remote cwd and marker contents.
--- .../core/src/context/environment_context.rs | 122 +++++++--- .../src/context/environment_context_tests.rs | 61 +++-- codex-rs/core/src/context_manager/history.rs | 17 ++ .../core/src/context_manager/history_tests.rs | 15 ++ codex-rs/core/src/session/mod.rs | 51 ++++ codex-rs/core/src/session/step_context.rs | 7 + codex-rs/core/src/session/turn.rs | 16 ++ codex-rs/core/tests/suite/remote_env.rs | 226 +++++++++++++++++- 8 files changed, 460 insertions(+), 55 deletions(-) create mode 100644 codex-rs/core/src/session/step_context.rs diff --git a/codex-rs/core/src/context/environment_context.rs b/codex-rs/core/src/context/environment_context.rs index e93ffb88ff..5f36124b81 100644 --- a/codex-rs/core/src/context/environment_context.rs +++ b/codex-rs/core/src/context/environment_context.rs @@ -1,3 +1,4 @@ +use crate::session::step_context::StepContext; use crate::session::turn_context::TurnContext; use crate::session::turn_context::TurnEnvironment; use crate::shell::Shell; @@ -30,34 +31,50 @@ pub(crate) struct EnvironmentContext { pub(crate) struct EnvironmentContextEnvironment { pub(crate) id: String, pub(crate) cwd: PathUri, - pub(crate) shell: String, + status: EnvironmentContextEnvironmentStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum EnvironmentContextEnvironmentStatus { + Starting, + Ready { shell: String }, } impl EnvironmentContextEnvironment { - fn legacy(cwd: PathUri, shell: String) -> Self { - Self { - id: String::new(), - cwd, - shell, - } - } - fn from_turn_environments(environments: &[TurnEnvironment], shell: &Shell) -> Vec { environments .iter() .map(|environment| Self { id: environment.environment_id.clone(), cwd: environment.cwd().clone(), - shell: environment - .shell - .as_ref() - .map(|shell| shell.name().to_string()) - .unwrap_or_else(|| shell.name().to_string()), + status: EnvironmentContextEnvironmentStatus::Ready { + shell: environment + .shell + .as_ref() + .map(|shell| shell.name().to_string()) + .unwrap_or_else(|| shell.name().to_string()), + }, }) .collect() } } +impl EnvironmentContextEnvironmentStatus { + fn equals_except_shell(&self, other: &Self) -> bool { + matches!( + (self, other), + (Self::Starting, Self::Starting) | (Self::Ready { .. }, Self::Ready { .. }) + ) + } + + fn render(&self, indent: &str) -> String { + match self { + Self::Starting => format!("{indent}starting"), + Self::Ready { shell } => format!("{indent}{shell}"), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum EnvironmentContextEnvironments { None, @@ -81,13 +98,16 @@ impl EnvironmentContextEnvironments { fn equals_except_shell(&self, other: &Self) -> bool { match (self, other) { (Self::None, Self::None) => true, - (Self::Single(left), Self::Single(right)) => left.cwd == right.cwd, + (Self::Single(left), Self::Single(right)) => { + left.cwd == right.cwd && left.status.equals_except_shell(&right.status) + } (Self::Multiple(left), Self::Multiple(right)) => { left.len() == right.len() - && left - .iter() - .zip(right.iter()) - .all(|(left, right)| left.id == right.id && left.cwd == right.cwd) + && left.iter().zip(right.iter()).all(|(left, right)| { + left.id == right.id + && left.cwd == right.cwd + && left.status.equals_except_shell(&right.status) + }) } _ => false, } @@ -365,6 +385,48 @@ impl EnvironmentContext { } } + pub(crate) fn from_step_context(step_context: &StepContext, shell: &Shell) -> Option { + let mut environments = EnvironmentContextEnvironment::from_turn_environments( + &step_context.environments.turn_environments, + shell, + ); + environments.extend( + step_context + .environments + .starting + .iter() + .map(|environment| EnvironmentContextEnvironment { + id: environment.selection.environment_id.clone(), + cwd: environment.selection.cwd.clone(), + status: EnvironmentContextEnvironmentStatus::Starting, + }), + ); + + Self::environment_only(environments) + } + + pub(crate) fn from_attached_environments( + environments: &[TurnEnvironment], + shell: &Shell, + ) -> Option { + Self::environment_only(EnvironmentContextEnvironment::from_turn_environments( + environments, + shell, + )) + } + + fn environment_only(environments: Vec) -> Option { + (!environments.is_empty()).then(|| { + Self::new( + environments, + /*current_date*/ None, + /*timezone*/ None, + /*network*/ None, + /*subagents*/ None, + ) + }) + } + /// Compares two environment contexts, ignoring the shell. Useful when /// comparing turn to turn, since the initial environment_context will /// include the shell, and then it is not configurable from turn to turn. @@ -386,10 +448,11 @@ impl EnvironmentContext { let environments = match &after.environments { EnvironmentContextEnvironments::Single(environment) => { if PathUri::from_abs_path(&before.cwd) != environment.cwd { - EnvironmentContextEnvironments::Single(EnvironmentContextEnvironment::legacy( - environment.cwd.clone(), - environment.shell.clone(), - )) + EnvironmentContextEnvironments::Single(EnvironmentContextEnvironment { + id: String::new(), + cwd: environment.cwd.clone(), + status: environment.status.clone(), + }) } else { EnvironmentContextEnvironments::None } @@ -442,10 +505,11 @@ impl EnvironmentContext { shell: String, ) -> Self { Self::new_with_environments( - EnvironmentContextEnvironments::from_vec(vec![EnvironmentContextEnvironment::legacy( - PathUri::from_abs_path(&turn_context_item.cwd), - shell, - )]), + EnvironmentContextEnvironments::from_vec(vec![EnvironmentContextEnvironment { + id: String::new(), + cwd: PathUri::from_abs_path(&turn_context_item.cwd), + status: EnvironmentContextEnvironmentStatus::Ready { shell }, + }]), turn_context_item.current_date.clone(), turn_context_item.timezone.clone(), Self::network_from_turn_context_item(turn_context_item), @@ -538,7 +602,7 @@ impl ContextualUserFragment for EnvironmentContext { EnvironmentContextEnvironments::Single(environment) => { let cwd = environment.cwd.inferred_native_path_string(); lines.push(format!(" {cwd}")); - lines.push(format!(" {}", environment.shell)); + lines.push(environment.status.render(" ")); } EnvironmentContextEnvironments::Multiple(environments) => { lines.push(" ".to_string()); @@ -546,7 +610,7 @@ impl ContextualUserFragment for EnvironmentContext { lines.push(format!(" ", environment.id)); let cwd = environment.cwd.inferred_native_path_string(); lines.push(format!(" {cwd}")); - lines.push(format!(" {}", environment.shell)); + lines.push(environment.status.render(" ")); lines.push(" ".to_string()); } lines.push(" ".to_string()); diff --git a/codex-rs/core/src/context/environment_context_tests.rs b/codex-rs/core/src/context/environment_context_tests.rs index d40ce4e671..4d9f1a7291 100644 --- a/codex-rs/core/src/context/environment_context_tests.rs +++ b/codex-rs/core/src/context/environment_context_tests.rs @@ -1,5 +1,6 @@ use crate::shell::ShellType; +use super::EnvironmentContextEnvironmentStatus::Ready; use super::*; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; @@ -37,7 +38,9 @@ fn serialize_workspace_write_environment_context() { vec![EnvironmentContextEnvironment { id: "local".to_string(), cwd: PathUri::from_abs_path(&cwd.abs()), - shell: fake_shell_name(), + status: Ready { + shell: fake_shell_name(), + }, }], Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), @@ -64,7 +67,9 @@ fn serialize_environment_context_with_foreign_windows_cwd() { vec![EnvironmentContextEnvironment { id: "remote".to_string(), cwd: PathUri::parse("file:///C:/windows").expect("Windows cwd URI"), - shell: "powershell".to_string(), + status: Ready { + shell: "powershell".to_string(), + }, }], /*current_date*/ None, /*timezone*/ None, @@ -91,7 +96,9 @@ fn serialize_environment_context_with_network() { vec![EnvironmentContextEnvironment { id: "local".to_string(), cwd: PathUri::from_abs_path(&test_abs_path("/repo")), - shell: fake_shell_name(), + status: Ready { + shell: fake_shell_name(), + }, }], Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), @@ -153,7 +160,9 @@ fn serialize_environment_context_with_full_filesystem_profile() { vec![EnvironmentContextEnvironment { id: "local".to_string(), cwd: PathUri::from_abs_path(&test_abs_path("/repo")), - shell: fake_shell_name(), + status: Ready { + shell: fake_shell_name(), + }, }], /*current_date*/ None, /*timezone*/ None, @@ -259,7 +268,9 @@ fn equals_except_shell_compares_cwd() { vec![EnvironmentContextEnvironment { id: "local".to_string(), cwd: PathUri::from_abs_path(&test_abs_path("/repo")), - shell: fake_shell_name(), + status: Ready { + shell: fake_shell_name(), + }, }], /*current_date*/ None, /*timezone*/ None, @@ -270,7 +281,9 @@ fn equals_except_shell_compares_cwd() { vec![EnvironmentContextEnvironment { id: "local".to_string(), cwd: PathUri::from_abs_path(&test_abs_path("/repo")), - shell: fake_shell_name(), + status: Ready { + shell: fake_shell_name(), + }, }], /*current_date*/ None, /*timezone*/ None, @@ -286,7 +299,9 @@ fn equals_except_shell_compares_cwd_differences() { vec![EnvironmentContextEnvironment { id: "local".to_string(), cwd: PathUri::from_abs_path(&test_abs_path("/repo1")), - shell: fake_shell_name(), + status: Ready { + shell: fake_shell_name(), + }, }], /*current_date*/ None, /*timezone*/ None, @@ -297,7 +312,9 @@ fn equals_except_shell_compares_cwd_differences() { vec![EnvironmentContextEnvironment { id: "local".to_string(), cwd: PathUri::from_abs_path(&test_abs_path("/repo2")), - shell: fake_shell_name(), + status: Ready { + shell: fake_shell_name(), + }, }], /*current_date*/ None, /*timezone*/ None, @@ -314,7 +331,9 @@ fn equals_except_shell_ignores_shell() { vec![EnvironmentContextEnvironment { id: "local".to_string(), cwd: PathUri::from_abs_path(&test_abs_path("/repo")), - shell: "bash".to_string(), + status: Ready { + shell: "bash".to_string(), + }, }], /*current_date*/ None, /*timezone*/ None, @@ -325,7 +344,9 @@ fn equals_except_shell_ignores_shell() { vec![EnvironmentContextEnvironment { id: "other".to_string(), cwd: PathUri::from_abs_path(&test_abs_path("/repo")), - shell: "zsh".to_string(), + status: Ready { + shell: "zsh".to_string(), + }, }], /*current_date*/ None, /*timezone*/ None, @@ -342,7 +363,9 @@ fn serialize_environment_context_with_subagents() { vec![EnvironmentContextEnvironment { id: "local".to_string(), cwd: PathUri::from_abs_path(&test_abs_path("/repo")), - shell: fake_shell_name(), + status: Ready { + shell: fake_shell_name(), + }, }], Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), @@ -376,12 +399,16 @@ fn serialize_environment_context_with_multiple_selected_environments() { EnvironmentContextEnvironment { id: "local".to_string(), cwd: PathUri::from_abs_path(&local_cwd.abs()), - shell: "bash".to_string(), + status: Ready { + shell: "bash".to_string(), + }, }, EnvironmentContextEnvironment { id: "remote".to_string(), cwd: PathUri::from_abs_path(&remote_cwd.abs()), - shell: "bash".to_string(), + status: Ready { + shell: "bash".to_string(), + }, }, ], Some("2026-02-26".to_string()), @@ -421,12 +448,16 @@ fn serialize_environment_context_prefers_environment_shell_when_present() { EnvironmentContextEnvironment { id: "local".to_string(), cwd: PathUri::from_abs_path(&local_cwd.abs()), - shell: "powershell".to_string(), + status: Ready { + shell: "powershell".to_string(), + }, }, EnvironmentContextEnvironment { id: "remote".to_string(), cwd: PathUri::from_abs_path(&remote_cwd.abs()), - shell: "cmd".to_string(), + status: Ready { + shell: "cmd".to_string(), + }, }, ], /*current_date*/ None, diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 6ce1c6148f..eb6f094248 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -1,3 +1,4 @@ +use crate::context::EnvironmentContext; use crate::context_manager::normalize; use crate::event_mapping::has_non_contextual_dev_message_content; use crate::event_mapping::is_contextual_dev_message_content; @@ -48,6 +49,8 @@ pub(crate) struct ContextManager { /// also clear this when it trims a mixed initial-context developer bundle /// whose non-diff fragments no longer exist in the surviving history. reference_context_item: Option, + /// Environment state most recently appended to model-visible history. + environment_context_baseline: Option, } impl ContextManager { @@ -59,6 +62,7 @@ impl ContextManager { &None, &None, /*model_context_window*/ None, ), reference_context_item: None, + environment_context_baseline: None, } } @@ -78,6 +82,17 @@ impl ContextManager { self.reference_context_item.clone() } + pub(crate) fn update_environment_context_baseline( + &mut self, + context: &EnvironmentContext, + ) -> bool { + if self.environment_context_baseline.as_ref() == Some(context) { + return false; + } + self.environment_context_baseline = Some(context.clone()); + true + } + pub(crate) fn set_token_usage_full(&mut self, context_window: i64) { match &mut self.token_info { Some(info) => info.fill_to_context_window(context_window), @@ -163,12 +178,14 @@ impl ContextManager { // its corresponding counterpart to keep the invariants intact without // running a full normalization pass. normalize::remove_corresponding_for(&mut self.items, &removed); + self.environment_context_baseline = None; } } pub(crate) fn replace(&mut self, items: Vec) { self.items = items; self.history_version = self.history_version.saturating_add(1); + self.environment_context_baseline = None; } /// Replace image content in the last turn if it originated from a tool output. diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 35a9656fe1..bbf8d8059a 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::context::EnvironmentContext; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_protocol::AgentPath; @@ -73,6 +74,20 @@ fn create_history_with_items(items: Vec) -> ContextManager { h } +#[test] +fn environment_context_baseline_deduplicates_until_history_is_replaced() { + let context = + EnvironmentContext::from_turn_context_item(&reference_context_item(), "bash".to_string()); + let mut history = ContextManager::new(); + + assert!(history.update_environment_context_baseline(&context)); + assert!(!history.update_environment_context_baseline(&context)); + + history.replace(Vec::new()); + + assert!(history.update_environment_context_baseline(&context)); +} + fn user_msg(text: &str) -> ResponseItem { ResponseItem::Message { id: None, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index f3c7a950dd..42eb0c29e3 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -218,6 +218,7 @@ mod rollout_budget; mod rollout_reconstruction; #[allow(clippy::module_inception)] pub(crate) mod session; +pub(crate) mod step_context; pub(crate) mod time_reminder; mod token_budget; pub(crate) mod turn; @@ -2772,6 +2773,35 @@ impl Session { self.send_raw_response_items(turn_context, items).await; } + pub(crate) async fn record_step_environment_context_if_changed( + &self, + turn_context: &TurnContext, + step_context: &step_context::StepContext, + ) { + if !turn_context.config.include_environment_context { + return; + } + + let shell = self.user_shell(); + let Some(environment_context) = + crate::context::EnvironmentContext::from_step_context(step_context, shell.as_ref()) + else { + return; + }; + let changed = { + let mut state = self.state.lock().await; + state + .history + .update_environment_context_baseline(&environment_context) + }; + if !changed { + return; + } + + let item = ContextualUserFragment::into(environment_context); + self.record_conversation_items(turn_context, &[item]).await; + } + pub(crate) async fn record_inter_agent_communication( &self, turn_context: &TurnContext, @@ -3438,6 +3468,22 @@ impl Session { .await, ); } + let initial_environment_context = if should_inject_full_context + && !context_items.is_empty() + && turn_context.config.include_environment_context + && turn_context + .config + .features + .enabled(Feature::DeferredExecutor) + { + let shell = self.user_shell(); + crate::context::EnvironmentContext::from_attached_environments( + &turn_context.environments.turn_environments, + shell.as_ref(), + ) + } else { + None + }; if !context_items.is_empty() { self.record_conversation_items(turn_context, &context_items) .await; @@ -3451,6 +3497,11 @@ impl Session { // context items. This keeps later runtime diffing aligned with the current turn state. let mut state = self.state.lock().await; state.set_reference_context_item(Some(turn_context_item)); + if let Some(environment_context) = initial_environment_context { + state + .history + .update_environment_context_baseline(&environment_context); + } } pub(crate) async fn update_token_usage_info( diff --git a/codex-rs/core/src/session/step_context.rs b/codex-rs/core/src/session/step_context.rs new file mode 100644 index 0000000000..17243ced67 --- /dev/null +++ b/codex-rs/core/src/session/step_context.rs @@ -0,0 +1,7 @@ +use crate::environment_selection::TurnEnvironmentSnapshot; + +/// Request-scoped state that may change between model sampling requests. +#[derive(Debug)] +pub(crate) struct StepContext { + pub(crate) environments: TurnEnvironmentSnapshot, +} diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 041da729ea..9b9c7cc66e 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -42,6 +42,7 @@ use crate::responses_retry::handle_retryable_response_stream_error; use crate::session::PreviousTurnSettings; use crate::session::TurnInput; use crate::session::session::Session; +use crate::session::step_context::StepContext; use crate::session::turn_context::TurnContext; use crate::stream_events_utils::HandleOutputCtx; use crate::stream_events_utils::TurnItemContributorPolicy; @@ -241,6 +242,21 @@ pub(crate) async fn run_turn( ) .await?; + if turn_context + .config + .features + .enabled(Feature::DeferredExecutor) + { + let step_context = StepContext { + environments: sess.services.turn_environments.snapshot().await, + }; + sess.record_step_environment_context_if_changed( + turn_context.as_ref(), + &step_context, + ) + .await; + } + // Construct the input that we will send to the model. let sampling_request_input: Vec = async { sess.clone_history() diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 67e5668f45..337ca8d405 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -26,6 +26,8 @@ use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionsResponse; +use codex_protocol::request_user_input::RequestUserInputAnswer; +use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; @@ -49,9 +51,13 @@ use core_test_support::test_codex::local; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::test_env; use core_test_support::wait_for_event; +use core_test_support::wait_for_event_match; +use futures::SinkExt; +use futures::StreamExt; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; +use std::collections::HashMap; use std::fs; use std::path::PathBuf; use std::process::Command; @@ -59,6 +65,12 @@ use std::time::Duration; use std::time::SystemTime; use std::time::UNIX_EPOCH; use tempfile::TempDir; +use tokio::net::TcpListener; +use tokio::net::TcpStream; +use tokio::time::timeout; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::accept_async; +use tokio_tungstenite::tungstenite::Message; async fn unified_exec_test(server: &wiremock::MockServer) -> Result { let mut builder = test_codex().with_config(|config| { config.use_experimental_unified_exec_tool = true; @@ -301,8 +313,7 @@ async fn explicit_remote_shell_runs_in_remote_cwd() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn deferred_executor_reaches_model_before_remote_environment_is_ready() -> Result<()> { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; +async fn deferred_executor_does_not_duplicate_initial_environment_context() -> Result<()> { let server = start_mock_server().await; let response_mock = mount_sse_once( &server, @@ -313,23 +324,216 @@ async fn deferred_executor_reaches_model_before_remote_environment_is_ready() -> ]), ) .await; + let mut builder = test_codex().with_config(|config| { + assert!(config.features.enable(Feature::DeferredExecutor).is_ok()); + }); + let test = builder.build(&server).await?; + + test.submit_turn("report the environment").await?; + + let user_context = response_mock.single_request().message_input_texts("user"); + assert_eq!( + user_context + .iter() + .filter(|text| text.contains("")) + .count(), + 1 + ); + + Ok(()) +} + +async fn read_exec_server_json(websocket: &mut WebSocketStream) -> Value { + loop { + match timeout(Duration::from_secs(5), websocket.next()) + .await + .expect("websocket read should not time out") + .expect("websocket should stay open") + .expect("websocket frame should read") + { + Message::Text(text) => { + return serde_json::from_str(text.as_ref()).expect("valid JSON-RPC message"); + } + Message::Binary(bytes) => { + return serde_json::from_slice(bytes.as_ref()).expect("valid JSON-RPC message"); + } + Message::Ping(_) | Message::Pong(_) => {} + other => panic!("expected JSON-RPC message, got {other:?}"), + } + } +} + +async fn serve_environment_info(listener: TcpListener) { + let (stream, _) = listener.accept().await.expect("connection"); + let mut websocket = accept_async(stream).await.expect("websocket handshake"); + + let initialize = read_exec_server_json(&mut websocket).await; + assert_eq!(initialize["method"], "initialize"); + websocket + .send(Message::Text( + json!({ + "id": initialize["id"], + "result": { "sessionId": "test-session" } + }) + .to_string() + .into(), + )) + .await + .expect("initialize response"); + let initialized = read_exec_server_json(&mut websocket).await; + assert_eq!(initialized["method"], "initialized"); + + let info = read_exec_server_json(&mut websocket).await; + assert_eq!(info["method"], "environment/info"); + websocket + .send(Message::Text( + json!({ + "id": info["id"], + "result": { "shell": { "name": "zsh", "path": "/bin/zsh" } } + }) + .to_string() + .into(), + )) + .await + .expect("environment info response"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn deferred_executor_updates_model_context_after_startup() -> Result<()> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let server = start_mock_server().await; + let user_input_call_id = "wait-for-startup"; + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + user_input_call_id, + "request_user_input", + &json!({ + "questions": [{ + "id": "continue", + "header": "Continue", + "question": "Continue after startup?", + "options": [{ + "label": "Yes (Recommended)", + "description": "Continue the test." + }, { + "label": "No", + "description": "Stop the test." + }] + }] + }) + .to_string(), + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_function_call( + "update-plan", + "update_plan", + &json!({ + "explanation": "Continue after startup.", + "plan": [{"step": "Finish", "status": "completed"}] + }) + .to_string(), + ), + ev_completed("resp-2"), + ]), + sse(vec![ + ev_response_created("resp-3"), + ev_assistant_message("msg-3", "done"), + ev_completed("resp-3"), + ]), + ], + ) + .await; let mut builder = test_codex() .with_exec_server_url(format!("ws://{}", listener.local_addr()?)) .with_config(|config| { assert!(config.features.enable(Feature::DeferredExecutor).is_ok()); + assert!( + config + .features + .enable(Feature::DefaultModeRequestUserInput) + .is_ok() + ); }); - - let test = tokio::time::timeout(Duration::from_secs(5), builder.build(&server)) + let test = timeout(Duration::from_secs(5), builder.build(&server)) .await .context("thread startup should not wait for the remote environment")??; - tokio::time::timeout( - Duration::from_secs(5), - test.submit_turn("respond before the environment is ready"), - ) - .await - .context("turn should reach the model before the remote environment is ready")??; - response_mock.single_request(); + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "wait for the environment".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + let request = wait_for_event_match(&test.codex, |event| match event { + EventMsg::RequestUserInput(request) => Some(request.clone()), + _ => None, + }) + .await; + + serve_environment_info(listener).await; + test.codex + .submit(Op::UserInputAnswer { + id: request.turn_id, + response: RequestUserInputResponse { + answers: HashMap::from([( + "continue".to_string(), + RequestUserInputAnswer { + answers: vec!["Yes (Recommended)".to_string()], + }, + )]), + }, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 3); + assert!( + requests[0] + .message_input_texts("user") + .iter() + .any(|text| text.contains("starting")) + ); + let ready_user_context = requests[1].message_input_texts("user"); + assert_eq!( + ready_user_context + .iter() + .filter(|text| text.contains("zsh")) + .count(), + 1 + ); + let final_user_context = requests[2].message_input_texts("user"); + assert_eq!( + final_user_context + .iter() + .filter(|text| text.contains("starting")) + .count(), + 1 + ); + assert_eq!( + final_user_context + .iter() + .filter(|text| text.contains("zsh")) + .count(), + 1 + ); + Ok(()) } From 8f8de7844f9a95ba0f07b9ae584b5c08b3173dd6 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Mon, 22 Jun 2026 09:05:27 -0700 Subject: [PATCH 106/112] fix(core): restore thread_source in x-codex-turn-metadata (#29455) ## Description Restore `thread_source` in `x-codex-turn-metadata`. Inadvertently removed `thread_source` from `x-codex-turn-metadata` in https://github.com/openai/codex/pull/27122 - didn't realize it was a top-level thread app-server API field, not passed in `responsesapi_client_metadata`. This also reserves the key so `responsesapi_client_metadata` cannot override it. --- .../tests/suite/v2/client_metadata.rs | 14 ++++++++++---- codex-rs/core/src/responses_metadata.rs | 8 ++++++++ codex-rs/core/src/session/review.rs | 8 ++++++-- codex-rs/core/src/session/turn_context.rs | 1 + codex-rs/core/src/turn_metadata.rs | 5 +++++ codex-rs/core/src/turn_metadata_tests.rs | 19 ++++++++++++++++++- 6 files changed, 48 insertions(+), 7 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/client_metadata.rs b/codex-rs/app-server/tests/suite/v2/client_metadata.rs index e55cd0c24d..ffe7a53627 100644 --- a/codex-rs/app-server/tests/suite/v2/client_metadata.rs +++ b/codex-rs/app-server/tests/suite/v2/client_metadata.rs @@ -63,7 +63,10 @@ async fn turn_start_forwards_client_metadata_to_responses_request_v2() -> Result timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp - .send_thread_start_request(ThreadStartParams::default()) + .send_thread_start_request(ThreadStartParams { + thread_source: Some(ThreadSource::Feature("automation".to_string())), + ..Default::default() + }) .await?; let thread_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, @@ -75,7 +78,6 @@ async fn turn_start_forwards_client_metadata_to_responses_request_v2() -> Result let client_metadata = HashMap::from([ ("fiber_run_id".to_string(), "fiber-start-123".to_string()), ("origin".to_string(), "gaas".to_string()), - ("thread_source".to_string(), "client-supplied".to_string()), ]); let turn_req = mcp .send_turn_start_request(TurnStartParams { @@ -110,7 +112,7 @@ async fn turn_start_forwards_client_metadata_to_responses_request_v2() -> Result .expect("x-codex-turn-metadata header should be present"); assert_eq!(metadata["fiber_run_id"].as_str(), Some("fiber-start-123")); assert_eq!(metadata["origin"].as_str(), Some("gaas")); - assert_eq!(metadata["thread_source"].as_str(), Some("client-supplied")); + assert_eq!(metadata["thread_source"].as_str(), Some("automation")); assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str())); assert!(metadata.get("installation_id").is_some()); assert!(metadata.get("session_id").is_some()); @@ -561,7 +563,10 @@ async fn turn_start_forwards_client_metadata_to_responses_websocket_request_body timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp - .send_thread_start_request(ThreadStartParams::default()) + .send_thread_start_request(ThreadStartParams { + thread_source: Some(ThreadSource::Feature("automation".to_string())), + ..Default::default() + }) .await?; let thread_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, @@ -619,6 +624,7 @@ async fn turn_start_forwards_client_metadata_to_responses_websocket_request_body .expect("websocket x-codex-turn-metadata client metadata should be present"); assert_eq!(metadata["fiber_run_id"].as_str(), Some("fiber-start-123")); assert_eq!(metadata["origin"].as_str(), Some("gaas")); + assert_eq!(metadata["thread_source"].as_str(), Some("automation")); assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str())); assert!(metadata.get("session_id").is_some()); assert_eq!( diff --git a/codex-rs/core/src/responses_metadata.rs b/codex-rs/core/src/responses_metadata.rs index 6e86ed8eb3..1852c4bea6 100644 --- a/codex-rs/core/src/responses_metadata.rs +++ b/codex-rs/core/src/responses_metadata.rs @@ -10,6 +10,7 @@ use codex_protocol::ThreadId; use codex_protocol::protocol::InternalSessionSource; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::ThreadSource; use codex_utils_string::to_ascii_json_string; use http::HeaderMap as ApiHeaderMap; use http::HeaderValue; @@ -34,6 +35,7 @@ pub(crate) const TURN_STARTED_AT_UNIX_MS_KEY: &str = "turn_started_at_unix_ms"; pub(crate) const FORKED_FROM_THREAD_ID_KEY: &str = "forked_from_thread_id"; pub(crate) const PARENT_THREAD_ID_KEY: &str = "parent_thread_id"; pub(crate) const SUBAGENT_KIND_KEY: &str = "subagent_kind"; +pub(crate) const THREAD_SOURCE_KEY: &str = "thread_source"; pub(crate) const SANDBOX_KEY: &str = "sandbox"; pub(crate) const WORKSPACES_KEY: &str = "workspaces"; @@ -56,6 +58,7 @@ const RESERVED_METADATA_KEYS: &[&str] = &[ FORKED_FROM_THREAD_ID_KEY, PARENT_THREAD_ID_KEY, SUBAGENT_KIND_KEY, + THREAD_SOURCE_KEY, SANDBOX_KEY, WORKSPACES_KEY, ]; @@ -143,6 +146,7 @@ pub struct CodexResponsesMetadata { pub(crate) parent_thread_id: Option, pub(crate) subagent_header: Option, pub(crate) subagent_kind: Option, + pub(crate) thread_source: Option, pub(crate) sandbox: Option, pub(crate) workspaces: BTreeMap, pub(crate) turn_started_at_unix_ms: Option, @@ -167,6 +171,7 @@ impl CodexResponsesMetadata { parent_thread_id: None, subagent_header: None, subagent_kind: None, + thread_source: None, sandbox: None, workspaces: BTreeMap::new(), turn_started_at_unix_ms: None, @@ -268,6 +273,7 @@ impl CodexResponsesMetadata { forked_from_thread_id: self.forked_from_thread_id, parent_thread_id: self.parent_thread_id, subagent_kind: self.subagent_kind.as_deref(), + thread_source: self.thread_source.as_ref(), sandbox: self.sandbox.as_deref(), workspaces: non_empty_workspaces(&self.workspaces), turn_started_at_unix_ms: self.turn_started_at_unix_ms, @@ -354,6 +360,8 @@ struct CodexTurnMetadataPayload<'a> { #[serde(default, skip_serializing_if = "Option::is_none")] subagent_kind: Option<&'a str>, #[serde(default, skip_serializing_if = "Option::is_none")] + thread_source: Option<&'a ThreadSource>, + #[serde(default, skip_serializing_if = "Option::is_none")] sandbox: Option<&'a str>, #[serde(default, skip_serializing_if = "Option::is_none")] workspaces: Option<&'a BTreeMap>, diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 7fccc4d19f..8986ffd052 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -75,9 +75,12 @@ pub(super) async fn spawn_review_thread( .model_reasoning_summary .unwrap_or(model_info.default_reasoning_summary); let session_source = parent_turn_context.session_source.clone(); - let forked_from_thread_id = { + let (forked_from_thread_id, thread_source) = { let state = sess.state.lock().await; - state.session_configuration.forked_from_thread_id + ( + state.session_configuration.forked_from_thread_id, + state.session_configuration.thread_source.clone(), + ) }; let per_turn_config = Arc::new(per_turn_config); @@ -88,6 +91,7 @@ pub(super) async fn spawn_review_thread( forked_from_thread_id, parent_turn_context.parent_thread_id, &session_source, + thread_source, review_turn_id.clone(), #[allow(deprecated)] parent_turn_context.cwd.clone(), diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 2fb02e7fc1..b546355f7e 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -536,6 +536,7 @@ impl Session { session_configuration.forked_from_thread_id, session_configuration.parent_thread_id, &session_configuration.session_source, + session_configuration.thread_source.clone(), sub_id.clone(), cwd.clone(), &session_configuration.permission_profile(), diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index d13c081817..0a1f781b38 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -25,6 +25,7 @@ use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::ThreadSource; use codex_utils_absolute_path::AbsolutePathBuf; const MODEL_KEY: &str = "model"; @@ -91,6 +92,7 @@ pub(crate) struct TurnMetadataState { parent_thread_id: Option, subagent_header: Option, subagent_kind: Option, + thread_source: Option, turn_id: String, sandbox: Option, enriched_workspaces: Arc>>>, @@ -108,6 +110,7 @@ impl TurnMetadataState { forked_from_thread_id: Option, parent_thread_id: Option, session_source: &SessionSource, + thread_source: Option, turn_id: String, cwd: AbsolutePathBuf, permission_profile: &PermissionProfile, @@ -132,6 +135,7 @@ impl TurnMetadataState { parent_thread_id, subagent_header: subagent_header_value(session_source), subagent_kind: subagent_metadata_kind(session_source), + thread_source, turn_id, sandbox, enriched_workspaces: Arc::new(RwLock::new(None)), @@ -225,6 +229,7 @@ impl TurnMetadataState { parent_thread_id: self.parent_thread_id, subagent_header: self.subagent_header.clone(), subagent_kind: self.subagent_kind.clone(), + thread_source: self.thread_source.clone(), sandbox: self.sandbox.clone(), workspaces: self.current_workspaces(), turn_started_at_unix_ms: self.current_turn_started_at_unix_ms(), diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs index 5e6d1f32bb..83649b9187 100644 --- a/codex-rs/core/src/turn_metadata_tests.rs +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -13,6 +13,7 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::ThreadSource; use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::PathBufExt; use core_test_support::PathExt; @@ -187,6 +188,7 @@ fn turn_metadata_state_uses_platform_sandbox_tag() { /*forked_from_thread_id*/ None, /*parent_thread_id*/ None, &SessionSource::Exec, + /*thread_source*/ None, "turn-a".to_string(), cwd, &permission_profile, @@ -229,6 +231,7 @@ fn turn_metadata_state_includes_root_fork_lineage() { Some(source_thread_id), /*parent_thread_id*/ None, &SessionSource::Exec, + /*thread_source*/ None, "turn-a".to_string(), cwd, &permission_profile, @@ -267,6 +270,7 @@ fn turn_metadata_state_includes_thread_spawn_subagent_parent_without_fork() { agent_nickname: None, agent_role: None, }), + /*thread_source*/ None, "turn-a".to_string(), cwd, &permission_profile, @@ -305,6 +309,7 @@ fn turn_metadata_state_includes_forked_thread_spawn_subagent_lineage() { agent_nickname: None, agent_role: None, }), + /*thread_source*/ None, "turn-a".to_string(), cwd, &permission_profile, @@ -349,6 +354,7 @@ fn turn_metadata_state_includes_known_parent_for_non_thread_spawn_subagents_with /*forked_from_thread_id*/ None, Some(parent_thread_id), &SessionSource::SubAgent(subagent_source), + /*thread_source*/ None, "turn-a".to_string(), cwd.clone(), &permission_profile, @@ -380,6 +386,7 @@ fn turn_metadata_state_includes_turn_started_at_unix_ms_after_start() { /*forked_from_thread_id*/ None, /*parent_thread_id*/ None, &SessionSource::Exec, + /*thread_source*/ None, "turn-a".to_string(), cwd, &permission_profile, @@ -409,6 +416,7 @@ fn turn_metadata_state_includes_model_and_reasoning_effort_only_in_request_meta( /*forked_from_thread_id*/ None, /*parent_thread_id*/ None, &SessionSource::Exec, + /*thread_source*/ None, "turn-a".to_string(), cwd, &permission_profile, @@ -457,6 +465,7 @@ fn turn_metadata_state_marks_user_input_requested_during_turn_only_for_mcp_reque /*forked_from_thread_id*/ None, /*parent_thread_id*/ None, &SessionSource::Exec, + /*thread_source*/ None, "turn-a".to_string(), cwd, &permission_profile, @@ -509,6 +518,7 @@ fn turn_metadata_state_ignores_client_reserved_metadata_before_start() { /*forked_from_thread_id*/ None, /*parent_thread_id*/ None, &SessionSource::Exec, + /*thread_source*/ None, "turn-a".to_string(), cwd, &permission_profile, @@ -562,6 +572,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( agent_nickname: None, agent_role: None, }), + Some(ThreadSource::Feature("automation".to_string())), "turn-a".to_string(), cwd, &permission_profile, @@ -637,7 +648,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( Some("55555555-5555-4555-8555-555555555555") ); assert_eq!(json["subagent_kind"].as_str(), Some("thread_spawn")); - assert_eq!(json["thread_source"].as_str(), Some("client-supplied")); + assert_eq!(json["thread_source"].as_str(), Some("automation")); assert_eq!(json["turn_id"].as_str(), Some("turn-a")); assert!(json.get("request_kind").is_none()); assert!(json.get(WINDOW_ID_KEY).is_none()); @@ -650,6 +661,10 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( let model_request_json: Value = serde_json::from_str(&model_request_header).expect("model request json"); assert_eq!(model_request_json["request_kind"].as_str(), Some("turn")); + assert_eq!( + model_request_json["thread_source"].as_str(), + Some("automation") + ); assert_eq!( model_request_json[INSTALLATION_ID_KEY].as_str(), Some("installation-a") @@ -679,6 +694,7 @@ fn turn_metadata_state_overlays_compaction_only_on_compaction_requests() { /*forked_from_thread_id*/ None, /*parent_thread_id*/ None, &SessionSource::Exec, + /*thread_source*/ None, "turn-a".to_string(), cwd, &permission_profile, @@ -741,6 +757,7 @@ async fn turn_metadata_state_preserves_lineage_after_git_enrichment() { agent_nickname: None, agent_role: None, }), + /*thread_source*/ None, "turn-a".to_string(), repo_path, &permission_profile, From e98d43ac372ddf7f513c0e30c56dd8dc35ea5404 Mon Sep 17 00:00:00 2001 From: jif Date: Mon, 22 Jun 2026 17:17:04 +0100 Subject: [PATCH 107/112] Filter noisy targets from persistent logs (#29457) ## Why The local SQLite log sink currently enables TRACE for every target. This persists high-volume dependency logs bridged through `target=log` and duplicates OpenTelemetry mirror events in `codex_otel.log_only` and `codex_otel.trace_safe`. These records rapidly consume the per-partition log budget and cause unnecessary SQLite insert-and-prune churn. ## What changed - Keep TRACE persistence for other targets. - Exclude bridged `target=log` events from the SQLite sink. - Exclude the two `codex_otel` mirror targets from the SQLite sink. - Share the same filter between app-server and TUI. Remote OpenTelemetry export and metrics are unchanged. --- codex-rs/app-server/src/lib.rs | 4 +--- codex-rs/state/src/log_db.rs | 10 ++++++++++ codex-rs/tui/src/lib.rs | 4 +--- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index f4cd83d732..264c28e098 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -68,13 +68,11 @@ use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; -use tracing::Level; use tracing::error; use tracing::info; use tracing::warn; use tracing_subscriber::EnvFilter; use tracing_subscriber::Layer; -use tracing_subscriber::filter::Targets; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::registry::Registry; use tracing_subscriber::util::SubscriberInitExt; @@ -666,7 +664,7 @@ pub async fn run_main_with_transport_options( let log_db = state_db.clone().map(log_db::start); let log_db_layer = log_db .clone() - .map(|layer| layer.with_filter(Targets::new().with_default(Level::TRACE))); + .map(|layer| layer.with_filter(log_db::default_filter())); let otel_logger_layer = otel.as_ref().and_then(|o| o.logger_layer()); let otel_tracing_layer = otel.as_ref().and_then(|o| o.tracing_layer()); let _ = tracing_subscriber::registry() diff --git a/codex-rs/state/src/log_db.rs b/codex-rs/state/src/log_db.rs index 71d2afbc01..55de2a3077 100644 --- a/codex-rs/state/src/log_db.rs +++ b/codex-rs/state/src/log_db.rs @@ -30,11 +30,13 @@ use tokio::sync::oneshot; use tracing::Event; use tracing::field::Field; use tracing::field::Visit; +use tracing::level_filters::LevelFilter; use tracing::span::Attributes; use tracing::span::Id; use tracing::span::Record; use tracing_subscriber::Layer; use tracing_subscriber::field::RecordFields; +use tracing_subscriber::filter::Targets; use tracing_subscriber::fmt::FormatFields; use tracing_subscriber::fmt::FormattedFields; use tracing_subscriber::fmt::format::DefaultFields; @@ -48,6 +50,14 @@ const LOG_QUEUE_CAPACITY: usize = 512; const LOG_BATCH_SIZE: usize = 128; const LOG_FLUSH_INTERVAL: Duration = Duration::from_secs(2); +pub fn default_filter() -> Targets { + Targets::new() + .with_default(LevelFilter::TRACE) + .with_target("log", LevelFilter::OFF) + .with_target("codex_otel.log_only", LevelFilter::OFF) + .with_target("codex_otel.trace_safe", LevelFilter::OFF) +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct LogSinkQueueConfig { pub queue_capacity: usize, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 9a4a88c86d..c5cf1e1853 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -74,12 +74,10 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::Instant; pub use token_usage::TokenUsage; -use tracing::Level; use tracing::error; use tracing::warn; use tracing_appender::non_blocking; use tracing_subscriber::EnvFilter; -use tracing_subscriber::filter::Targets; use tracing_subscriber::prelude::*; use url::Url; use uuid::Uuid; @@ -1232,7 +1230,7 @@ pub async fn run_main( let log_db = state_db.clone().map(log_db::start); let log_db_layer = log_db .clone() - .map(|layer| layer.with_filter(Targets::new().with_default(Level::TRACE))); + .map(|layer| layer.with_filter(log_db::default_filter())); let _ = tracing_subscriber::registry() .with(tui_file_layer) From e79d72d75d479c1aceaf6a5853833e7ddad33c29 Mon Sep 17 00:00:00 2001 From: rka-oai Date: Mon, 22 Jun 2026 10:05:11 -0700 Subject: [PATCH 108/112] remove flag for image preparation (#29429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What - make Fjord's centralized response-item image preparation unconditional for new and resumed history - have local user images and `view_image` outputs always defer decoding and resizing to that path - retain `resize_all_images` as an ignored, removed compatibility key for released clients - delete the flag-off producer paths and obsolete policy-specific tests ## Why Centralized preparation is now the intended image path. Keeping the runtime feature checks also kept two image-processing implementations alive and allowed client config to select the legacy behavior. This is a clean replacement for #28975, rebuilt from the latest `main`. ## How `prepare_response_items` now runs whenever items enter history and whenever persisted history is reconstructed. Producers emit deferred image data, so malformed images become the existing model-visible placeholder instead of failing the session at the producer. ## Test plan - `just fmt` - `just fix -p codex-core -p codex-features` - `just test -p codex-features` — 52 passed - focused affected `codex-core` set — 20 passed - `just test -p codex-core handle_accepts_explicit_high_detail` — 1 passed - full `just test -p codex-core` attempt — 2,723 passed; 88 unrelated environment failures from read-only `~/.codex` SQLite state and unavailable integration helper binaries --- .../tests/suite/v2/dynamic_tools.rs | 8 +- .../tests/suite/v2/imagegen_extension.rs | 10 +- codex-rs/core/src/prompt_debug.rs | 2 +- codex-rs/core/src/session/mod.rs | 43 ++----- codex-rs/core/src/session/tests.rs | 14 +-- .../core/src/tools/handlers/view_image.rs | 28 +---- codex-rs/core/tests/suite/client.rs | 4 +- codex-rs/core/tests/suite/code_mode.rs | 20 +-- codex-rs/core/tests/suite/compact.rs | 2 +- .../core/tests/suite/extension_sandbox.rs | 10 +- codex-rs/core/tests/suite/image_rollout.rs | 2 +- codex-rs/core/tests/suite/model_switching.rs | 2 +- codex-rs/core/tests/suite/responses_lite.rs | 2 +- codex-rs/core/tests/suite/rmcp_client.rs | 5 - codex-rs/core/tests/suite/truncation.rs | 2 +- codex-rs/core/tests/suite/view_image.rs | 114 +----------------- codex-rs/features/src/lib.rs | 8 +- codex-rs/features/src/tests.rs | 17 +++ 18 files changed, 72 insertions(+), 221 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs index ef1cf86753..2adc2be0b2 100644 --- a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs +++ b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs @@ -38,6 +38,8 @@ use tempfile::TempDir; use tokio::time::timeout; use wiremock::MockServer; +const TINY_PNG_DATA_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; + // macOS and Windows Bazel CI can spend tens of seconds starting app-server // subprocesses or processing test RPCs under load. #[cfg(any(target_os = "macos", windows))] @@ -650,7 +652,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<( text: "dynamic-ok".to_string(), }, DynamicToolCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,AAA".to_string(), + image_url: TINY_PNG_DATA_URL.to_string(), }, ]; let content_items = response_content_items @@ -695,7 +697,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<( text: "dynamic-ok".to_string(), }, DynamicToolCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,AAA".to_string(), + image_url: TINY_PNG_DATA_URL.to_string(), }, ]) ); @@ -721,7 +723,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<( }, { "type": "input_image", - "image_url": "data:image/png;base64,AAA", + "image_url": TINY_PNG_DATA_URL, "detail": "high" } ]) diff --git a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs index 5c18aa8a4e..7a29bb3542 100644 --- a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs +++ b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs @@ -28,13 +28,13 @@ use wiremock::ResponseTemplate; use wiremock::matchers::method; use wiremock::matchers::path; -const RESULT: &str = "cG5n"; +const RESULT: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; const TINY_PNG_BYTES: &[u8] = &[ 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, - 0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2, 0, 0, 5, 0, 1, - 122, 94, 171, 63, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, + 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, 120, 156, 99, 248, 207, 192, 240, 31, 0, + 5, 0, 1, 255, 137, 153, 61, 29, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, ]; -const TINY_PNG_DATA_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII="; +const TINY_PNG_DATA_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; #[derive(Clone, Copy)] enum ImagegenTestMode { @@ -116,7 +116,7 @@ async fn standalone_image_generation_returns_saved_path_hint_to_model() -> Resul assert_eq!(status, "completed"); assert_eq!(revised_prompt.as_deref(), Some("paint a blue whale")); assert_eq!(result, RESULT); - assert_eq!(std::fs::read(&saved_path)?, b"png"); + assert_eq!(std::fs::read(&saved_path)?, TINY_PNG_BYTES); let requests = response_mock.requests(); assert_eq!(requests.len(), 2); diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index bd9289b770..307115c505 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -81,7 +81,7 @@ pub(crate) async fn build_prompt_input_from_session( .await; if !input.is_empty() { - let response_item = sess.response_item_from_user_input(turn_context.as_ref(), input); + let response_item = sess.response_item_from_user_input(input); sess.record_conversation_items(turn_context.as_ref(), std::slice::from_ref(&response_item)) .await; } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 42eb0c29e3..32fcfd09e2 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1372,17 +1372,11 @@ impl Session { } = self .reconstruct_history_from_rollout(turn_context, rollout_items) .await; - if turn_context - .config - .features - .enabled(Feature::ResizeAllImages) - { - // Keep the recorded rollout unchanged. Prepare its reconstructed history before - // installing it, so legacy images are processed once for this resume or fork and - // will be processed again if the rollout is reconstructed in a future session. - // This meets image resizing requirements without modifying persisted rollouts. - prepare_response_items(&mut history); - } + // Keep the recorded rollout unchanged. Prepare its reconstructed history before + // installing it, so legacy images are processed once for this resume or fork and + // will be processed again if the rollout is reconstructed in a future session. + // This meets image resizing requirements without modifying persisted rollouts. + prepare_response_items(&mut history); { let mut state = self.state.lock().await; state.replace_history(history, reference_context_item); @@ -2689,13 +2683,7 @@ impl Session { items: &'a [ResponseItem], ) -> Cow<'a, [ResponseItem]> { let mut items = Cow::Borrowed(items); - if turn_context - .config - .features - .enabled(Feature::ResizeAllImages) - { - prepare_response_items(items.to_mut()); - } + prepare_response_items(items.to_mut()); if turn_context.config.features.enabled(Feature::ItemIds) { Self::assign_missing_response_item_ids(items) } else { @@ -2734,23 +2722,10 @@ impl Session { items } - pub(crate) fn response_item_from_user_input( - &self, - turn_context: &TurnContext, - input: Vec, - ) -> ResponseItem { - let local_image_preparation = if turn_context - .config - .features - .enabled(Feature::ResizeAllImages) - { - LocalImagePreparation::Defer - } else { - LocalImagePreparation::Process - }; + pub(crate) fn response_item_from_user_input(&self, input: Vec) -> ResponseItem { ResponseItem::from(ResponseInputItem::from_user_input( input, - local_image_preparation, + LocalImagePreparation::Defer, )) } @@ -3666,7 +3641,7 @@ impl Session { // Persist the user message to history, but emit the turn item from `UserInput` so // UI-only `text_elements` are preserved. `ResponseItem::Message` does not carry // those spans, and `record_response_item_and_emit_turn_item` would drop them. - let response_item = self.response_item_from_user_input(turn_context, input.to_vec()); + let response_item = self.response_item_from_user_input(input.to_vec()); self.record_conversation_items(turn_context, std::slice::from_ref(&response_item)) .await; let mut user_message_item = UserMessageItem::new(input); diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index edd5ace9ed..5c56f03d02 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1694,12 +1694,11 @@ async fn record_initial_history_reconstructs_resumed_transcript() { } #[tokio::test] -async fn resize_all_images_prepares_failures_before_history_insertion() { +async fn prepares_image_failures_before_history_insertion() { let (session, turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx( CodexAuth::from_api_key("Test API Key"), Vec::new(), |config| { - let _ = config.features.enable(Feature::ResizeAllImages); let _ = config.features.enable(Feature::ItemIds); }, ) @@ -1764,15 +1763,8 @@ async fn resize_all_images_prepares_failures_before_history_insertion() { } #[tokio::test] -async fn resize_all_images_prepares_resumed_history_before_installing_it() { - let (session, _turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx( - CodexAuth::from_api_key("Test API Key"), - Vec::new(), - |config| { - let _ = config.features.enable(Feature::ResizeAllImages); - }, - ) - .await; +async fn prepares_resumed_history_before_installing_it() { + let (session, _turn_context) = make_session_and_context().await; let resumed_item = ResponseItem::Message { id: None, role: "user".to_string(), diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index 84911fb815..09a6844f19 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -1,4 +1,3 @@ -use codex_features::Feature; use codex_protocol::items::ImageViewItem; use codex_protocol::items::TurnItem; use codex_protocol::models::DEFAULT_IMAGE_DETAIL; @@ -8,9 +7,7 @@ use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ImageDetail; use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::InputModality; -use codex_utils_image::PromptImageMode; use codex_utils_image::data_url_from_bytes; -use codex_utils_image::load_for_prompt_bytes; use serde::Deserialize; use crate::function_tool::FunctionCallError; @@ -195,24 +192,8 @@ impl ViewImageHandler { DEFAULT_IMAGE_DETAIL }; - let image_url = if turn.config.features.enabled(Feature::ResizeAllImages) { - // The history insertion path owns image decoding and resizing when this is enabled. - data_url_from_bytes("application/octet-stream", &file_bytes) - } else { - let image_mode = if use_original_detail { - PromptImageMode::Original - } else { - PromptImageMode::ResizeToFit - }; - load_for_prompt_bytes(abs_path.as_path(), file_bytes, image_mode) - .map_err(|error| { - FunctionCallError::RespondToModel(format!( - "unable to process image at `{}`: {error}", - abs_path.display() - )) - })? - .into_data_url() - }; + // The history insertion path owns image decoding and resizing. + let image_url = data_url_from_bytes("application/octet-stream", &file_bytes); let item = TurnItem::ImageView(ImageViewItem { id: call_id, @@ -420,9 +401,6 @@ mod tests { }) .await; - let Err(FunctionCallError::RespondToModel(message)) = result else { - panic!("expected image processing error"); - }; - assert!(message.contains("unable to process image"), "{message}"); + result.expect("explicit high detail should be accepted"); } } diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index f9b2fe42ef..3c905351cc 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -860,7 +860,7 @@ async fn resume_replays_legacy_js_repl_image_rollout_shapes() { async fn resume_replays_image_tool_outputs_with_detail() { skip_if_no_network!(); - let image_url = "data:image/webp;base64,UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEAAUAmJaACdLoB+AADsAD+8ut//NgVzXPv9//S4P0uD9Lg/9KQAAA="; + let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; let function_call_id = "view-image-call"; let custom_call_id = "js-repl-call"; let thread_id = ThreadId::default(); @@ -888,7 +888,7 @@ async fn resume_replays_image_tool_outputs_with_detail() { id: None, name: "view_image".to_string(), namespace: None, - arguments: "{\"path\":\"/tmp/example.webp\"}".to_string(), + arguments: "{\"path\":\"/tmp/example.png\"}".to_string(), call_id: function_call_id.to_string(), metadata: None, }), diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 0a765a0e26..14bee0fb8d 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2681,7 +2681,7 @@ async fn code_mode_can_output_images_via_global_helper() -> Result<()> { &server, "use exec to return images", r#" -image("data:image/png;base64,AAA"); +image("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="); "#, ) .await?; @@ -2706,7 +2706,7 @@ image("data:image/png;base64,AAA"); items[1], serde_json::json!({ "type": "input_image", - "image_url": "data:image/png;base64,AAA", + "image_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", "detail": "high" }), ); @@ -2715,17 +2715,14 @@ image("data:image/png;base64,AAA"); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn resize_all_images_replaces_malformed_code_mode_image() -> Result<()> { +async fn code_mode_replaces_malformed_image() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; - let (_test, second_mock) = run_code_mode_turn_with_config( + let (_test, second_mock) = run_code_mode_turn( &server, "use exec to return an image", r#"image("data:image/png;base64,AAA");"#, - |config| { - let _ = config.features.enable(Feature::ResizeAllImages); - }, ) .await?; @@ -2746,7 +2743,7 @@ async fn resize_all_images_replaces_malformed_code_mode_image() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn resize_all_images_resizes_explicit_original_code_mode_image() -> Result<()> { +async fn code_mode_resizes_explicit_original_image() -> Result<()> { skip_if_no_network!(Ok(())); let original_dimensions = (6401, 100); @@ -2772,12 +2769,7 @@ async fn resize_all_images_resizes_explicit_original_code_mode_image() -> Result "use exec to return a large original-detail image", &code, "gpt-5.3-codex", - |config| { - config - .features - .enable(Feature::ResizeAllImages) - .expect("resize_all_images should be enabled"); - }, + |_| {}, ) .await?; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index e40a13dcd4..fb20603850 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -4403,7 +4403,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess ) .await .expect("override thread settings"); - let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=" + let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" .to_string(); codex .submit(Op::UserInput { diff --git a/codex-rs/core/tests/suite/extension_sandbox.rs b/codex-rs/core/tests/suite/extension_sandbox.rs index e4c01557de..aa835fa9ac 100644 --- a/codex-rs/core/tests/suite/extension_sandbox.rs +++ b/codex-rs/core/tests/suite/extension_sandbox.rs @@ -42,9 +42,11 @@ use wiremock::matchers::path; const TINY_PNG_BYTES: &[u8] = &[ 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, - 0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2, 0, 0, 5, 0, 1, - 122, 94, 171, 63, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, + 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, 120, 156, 99, 248, 207, 192, 240, 31, 0, + 5, 0, 1, 255, 137, 153, 61, 29, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, ]; +const TINY_PNG_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; +const TINY_PNG_DATA_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; fn image_generation_extensions(auth: &CodexAuth) -> Arc> { let auth_manager = codex_core::test_support::auth_manager_from_auth(auth.clone()); @@ -148,7 +150,7 @@ async fn extension_tool_uses_granted_turn_permissions() -> Result<()> { .and(path("/v1/images/edits")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "created": 1, - "data": [{"b64_json": "cG5n"}], + "data": [{"b64_json": TINY_PNG_BASE64}], }))) .expect(1) .mount(&server) @@ -299,7 +301,7 @@ async fn extension_tool_uses_granted_turn_permissions() -> Result<()> { let output = request.function_call_output(image_call_id); let image = &output["output"][0]; assert_eq!(image["type"], "input_image"); - assert_eq!(image["image_url"], "data:image/png;base64,cG5n"); + assert_eq!(image["image_url"], TINY_PNG_DATA_URL); Ok(()) } diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 2b18559075..c97e8cc896 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -200,7 +200,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> .. } = test_codex().build(&server).await?; - let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=".to_string(); + let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==".to_string(); let response = sse(vec![ ev_response_created("resp-1"), diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index ef7a305bd7..4688434a4d 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -506,7 +506,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< let _ = models_manager .list_models(RefreshStrategy::OnlineIfUncached) .await; - let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=" + let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" .to_string(); test.codex diff --git a/codex-rs/core/tests/suite/responses_lite.rs b/codex-rs/core/tests/suite/responses_lite.rs index b137d5bc73..db4faa675e 100644 --- a/codex-rs/core/tests/suite/responses_lite.rs +++ b/codex-rs/core/tests/suite/responses_lite.rs @@ -55,7 +55,7 @@ fn has_hosted_tool(tools: &[Value], tool_type: &str) -> bool { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn responses_lite_strips_data_image_detail_without_resize_all_images() -> Result<()> { +async fn responses_lite_strips_data_image_detail() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index dde226a194..2b4e09bd1c 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -24,7 +24,6 @@ use codex_core::config::Config; use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::Environment; use codex_exec_server::HttpRequestParams; -use codex_features::Feature; use codex_login::CodexAuth; use codex_mcp::MCP_SANDBOX_STATE_META_CAPABILITY; use codex_models_manager::manager::RefreshStrategy; @@ -1455,10 +1454,6 @@ async fn stdio_image_responses_resize_large_image() -> anyhow::Result<()> { let rmcp_test_server_bin = remote_aware_stdio_server_bin()?; let fixture = test_codex() .with_config(move |config| { - config - .features - .enable(Feature::ResizeAllImages) - .expect("resize_all_images should be enabled"); insert_mcp_server( config, server_name, diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index cc942cbc17..c7e296ec0c 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -474,7 +474,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { let rmcp_test_server_bin = stdio_server_bin()?; // 1x1 PNG data URL - let openai_png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/ee9bQAAAABJRU5ErkJggg=="; + let openai_png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; let mut builder = test_codex().with_config(move |config| { let mut servers = config.mcp_servers.get().clone(); diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 1857eedefd..c2c7b562d8 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -7,7 +7,6 @@ use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_exec_server::REMOTE_ENVIRONMENT_ID; use codex_exec_server::RemoveOptions; -use codex_features::Feature; use codex_login::CodexAuth; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::PermissionProfile; @@ -181,15 +180,10 @@ async fn write_workspace_png( async fn assert_user_turn_local_image_resizes_to( original_dimensions: (u32, u32), expected_dimensions: (u32, u32), - resize_policy: TestImageResizePolicy, ) -> anyhow::Result<()> { let server = start_mock_server().await; - let mut builder = test_codex().with_config(move |config| { - if resize_policy == TestImageResizePolicy::AllImages { - let _ = config.features.enable(Feature::ResizeAllImages); - } - }); + let mut builder = test_codex(); let test = builder.build_with_remote_env(&server).await?; let TestCodex { codex, @@ -263,42 +257,25 @@ async fn assert_user_turn_local_image_resizes_to( Ok(()) } -#[derive(Clone, Copy, Eq, PartialEq)] -enum TestImageResizePolicy { - Legacy, - AllImages, -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); - assert_user_turn_local_image_resizes_to((2304, 864), (2048, 768), TestImageResizePolicy::Legacy) - .await + assert_user_turn_local_image_resizes_to((2304, 864), (2048, 768)).await } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn user_turn_with_vertical_local_image_resizes_to_square_bounds() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); - assert_user_turn_local_image_resizes_to( - (1024, 4096), - (512, 2048), - TestImageResizePolicy::Legacy, - ) - .await + assert_user_turn_local_image_resizes_to((1024, 4096), (512, 2048)).await } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn resize_all_images_applies_patch_budget_to_local_user_image() -> anyhow::Result<()> { +async fn user_turn_local_image_applies_patch_budget() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); - assert_user_turn_local_image_resizes_to( - (2048, 2048), - (1600, 1600), - TestImageResizePolicy::AllImages, - ) - .await + assert_user_turn_local_image_resizes_to((2048, 2048), (1600, 1600)).await } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -1209,11 +1186,10 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { +async fn view_image_tool_turns_invalid_image_into_placeholder() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mut builder = test_codex(); let test = builder.build_with_remote_env(&server).await?; let TestCodex { @@ -1222,84 +1198,6 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { .. } = &test; - let rel_path = "assets/example.json"; - let abs_path = - write_workspace_file(&test, rel_path, br#"{ "message": "hello" }"#.to_vec()).await?; - - let call_id = "view-image-non-image"; - let arguments = serde_json::json!({ "path": rel_path }).to_string(); - - let first_response = sse(vec![ - ev_response_created("resp-1"), - ev_function_call(call_id, "view_image", &arguments), - ev_completed("resp-1"), - ]); - responses::mount_sse_once(&server, first_response).await; - - let second_response = sse(vec![ - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]); - let mock = responses::mount_sse_once(&server, second_response).await; - - let session_model = session_configured.model.clone(); - - codex - .submit(disabled_user_turn( - &test, - vec![UserInput::Text { - text: "please use the view_image tool to read the json file".into(), - text_elements: Vec::new(), - }], - session_model, - )) - .await?; - - wait_for_event_with_timeout( - codex, - |event| matches!(event, EventMsg::TurnComplete(_)), - VIEW_IMAGE_TURN_COMPLETE_TIMEOUT, - ) - .await; - - let request = mock.single_request(); - assert!( - request.inputs_of_type("input_image").is_empty(), - "non-image file should not produce an input_image message" - ); - let (error_text, success) = request - .function_call_output_content_and_success(call_id) - .expect("function_call_output should be present"); - assert_eq!(success, None); - let error_text = error_text.expect("error text present"); - - let expected_error = format!( - "unable to process image at `{}`: unsupported image `application/json`", - abs_path.display() - ); - assert!( - error_text.contains(&expected_error), - "error should describe unsupported file type: {error_text}" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn resize_all_images_turns_invalid_view_image_into_placeholder() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let mut builder = test_codex().with_config(|config| { - let _ = config.features.enable(Feature::ResizeAllImages); - }); - let test = builder.build_with_remote_env(&server).await?; - let TestCodex { - codex, - session_configured, - .. - } = &test; - let rel_path = "assets/invalid-image.json"; write_workspace_file(&test, rel_path, br#"{ "message": "hello" }"#.to_vec()).await?; let call_id = "view-image-invalid-placeholder"; diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 75808e49d0..933ec73c60 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -193,7 +193,7 @@ pub enum Feature { ImageGeneration, /// Replace hosted image generation with the standalone image-generation extension. ImageGenExt, - /// Resize all inline data-URL images before recording them in history. + /// Removed compatibility flag for always-on centralized image preparation. ResizeAllImages, /// Generate Responses API item IDs for client-created history items. ItemIds, @@ -472,7 +472,7 @@ impl Features { "tool_search" | "apps_mcp_path_override" => { continue; } - "image_detail_original" => { + "image_detail_original" | "resize_all_images" => { continue; } "plugin_hooks" => { @@ -1160,8 +1160,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::ResizeAllImages, key: "resize_all_images", - stage: Stage::UnderDevelopment, - default_enabled: false, + stage: Stage::Removed, + default_enabled: true, }, FeatureSpec { id: Feature::ItemIds, diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 476c80aedc..b9902320bb 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -476,6 +476,23 @@ fn from_sources_ignores_removed_image_detail_original_feature_key() { assert_eq!(features, Features::with_defaults()); } +#[test] +fn from_sources_ignores_removed_resize_all_images_feature_key() { + let features_toml = + FeaturesToml::from(BTreeMap::from([("resize_all_images".to_string(), false)])); + + let features = Features::from_sources( + FeatureConfigSource { + features: Some(&features_toml), + ..Default::default() + }, + FeatureConfigSource::default(), + FeatureOverrides::default(), + ); + + assert_eq!(features, Features::with_defaults()); +} + #[test] fn from_sources_ignores_removed_undo_feature_key() { let features_toml = FeaturesToml::from(BTreeMap::from([("undo".to_string(), true)])); From c1f8b280b5cfc280c75682cab1b5be4a4dc58709 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Mon, 22 Jun 2026 10:26:30 -0700 Subject: [PATCH 109/112] ci: restore custom Windows runner with hermetic LLVM 0.7.9 (#29143) The custom Windows argument-comment-lint job was temporarily moved to `windows-2022` in #28940 after hermetic LLVM source extraction failed on the newer runner. This takes the upstream extraction fix so the job can return to the intended custom runner. This upgrades `llvm` to `0.7.9` and `rules_cc` to `0.2.18`, refreshes the module lock, rebases the remaining Windows and custom libc++ patches, drops the obsolete symlink-extraction workaround, and restores the `windows-x64` runner configuration. Validation: - Verified all LLVM patches apply cleanly against the `0.7.9` source. - Built `@llvm-project//compiler-rt:clang_rt.builtins.static`. --- .github/workflows/rust-ci.yml | 5 ++- MODULE.bazel | 11 +++--- MODULE.bazel.lock | 12 ++++-- patches/BUILD.bazel | 2 +- patches/llvm_windows_arm64_powl.patch | 13 ++++--- ....patch => llvm_windows_mingw_compat.patch} | 38 ++++++------------- 6 files changed, 37 insertions(+), 44 deletions(-) rename patches/{llvm_windows_symlink_extract.patch => llvm_windows_mingw_compat.patch} (51%) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 59a7820151..4cf6d6e37a 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -173,8 +173,11 @@ jobs: runner: macos-15-xlarge timeout_minutes: 30 - name: Windows - runner: windows-2022 + runner: windows-x64 timeout_minutes: 30 + runs_on: + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-x64 steps: - name: Check whether argument comment lint should run id: argument_comment_lint_gate diff --git a/MODULE.bazel b/MODULE.bazel index e9ec662d09..a24f93afd8 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -2,18 +2,17 @@ module(name = "codex") bazel_dep(name = "bazel_skylib", version = "1.9.0") bazel_dep(name = "platforms", version = "1.0.0") -bazel_dep(name = "llvm", version = "0.7.1") +bazel_dep(name = "llvm", version = "0.7.9") -# The upstream LLVM archive contains a few unix-only symlink entries and is -# missing a couple of MinGW compatibility archives that windows-gnullvm needs -# during extraction and linking, so patch it until upstream grows native support. +# Patch hermetic LLVM for Codex's custom libc++ and Windows gnullvm runtime +# needs that have not landed upstream. single_version_override( module_name = "llvm", patch_strip = 1, patches = [ "//patches:llvm_rusty_v8_custom_libcxx.patch", "//patches:llvm_windows_arm64_powl.patch", - "//patches:llvm_windows_symlink_extract.patch", + "//patches:llvm_windows_mingw_compat.patch", ], ) @@ -80,7 +79,7 @@ use_repo(osx, "macos_sdk") # Needed to disable xcode... bazel_dep(name = "apple_support", version = "2.1.0") -bazel_dep(name = "rules_cc", version = "0.2.16") +bazel_dep(name = "rules_cc", version = "0.2.18") single_version_override( module_name = "rules_cc", patch_strip = 1, diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index be8d988f5d..1826001540 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -45,6 +45,7 @@ "https://bcr.bazel.build/modules/bazel_features/1.34.0/MODULE.bazel": "e8475ad7c8965542e0c7aac8af68eb48c4af904be3d614b6aa6274c092c2ea1e", "https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7", "https://bcr.bazel.build/modules/bazel_features/1.42.0/MODULE.bazel": "e8ca15cb2639c5f12183db6dcb678735555d0cdd739b32a0418b6532b5e565f8", + "https://bcr.bazel.build/modules/bazel_features/1.43.0/MODULE.bazel": "defa2226f06ba20550d6548c3a2ea2a7929634437a52973869c20c225450eb91", "https://bcr.bazel.build/modules/bazel_features/1.45.0/MODULE.bazel": "7daec6d87ab0703417486d4cb948af0b06f55d4d7c08cbb5978c80e79b538edf", "https://bcr.bazel.build/modules/bazel_features/1.45.0/source.json": "635e4536e09ff125b8972e0fa239c135fde5f18701f7d5115680560651dfb41d", "https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b", @@ -63,6 +64,7 @@ "https://bcr.bazel.build/modules/bazel_skylib/1.6.1/MODULE.bazel": "8fdee2dbaace6c252131c00e1de4b165dc65af02ea278476187765e1a617b917", "https://bcr.bazel.build/modules/bazel_skylib/1.7.0/MODULE.bazel": "0db596f4563de7938de764cc8deeabec291f55e8ec15299718b93c4423e9796d", "https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b", + "https://bcr.bazel.build/modules/bazel_skylib/1.8.0/MODULE.bazel": "2fb3fb53675f6adfc1ca5bfbd5cfb655ae350fba4706d924a8ec7e3ba945671c", "https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6", "https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67", "https://bcr.bazel.build/modules/bazel_skylib/1.9.0/MODULE.bazel": "72997b29dfd95c3fa0d0c48322d05590418edef451f8db8db5509c57875fb4b7", @@ -87,8 +89,8 @@ "https://bcr.bazel.build/modules/libcap/2.27.bcr.1/source.json": "3b116cbdbd25a68ffb587b672205f6d353a4c19a35452e480d58fc89531e0a10", "https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902", "https://bcr.bazel.build/modules/llvm/0.7.0/MODULE.bazel": "3c07a4e5734b0ad41fe24dedaacbf3a35ce4377b7e1a21f24488a0c9ac4f1e6b", - "https://bcr.bazel.build/modules/llvm/0.7.1/MODULE.bazel": "74ac75efc6385b8a95d83bfa36ad399500f747c3d0f50287f9b6f9e854ec4814", - "https://bcr.bazel.build/modules/llvm/0.7.1/source.json": "0cac59d04dafa0ca1f70ea21cf1569e034ef8736c2c7510e834a9e8141d7e631", + "https://bcr.bazel.build/modules/llvm/0.7.9/MODULE.bazel": "452b272a6b5ecc8747db8e5b7fd9d6799ef0db17b8d437aa8d4dda4f56a3e9ea", + "https://bcr.bazel.build/modules/llvm/0.7.9/source.json": "0c098ab546a8c4b0a720dcb010714e13f9ba6ceab0d23a87b7f1083788928c31", "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/MODULE.bazel": "6f7b417dcc794d9add9e556673ad25cb3ba835224290f4f848f8e2db1e1fca74", "https://bcr.bazel.build/modules/openssl/3.5.4.bcr.0/MODULE.bazel": "0f6b8f20b192b9ff0781406256150bcd46f19e66d807dcb0c540548439d6fc35", "https://bcr.bazel.build/modules/openssl/3.5.4.bcr.0/source.json": "543ed7627cc18e6460b9c1ae4a1b6b1debc5a5e0aca878b00f7531c7186b73da", @@ -143,7 +145,8 @@ "https://bcr.bazel.build/modules/rules_cc/0.2.13/MODULE.bazel": "eecdd666eda6be16a8d9dc15e44b5c75133405e820f620a234acc4b1fdc5aa37", "https://bcr.bazel.build/modules/rules_cc/0.2.14/MODULE.bazel": "353c99ed148887ee89c54a17d4100ae7e7e436593d104b668476019023b58df8", "https://bcr.bazel.build/modules/rules_cc/0.2.16/MODULE.bazel": "9242fa89f950c6ef7702801ab53922e99c69b02310c39fb6e62b2bd30df2a1d4", - "https://bcr.bazel.build/modules/rules_cc/0.2.16/source.json": "d03d5cde49376d87e14ec14b666c56075e5e3926930327fd5d0484a1ff2ac1cc", + "https://bcr.bazel.build/modules/rules_cc/0.2.18/MODULE.bazel": "4460ec36adc8f722a6a2a4ac9374cb91f2acebadaa93fc37966129afb3dece87", + "https://bcr.bazel.build/modules/rules_cc/0.2.18/source.json": "abad668ff2fd63ada1ac49bf386d37e27048b89a3465a6fd968bb832b00a09d3", "https://bcr.bazel.build/modules/rules_cc/0.2.4/MODULE.bazel": "1ff1223dfd24f3ecf8f028446d4a27608aa43c3f41e346d22838a4223980b8cc", "https://bcr.bazel.build/modules/rules_cc/0.2.8/MODULE.bazel": "f1df20f0bf22c28192a794f29b501ee2018fa37a3862a1a2132ae2940a23a642", "https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6", @@ -221,8 +224,9 @@ "https://bcr.bazel.build/modules/stardoc/0.7.2/source.json": "58b029e5e901d6802967754adf0a9056747e8176f017cfe3607c0851f4d42216", "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/MODULE.bazel": "5e463fbfba7b1701d957555ed45097d7f984211330106ccd1352c6e0af0dcf91", "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/MODULE.bazel": "75aab2373a4bbe2a1260b9bf2a1ebbdbf872d3bd36f80bff058dccd82e89422f", + "https://bcr.bazel.build/modules/tar.bzl/0.10.4/MODULE.bazel": "e8f9ff79199e8d9eaad7f1b0a77ad74b30bb82d794b87d8ca942bead5de83ae9", + "https://bcr.bazel.build/modules/tar.bzl/0.10.4/source.json": "20143442376c03426f6135292ba02d825cb75308aa47e6bf42dd4cc5a435c2ff", "https://bcr.bazel.build/modules/tar.bzl/0.9.0/MODULE.bazel": "452a22d7f02b1c9d7a22ab25edf20f46f3e1101f0f67dc4bfbf9a474ddf02445", - "https://bcr.bazel.build/modules/tar.bzl/0.9.0/source.json": "c732760a374831a2cf5b08839e4be75017196b4d796a5aa55235272ee17cd839", "https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43", "https://bcr.bazel.build/modules/with_cfg.bzl/0.12.0/MODULE.bazel": "b573395fe63aef4299ba095173e2f62ccfee5ad9bbf7acaa95dba73af9fc2b38", "https://bcr.bazel.build/modules/with_cfg.bzl/0.12.0/source.json": "3f3fbaeafecaf629877ad152a2c9def21f8d330d91aa94c5dc75bbb98c10b8b8", diff --git a/patches/BUILD.bazel b/patches/BUILD.bazel index a431c3f3a6..ed14137170 100644 --- a/patches/BUILD.bazel +++ b/patches/BUILD.bazel @@ -6,7 +6,7 @@ exports_files([ "bzip2_windows_stack_args.patch", "llvm_rusty_v8_custom_libcxx.patch", "llvm_windows_arm64_powl.patch", - "llvm_windows_symlink_extract.patch", + "llvm_windows_mingw_compat.patch", "rules_rust_windows_bootstrap_process_wrapper_linker.patch", "rules_rust_windows_build_script_runner_paths.patch", "rules_rust_windows_exec_bin_target.patch", diff --git a/patches/llvm_windows_arm64_powl.patch b/patches/llvm_windows_arm64_powl.patch index 5e12bd7d3b..0fd33c9766 100644 --- a/patches/llvm_windows_arm64_powl.patch +++ b/patches/llvm_windows_arm64_powl.patch @@ -15,10 +15,11 @@ diff --git a/runtimes/mingw/crt_sources.bzl b/runtimes/mingw/crt_sources.bzl diff --git a/toolchain/args/windows/BUILD.bazel b/toolchain/args/windows/BUILD.bazel --- a/toolchain/args/windows/BUILD.bazel +++ b/toolchain/args/windows/BUILD.bazel -@@ -49,5 +49,6 @@ cc_args( +@@ -45,5 +45,6 @@ cc_args( + args = [ + "-L{mingw_import_library_search_path}", + "-L{mingw_crt_library_search_path}", +- + "-lmingwex", - # For now, we force on ucrt. - # TODO(cerisier): make this either a constraint or a build setting - "-lucrt", - ], - data = [ ++ + # Clang will respect the user's chosen CRT variant. diff --git a/patches/llvm_windows_symlink_extract.patch b/patches/llvm_windows_mingw_compat.patch similarity index 51% rename from patches/llvm_windows_symlink_extract.patch rename to patches/llvm_windows_mingw_compat.patch index 9f548636b2..66cf4e7b34 100644 --- a/patches/llvm_windows_symlink_extract.patch +++ b/patches/llvm_windows_mingw_compat.patch @@ -1,26 +1,11 @@ -diff --git a/extensions/llvm_source.bzl b/extensions/llvm_source.bzl -index 89dcf81..cf27c92 100644 ---- a/extensions/llvm_source.bzl -+++ b/extensions/llvm_source.bzl -@@ -83,6 +83,13 @@ def _llvm_source_archive_excludes(): - "offload", - "libc/docs", - "libc/utils/gn", -+ # These entries are unix symlinks in the upstream source tarball. -+ # Windows tar.exe cannot materialize them during repository extraction, -+ # but nothing in our Bazel usage needs the symlink entrypoints. -+ "llvm/utils/mlgo-utils/extract_ir.py", -+ "llvm/utils/mlgo-utils/make_corpus.py", -+ "llvm/utils/mlgo-utils/combine_training_corpus.py", -+ "llvm/docs/_themes/llvm-theme", - ] - - test_docs_subprojects = [ +# What: provide MinGW compatibility archives required by Windows gnullvm links. +# Why: clang and prebuilt MSVC archives can request these names even though the +# hermetic MinGW runtime does not publish them by default. + diff --git a/runtimes/mingw/BUILD.bazel b/runtimes/mingw/BUILD.bazel -index ebd99db..9eb5d5b 100644 --- a/runtimes/mingw/BUILD.bazel +++ b/runtimes/mingw/BUILD.bazel -@@ -334,6 +334,30 @@ stub_library( +@@ -386,14 +386,42 @@ stub_library( name = "stdc++", ) @@ -51,14 +36,15 @@ index ebd99db..9eb5d5b 100644 copy_to_directory( name = "mingw_crt_library_search_directory", srcs = [ -@@ -344,6 +364,10 @@ copy_to_directory( ++ ":libcmt", + ":m", + ":mingw32", + ":mingwex", ":moldname", + ":oldnames", ":pthread", - ":stdc++", + ":ssp", + ":ssp_nonshared", -+ ":libcmt", - ":ucrt", - ":ucrtbase", - ":ucrtbased", + ":stdc++", + ":uuid", + ":winpthread", From 44dbae90eb5a2e3e047d9636bf2463f3beb00fa8 Mon Sep 17 00:00:00 2001 From: jameswt-oai Date: Mon, 22 Jun 2026 10:27:23 -0700 Subject: [PATCH 110/112] [codex] Centralize Plugin Analytics Metadata (#27102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR moves construction of `PluginTelemetryMetadata` from loader and model helpers into `PluginsManager`, which already owns installed plugin state and will eventually perform remote identity enrichment. The metadata type remains in `codex-plugin`, and serialized analytics events remain unchanged. ## Before ```mermaid flowchart LR subgraph Events["Analytics event paths"] direction TB Lifecycle["Local install / uninstall"] Config["Enable / disable"] Remote["Remote install"] Used["Plugin used"] end subgraph Construction["Metadata construction"] direction TB Loader["Loader telemetry helpers"] Summary["PluginCapabilitySummary::telemetry_metadata"] Override["Caller adds remote_plugin_id"] end Metadata["PluginTelemetryMetadata"] Lifecycle --> Loader Config --> Loader Remote --> Loader Loader -->|"local events"| Metadata Loader -->|"remote install"| Override Override --> Metadata Used --> Summary Summary --> Metadata ``` Telemetry metadata was constructed through loader helpers, a capability-summary method, and a remote-install call-site override. ## After ```mermaid flowchart LR subgraph Events["Analytics event paths"] direction TB Lifecycle["Local install / uninstall"] Config["Enable / disable"] Remote["Remote install"] Used["Plugin used"] end Manager["PluginsManager — single construction owner"] Metadata["PluginTelemetryMetadata"] Lifecycle --> Manager Config --> Manager Remote -->|"authoritative remote ID"| Manager Used -->|"capability summary"| Manager Manager --> Metadata ``` Every analytics path delegates metadata construction to `PluginsManager`. Remote install still supplies its authoritative backend ID explicitly. ## What Changes - Make loader code return a focused plugin capability summary instead of constructing analytics metadata. - Centralize immutable plugin telemetry metadata construction in `PluginsManager`. - Route local install/uninstall, remote install, enable/disable, and plugin-used emitters through the manager. - Preserve the current serialized analytics contract exactly. Normal metadata still has no remote override. Remote install continues to provide its authoritative backend ID explicitly, so the existing serializer continues reporting that ID through `plugin_id`. Snapshot-based enrichment is intentionally deferred to the final PR. ## Testing - `just test -p codex-core-plugins` (238 tests passed) - `just test -p codex-plugin` (3 tests passed) - Scoped Clippy/compile checks passed for `codex-plugin`, `codex-core-plugins`, `codex-app-server`, and `codex-core`. ## Split Overview ```text main ├── #27093 Debug analytics capture (merged) ├── #27099 Non-mutating plugin smoke (merged) ├── #27100 Remote install/uninstall smoke (merged) └── #27102 Plugin telemetry metadata refactor ← you are here └── #27669 Persist remote plugin identity After #27102 and #27669 merge: └── Final PR: add explicit local and remote IDs to plugin analytics ``` Review order and dependencies: 1. [#27093 Add debug-only analytics event capture](https://github.com/openai/codex/pull/27093) (merged) 2. [#27099 Add a plugin analytics smoke workflow](https://github.com/openai/codex/pull/27099) (merged) 3. [#27100 Add a remote plugin analytics mutation smoke workflow](https://github.com/openai/codex/pull/27100) (merged) 4. This metadata refactor, independent and based on `main` 5. [#27669 Persist remote plugin identity](https://github.com/openai/codex/pull/27669), stacked on this PR 6. Final remote-ID behavior PR, created after the prerequisites merge The original [#26281](https://github.com/openai/codex/pull/26281) remains open as the aggregate reference until the final replacement PR is published. --- .../analytics/src/analytics_client_tests.rs | 9 +- codex-rs/app-server/src/request_processors.rs | 1 - .../request_processors/config_processor.rs | 9 +- .../src/request_processors/plugins.rs | 18 ++-- codex-rs/core-plugins/src/loader.rs | 47 +++------- codex-rs/core-plugins/src/manager.rs | 72 ++++++++++++++-- codex-rs/core-plugins/src/manager_tests.rs | 85 +++++++++++++++++-- codex-rs/core/src/session/turn.rs | 17 ++-- codex-rs/plugin/src/lib.rs | 22 ----- 9 files changed, 189 insertions(+), 91 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index a9a1d90e74..091a85e11f 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -3504,10 +3504,11 @@ async fn reducer_ingests_plugin_install_failed_fact() { async fn reducer_ingests_plugin_install_failed_fact_without_detail() { let mut reducer = AnalyticsReducer::default(); let mut events = Vec::new(); - let mut plugin = PluginTelemetryMetadata::from_plugin_id( - &PluginId::parse("unknown@openai-curated-remote").expect("valid plugin id"), - ); - plugin.remote_plugin_id = Some("plugins~Plugin_00000000000000000000000000000000".to_string()); + let plugin = PluginTelemetryMetadata { + plugin_id: PluginId::parse("unknown@openai-curated-remote").expect("valid plugin id"), + remote_plugin_id: Some("plugins~Plugin_00000000000000000000000000000000".to_string()), + capability_summary: None, + }; reducer .ingest( diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index c8bd48c474..2972d05f09 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -337,7 +337,6 @@ use codex_core_plugins::PluginUninstallError as CorePluginUninstallError; use codex_core_plugins::PluginsManager; use codex_core_plugins::loader::load_plugin_apps; use codex_core_plugins::loader::load_plugin_mcp_servers; -use codex_core_plugins::loader::plugin_telemetry_metadata_from_root; use codex_core_plugins::manifest::PluginManifestInterface; use codex_core_plugins::marketplace::MarketplaceError; use codex_core_plugins::marketplace::MarketplacePluginSource; diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 15c3b7c864..50bba54009 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -295,15 +295,14 @@ impl ConfigRequestProcessor { &self, pending_changes: std::collections::BTreeMap, ) { + let plugins_manager = self.thread_manager.plugins_manager(); for (plugin_id, enabled) in pending_changes { let Ok(plugin_id) = PluginId::parse(&plugin_id) else { continue; }; - let metadata = codex_core_plugins::loader::installed_plugin_telemetry_metadata( - self.config_manager.codex_home(), - &plugin_id, - ) - .await; + let metadata = plugins_manager + .telemetry_metadata_for_installed_plugin(&plugin_id) + .await; if enabled { self.analytics_events_client.track_plugin_enabled(metadata); } else { diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index e9bf044766..1e4716a88d 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -23,7 +23,6 @@ use codex_mcp::McpOAuthLoginSupport; use codex_mcp::oauth_login_support; use codex_mcp::should_retry_without_scopes; use codex_plugin::PluginId; -use codex_plugin::PluginTelemetryMetadata; use codex_rmcp_client::perform_oauth_login_silent; #[derive(Clone)] @@ -1620,9 +1619,14 @@ impl PluginRequestProcessor { Some(self.effective_plugins_changed_callback()), ); - let mut plugin_metadata = - plugin_telemetry_metadata_from_root(&result.plugin_id, &result.installed_path).await; - plugin_metadata.remote_plugin_id = Some(remote_plugin_id.clone()); + let plugin_metadata = self + .thread_manager + .plugins_manager() + .telemetry_metadata_for_installed_plugin_with_remote_id( + &result.plugin_id, + &remote_plugin_id, + ) + .await; self.analytics_events_client .track_plugin_installed(plugin_metadata); @@ -1713,8 +1717,10 @@ impl PluginRequestProcessor { else { return; }; - let mut plugin = PluginTelemetryMetadata::from_plugin_id(&plugin_id); - plugin.remote_plugin_id = Some(remote_plugin_id.to_string()); + let plugin = self + .thread_manager + .plugins_manager() + .telemetry_metadata_for_plugin_id_with_remote_id(&plugin_id, remote_plugin_id); self.analytics_events_client .track_plugin_install_failed(plugin, error_type.to_string()); } diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index 54eb371686..2a8d44fc97 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -38,7 +38,6 @@ use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginHookSource; use codex_plugin::PluginId; use codex_plugin::PluginIdError; -use codex_plugin::PluginTelemetryMetadata; use codex_plugin::app_connector_ids_from_declarations; use codex_protocol::protocol::Product; use codex_protocol::protocol::SkillScope; @@ -1164,13 +1163,11 @@ fn cleaned_app_category(category: Option) -> Option { .filter(|category| !category.is_empty()) } -pub async fn plugin_telemetry_metadata_from_root( +pub async fn plugin_capability_summary_from_root( plugin_id: &PluginId, plugin_root: &AbsolutePathBuf, -) -> PluginTelemetryMetadata { - let Some(manifest) = load_plugin_manifest(plugin_root.as_path()) else { - return PluginTelemetryMetadata::from_plugin_id(plugin_id); - }; +) -> Option { + let manifest = load_plugin_manifest(plugin_root.as_path())?; let manifest_paths = &manifest.paths; let has_skills = !plugin_skill_roots(plugin_root, manifest_paths).is_empty(); @@ -1192,18 +1189,14 @@ pub async fn plugin_telemetry_metadata_from_root( .await; let app_connector_ids = app_connector_ids_from_declarations(&app_declarations); - PluginTelemetryMetadata { - plugin_id: plugin_id.clone(), - remote_plugin_id: None, - capability_summary: Some(PluginCapabilitySummary { - config_name: plugin_id.as_key(), - display_name: plugin_id.plugin_name.clone(), - description: None, - has_skills, - mcp_server_names, - app_connector_ids, - }), - } + Some(PluginCapabilitySummary { + config_name: plugin_id.as_key(), + display_name: plugin_id.plugin_name.clone(), + description: None, + has_skills, + mcp_server_names, + app_connector_ids, + }) } pub async fn load_plugin_mcp_servers( @@ -1279,24 +1272,6 @@ pub(crate) async fn load_plugin_mcp_servers_from_manifest( mcp_servers } -pub async fn installed_plugin_telemetry_metadata( - codex_home: &Path, - plugin_id: &PluginId, -) -> PluginTelemetryMetadata { - let store = match PluginStore::try_new(codex_home.to_path_buf()) { - Ok(store) => store, - Err(err) => { - warn!("failed to resolve plugin cache root: {err}"); - return PluginTelemetryMetadata::from_plugin_id(plugin_id); - } - }; - let Some(plugin_root) = store.active_plugin_root(plugin_id) else { - return PluginTelemetryMetadata::from_plugin_id(plugin_id); - }; - - plugin_telemetry_metadata_from_root(plugin_id, &plugin_root).await -} - async fn load_mcp_servers_from_file( plugin_root: &Path, mcp_config_path: &AbsolutePathBuf, diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index b502106ea9..19b48b2c01 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -6,7 +6,6 @@ use crate::is_openai_curated_marketplace_name; use crate::loader::PluginHookLoadOutcome; use crate::loader::configured_curated_plugin_ids_from_codex_home; use crate::loader::curated_plugin_cache_version; -use crate::loader::installed_plugin_telemetry_metadata; use crate::loader::load_plugin_apps_from_manifest; use crate::loader::load_plugin_hooks; use crate::loader::load_plugin_hooks_from_layer_stack; @@ -15,7 +14,7 @@ use crate::loader::load_plugin_skills; use crate::loader::load_plugins_from_layer_stack; use crate::loader::log_plugin_load_errors; use crate::loader::materialize_marketplace_plugin_source; -use crate::loader::plugin_telemetry_metadata_from_root; +use crate::loader::plugin_capability_summary_from_root; use crate::loader::refresh_curated_plugin_cache; use crate::loader::refresh_non_curated_plugin_cache; use crate::loader::refresh_non_curated_plugin_cache_force_reinstall; @@ -709,6 +708,66 @@ impl PluginsManager { remote_installed_plugins_to_config(plugins, &self.store) } + pub async fn telemetry_metadata_for_installed_plugin( + &self, + plugin_id: &PluginId, + ) -> PluginTelemetryMetadata { + let mut metadata = self.telemetry_metadata_for_plugin_id(plugin_id); + metadata.capability_summary = match self.store.active_plugin_root(plugin_id) { + Some(plugin_root) => plugin_capability_summary_from_root(plugin_id, &plugin_root).await, + None => None, + }; + metadata + } + + pub async fn telemetry_metadata_for_installed_plugin_with_remote_id( + &self, + plugin_id: &PluginId, + remote_plugin_id: &str, + ) -> PluginTelemetryMetadata { + let mut metadata = + self.telemetry_metadata_for_plugin_id_with_remote_id(plugin_id, remote_plugin_id); + metadata.capability_summary = match self.store.active_plugin_root(plugin_id) { + Some(plugin_root) => plugin_capability_summary_from_root(plugin_id, &plugin_root).await, + None => None, + }; + metadata + } + + pub fn telemetry_metadata_for_plugin_id( + &self, + plugin_id: &PluginId, + ) -> PluginTelemetryMetadata { + PluginTelemetryMetadata { + plugin_id: plugin_id.clone(), + remote_plugin_id: None, + capability_summary: None, + } + } + + pub fn telemetry_metadata_for_plugin_id_with_remote_id( + &self, + plugin_id: &PluginId, + remote_plugin_id: &str, + ) -> PluginTelemetryMetadata { + PluginTelemetryMetadata { + remote_plugin_id: Some(remote_plugin_id.to_string()), + ..self.telemetry_metadata_for_plugin_id(plugin_id) + } + } + + pub fn telemetry_metadata_for_capability_summary( + &self, + summary: &PluginCapabilitySummary, + ) -> Option { + let plugin_id = PluginId::parse(&summary.config_name).ok()?; + Some(PluginTelemetryMetadata { + plugin_id, + remote_plugin_id: None, + capability_summary: Some(summary.clone()), + }) + } + pub fn build_remote_installed_plugin_marketplaces_from_cache( &self, visible_marketplaces: &[&str], @@ -1281,7 +1340,7 @@ impl PluginsManager { }; if let Some(analytics_events_client) = analytics_events_client { analytics_events_client.track_plugin_install_failed( - PluginTelemetryMetadata::from_plugin_id(plugin_id), + self.telemetry_metadata_for_plugin_id(plugin_id), error_type.to_string(), ); } @@ -1351,7 +1410,7 @@ impl PluginsManager { }; if let Some(analytics_events_client) = analytics_events_client { analytics_events_client.track_plugin_installed( - plugin_telemetry_metadata_from_root(&result.plugin_id, &result.installed_path) + self.telemetry_metadata_for_installed_plugin(&result.plugin_id) .await, ); } @@ -1392,7 +1451,10 @@ impl PluginsManager { async fn uninstall_plugin_id(&self, plugin_id: PluginId) -> Result<(), PluginUninstallError> { let plugin_telemetry = if self.store.active_plugin_root(&plugin_id).is_some() { - Some(installed_plugin_telemetry_metadata(self.codex_home.as_path(), &plugin_id).await) + Some( + self.telemetry_metadata_for_installed_plugin(&plugin_id) + .await, + ) } else { None }; diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 4f448ff160..50a5fe3e73 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -845,6 +845,81 @@ remote_plugin = true assert_eq!(outcome, PluginLoadOutcome::default()); } +#[tokio::test] +async fn installed_plugin_telemetry_metadata_collects_capabilities() { + let codex_home = TempDir::new().unwrap(); + write_cached_plugin(codex_home.path(), "test", "sample"); + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + let plugin_id = PluginId::parse("sample@test").expect("plugin id should parse"); + + let metadata = manager + .telemetry_metadata_for_installed_plugin(&plugin_id) + .await; + + assert_eq!( + metadata, + PluginTelemetryMetadata { + plugin_id, + remote_plugin_id: None, + capability_summary: Some(PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "sample".to_string(), + description: None, + has_skills: true, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + }), + } + ); +} + +#[tokio::test] +async fn installed_plugin_telemetry_metadata_accepts_authoritative_remote_identity() { + let codex_home = TempDir::new().unwrap(); + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + let plugin_id = + PluginId::parse("linear@openai-curated-remote").expect("plugin id should parse"); + + let metadata = manager + .telemetry_metadata_for_installed_plugin_with_remote_id(&plugin_id, "plugins~Plugin_linear") + .await; + + assert_eq!( + metadata, + PluginTelemetryMetadata { + plugin_id, + remote_plugin_id: Some("plugins~Plugin_linear".to_string()), + capability_summary: None, + } + ); +} + +#[test] +fn capability_summary_telemetry_metadata_uses_local_identity() { + let codex_home = TempDir::new().unwrap(); + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + let summary = PluginCapabilitySummary { + config_name: "linear@openai-curated-remote".to_string(), + display_name: "Linear".to_string(), + description: Some("Track work".to_string()), + has_skills: true, + mcp_server_names: vec!["linear".to_string()], + app_connector_ids: vec![AppConnectorId("linear-app".to_string())], + }; + + let metadata = manager.telemetry_metadata_for_capability_summary(&summary); + + assert_eq!( + metadata, + Some(PluginTelemetryMetadata { + plugin_id: PluginId::parse("linear@openai-curated-remote") + .expect("plugin id should parse"), + remote_plugin_id: None, + capability_summary: Some(summary), + }) + ); +} + #[tokio::test] async fn remote_installed_cache_prefers_local_curated_conflicts_when_remote_plugin_disabled() { let codex_home = TempDir::new().unwrap(); @@ -1154,14 +1229,14 @@ async fn plugin_telemetry_metadata_uses_default_mcp_config_path() { }"#, ); - let metadata = plugin_telemetry_metadata_from_root( + let summary = plugin_capability_summary_from_root( &PluginId::parse("sample@test").expect("plugin id should parse"), &plugin_root.abs(), ) .await; assert_eq!( - metadata.capability_summary, + summary, Some(PluginCapabilitySummary { config_name: "sample@test".to_string(), display_name: "sample".to_string(), @@ -1174,7 +1249,7 @@ async fn plugin_telemetry_metadata_uses_default_mcp_config_path() { } #[tokio::test] -async fn plugin_telemetry_metadata_uses_manifest_mcp_server_objects() { +async fn plugin_capability_summary_uses_manifest_mcp_server_objects() { let codex_home = TempDir::new().unwrap(); let plugin_root = codex_home .path() @@ -1195,14 +1270,14 @@ async fn plugin_telemetry_metadata_uses_manifest_mcp_server_objects() { }"#, ); - let metadata = plugin_telemetry_metadata_from_root( + let summary = plugin_capability_summary_from_root( &PluginId::parse("counter-sample@test").expect("plugin id should parse"), &plugin_root.abs(), ) .await; assert_eq!( - metadata.capability_summary, + summary, Some(PluginCapabilitySummary { config_name: "counter-sample@test".to_string(), display_name: "counter-sample".to_string(), diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 9b9c7cc66e..81f9f52356 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -656,13 +656,16 @@ async fn build_skills_and_plugins( sess.services .analytics_events_client .track_app_mentioned(tracking.clone(), mentioned_app_invocations); - for plugin in mentioned_plugins - .iter() - .filter_map(crate::plugins::PluginCapabilitySummary::telemetry_metadata) - { - sess.services - .analytics_events_client - .track_plugin_used(tracking.clone(), plugin); + for summary in &mentioned_plugins { + if let Some(plugin) = sess + .services + .plugins_manager + .telemetry_metadata_for_capability_summary(summary) + { + sess.services + .analytics_events_client + .track_plugin_used(tracking.clone(), plugin); + } } let mut injection_items: Vec = match injected_host_skill_prompts { diff --git a/codex-rs/plugin/src/lib.rs b/codex-rs/plugin/src/lib.rs index 7daa78bc06..1a4eb23af1 100644 --- a/codex-rs/plugin/src/lib.rs +++ b/codex-rs/plugin/src/lib.rs @@ -76,25 +76,3 @@ pub struct PluginTelemetryMetadata { pub remote_plugin_id: Option, pub capability_summary: Option, } - -impl PluginTelemetryMetadata { - pub fn from_plugin_id(plugin_id: &PluginId) -> Self { - Self { - plugin_id: plugin_id.clone(), - remote_plugin_id: None, - capability_summary: None, - } - } -} - -impl PluginCapabilitySummary { - pub fn telemetry_metadata(&self) -> Option { - PluginId::parse(&self.config_name) - .ok() - .map(|plugin_id| PluginTelemetryMetadata { - plugin_id, - remote_plugin_id: None, - capability_summary: Some(self.clone()), - }) - } -} From f6fa2593126d726fed67920e24eadc5c5c12bf0d Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Mon, 22 Jun 2026 10:30:16 -0700 Subject: [PATCH 111/112] TUI Plugin Sharing 3 - render remote plugin catalog sections (#26703) ## Summary [#26701](https://github.com/openai/codex/pull/26701) added remote plugin identity support, [#26702](https://github.com/openai/codex/pull/26702) added remote-section fetching and state, and [#28768](https://github.com/openai/codex/pull/28768) extracted the catalog rendering module. This PR builds the product-facing `/plugins` catalog on that foundation so remote records appear as OpenAI Curated, Workspace, and Shared with me sections rather than backend marketplace implementation details. Plugin details remain read-only for sharing metadata. This PR does not add share-authoring actions or change the app-server protocol. ## Changes - Renders OpenAI Curated, Workspace, and Shared with me sections with loading, empty, and error states. - Preserves section selection and stable tab ordering as remote sections transition between fallback and populated states. - Shows OpenAI Curated loading only when the explicit vertical fallback request was issued. - Centralizes remote marketplace identity matching around the existing marketplace constants. - Uses product labels for remote marketplaces and identifies the personal marketplace as Local by its path. - Shows read-only source, authentication, version, and sharing metadata in plugin detail views. - Applies narrow display deduplication for local and remote records sharing a remote plugin ID: - installed records take precedence; - local mapped sources are preferred for details only when their installed state matches the selected record. - Returns from detail and confirmation views through the current plugin cache so newly loaded remote sections are not overwritten by an older captured response. - Keeps admin-disabled plugins view-only and labels default-installed plugins as Available by default. ## Tests New tests: - `plugins_popup_admin_disabled_available_plugin_has_view_only_hint` - `plugins_popup_remote_section_fallback_states_snapshot` - `plugins_popup_installed_remote_row_keeps_remote_detail_when_local_share_is_uninstalled` Updated existing plugin catalog tests and snapshots for product labels, detail metadata, personal-marketplace labeling, and stable tab ordering. Verification: - `cargo clippy -p codex-tui --all-targets -- -D warnings` ## Follow-ups - Local/remote duplicate normalization should eventually move into app-server. This PR intentionally keeps the compatibility behavior narrow and display-only. - PR5 will sanitize sensitive components before displaying Git source URLs. --- codex-rs/tui/src/app/event_dispatch.rs | 3 + codex-rs/tui/src/app_event.rs | 6 + codex-rs/tui/src/chatwidget/plugin_catalog.rs | 807 +++++++++++++++--- codex-rs/tui/src/chatwidget/plugins.rs | 35 + ...ests__plugin_detail_popup_installable.snap | 4 +- ..._tests__plugin_detail_popup_installed.snap | 2 + ...ts__plugins_popup_curated_marketplace.snap | 8 +- ...ins_popup_newly_installed_marketplace.snap | 4 +- .../chatwidget/tests/popups_and_settings.rs | 284 +++++- 9 files changed, 1003 insertions(+), 150 deletions(-) diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 181e60d963..0830c69b0f 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -507,6 +507,9 @@ impl App { AppEvent::PluginsLoaded { cwd, result } => { self.chat_widget.on_plugins_loaded(cwd, result); } + AppEvent::OpenPluginsList { cwd, response } => { + self.chat_widget.open_plugins_list(cwd, response); + } AppEvent::PluginRemoteSectionsLoaded { cwd, marketplaces, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 643cd9e2d8..26686c3b53 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -450,6 +450,12 @@ pub(crate) enum AppEvent { result: Result, }, + /// Open the plugin list from an already cached response. + OpenPluginsList { + cwd: PathBuf, + response: PluginListResponse, + }, + /// Result of explicitly fetching remote-backed plugin sections. PluginRemoteSectionsLoaded { cwd: PathBuf, diff --git a/codex-rs/tui/src/chatwidget/plugin_catalog.rs b/codex-rs/tui/src/chatwidget/plugin_catalog.rs index c51e2e8ea9..a19ede3ab5 100644 --- a/codex-rs/tui/src/chatwidget/plugin_catalog.rs +++ b/codex-rs/tui/src/chatwidget/plugin_catalog.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::path::Path; use std::time::Duration; use std::time::Instant; @@ -9,6 +10,7 @@ use super::plugins::PLUGINS_SELECTION_VIEW_ID; use super::plugins::PluginsCacheState; use crate::app_event::AppEvent; use crate::app_event::PluginLocation; +use crate::app_event::PluginRemoteSectionError; use crate::bottom_pane::ColumnWidthMode; use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; @@ -24,18 +26,24 @@ use crate::onboarding::mark_url_hyperlink; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use crate::tui::FrameRequester; +use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginAvailability; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginMarketplaceEntry; +use codex_app_server_protocol::PluginShareContext; +use codex_app_server_protocol::PluginShareDiscoverability; +use codex_app_server_protocol::PluginSharePrincipal; use codex_app_server_protocol::PluginSource; use codex_app_server_protocol::PluginSummary; use codex_core_plugins::is_openai_curated_marketplace_name; +use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; use codex_core_plugins::remote::REMOTE_WORKSPACE_MARKETPLACE_NAME; use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME; use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME; use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME; +use codex_utils_absolute_path::AbsolutePathBuf; use crossterm::event::KeyCode; use ratatui::buffer::Buffer; use ratatui::layout::Rect; @@ -55,6 +63,179 @@ const PLUGIN_ROW_PREFIX_WIDTH: usize = 6; const LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1); const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100); const APPS_HELP_ARTICLE_URL: &str = "https://help.openai.com/en/articles/11487775-apps-in-chatgpt"; +const PERSONAL_MARKETPLACE_RELATIVE_PATH: &str = ".agents/plugins/marketplace.json"; +const REMOTE_LOADING_TAB_ID_PREFIX: &str = "remote-loading:"; +const REMOTE_EMPTY_TAB_ID_PREFIX: &str = "remote-empty:"; +const REMOTE_ERROR_TAB_ID_PREFIX: &str = "remote-error:"; +const WORKSPACE_SECTION_TAB_ORDER: u8 = 0; +const SHARED_WITH_ME_SECTION_TAB_ORDER: u8 = 1; +const SHARED_WITH_ME_LINK_SECTION_TAB_ORDER: u8 = 2; +const LOCAL_MARKETPLACE_TAB_ORDER: u8 = 3; +const OTHER_MARKETPLACE_TAB_ORDER: u8 = 4; + +#[derive(Debug, Clone)] +struct PreferredLocalPluginSource { + marketplace_path: AbsolutePathBuf, + plugin_name: String, + installed: bool, +} + +#[derive(Debug, Clone, Copy)] +enum MarketplaceProduct { + OpenAiCurated, + Workspace, + SharedWithMe, + SharedWithMeLink, + Local, + Other, +} + +impl MarketplaceProduct { + fn from_marketplace(marketplace: &PluginMarketplaceEntry) -> Self { + Self::from_marketplace_parts(&marketplace.name, marketplace.path.as_ref()) + } + + fn from_marketplace_parts( + marketplace_name: &str, + marketplace_path: Option<&AbsolutePathBuf>, + ) -> Self { + if marketplace_path.is_some_and(is_personal_marketplace_path) { + return Self::Local; + } + + Self::from_marketplace_name(marketplace_name) + } + + fn from_marketplace_name(marketplace_name: &str) -> Self { + if is_openai_curated_marketplace_name(marketplace_name) + || marketplace_name == REMOTE_GLOBAL_MARKETPLACE_NAME + { + return Self::OpenAiCurated; + } + + match marketplace_name { + REMOTE_WORKSPACE_MARKETPLACE_NAME => Self::Workspace, + REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME + | REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME => Self::SharedWithMe, + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME => Self::SharedWithMeLink, + _ => Self::Other, + } + } + + fn label(self) -> Option<&'static str> { + match self { + Self::OpenAiCurated => Some("OpenAI Curated"), + Self::Workspace => Some("Workspace"), + Self::SharedWithMe => Some("Shared with me"), + Self::SharedWithMeLink => Some("Shared with me (link)"), + Self::Local => Some("Local"), + Self::Other => None, + } + } + + fn tab_order(self) -> u8 { + match self { + Self::Workspace => WORKSPACE_SECTION_TAB_ORDER, + Self::SharedWithMe => SHARED_WITH_ME_SECTION_TAB_ORDER, + Self::SharedWithMeLink => SHARED_WITH_ME_LINK_SECTION_TAB_ORDER, + Self::Local => LOCAL_MARKETPLACE_TAB_ORDER, + Self::OpenAiCurated | Self::Other => OTHER_MARKETPLACE_TAB_ORDER, + } + } + + fn is_by_openai(self) -> bool { + matches!(self, Self::OpenAiCurated) + } +} + +#[derive(Debug, Clone, Copy)] +struct RemoteMarketplaceSection { + id: &'static str, + label: &'static str, + loading_tab_id: &'static str, + marketplace_names: &'static [&'static str], + empty_item_name: &'static str, + empty_item_description: &'static str, + tab_order: u8, +} + +const REMOTE_MARKETPLACE_SECTIONS: [RemoteMarketplaceSection; 2] = [ + RemoteMarketplaceSection { + id: "workspace", + label: "Workspace", + loading_tab_id: "workspace-loading", + marketplace_names: &[REMOTE_WORKSPACE_MARKETPLACE_NAME], + empty_item_name: "No workspace plugins available", + empty_item_description: "No workspace directory plugins are available.", + tab_order: WORKSPACE_SECTION_TAB_ORDER, + }, + RemoteMarketplaceSection { + id: "shared-with-me", + label: "Shared with me", + loading_tab_id: "shared-with-me-loading", + marketplace_names: &[ + REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME, + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME, + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME, + ], + empty_item_name: "No shared plugins available", + empty_item_description: "No plugins have been shared with you.", + tab_order: SHARED_WITH_ME_SECTION_TAB_ORDER, + }, +]; + +impl RemoteMarketplaceSection { + fn fallback_tab( + self, + marketplaces: &[PluginMarketplaceEntry], + remote_sections_loading: bool, + remote_sections_loaded: bool, + section_errors: &[PluginRemoteSectionError], + ) -> Option<(u8, SelectionTab)> { + if marketplaces + .iter() + .any(|marketplace| self.contains_marketplace(&marketplace.name)) + { + return None; + } + + let tab = if remote_sections_loading { + remote_section_loading_tab(self.loading_tab_id, self.label) + } else if remote_sections_loaded { + if let Some(section_error) = plugin_remote_section_error(section_errors, self.id) { + remote_section_error_tab(section_error) + } else { + remote_section_empty_tab( + self.id, + self.label, + self.empty_item_name, + self.empty_item_description, + ) + } + } else { + return None; + }; + + Some((self.tab_order, tab)) + } + + fn contains_marketplace(self, marketplace_name: &str) -> bool { + self.marketplace_names.contains(&marketplace_name) + } + + fn is_fallback_tab_id(self, tab_id: &str) -> bool { + tab_id.strip_prefix(REMOTE_LOADING_TAB_ID_PREFIX) == Some(self.loading_tab_id) + || tab_id.strip_prefix(REMOTE_EMPTY_TAB_ID_PREFIX) == Some(self.id) + || tab_id.strip_prefix(REMOTE_ERROR_TAB_ID_PREFIX) == Some(self.id) + } + + fn contains_tab_id(self, tab_id: &str) -> bool { + self.is_fallback_tab_id(tab_id) + || tab_id + .strip_prefix(MARKETPLACE_TAB_ID_PREFIX) + .is_some_and(|marketplace_name| self.contains_marketplace(marketplace_name)) + } +} struct DelayedLoadingHeader { started_at: Instant, @@ -237,18 +418,18 @@ impl ChatWidget { description: Some("Keep this marketplace installed.".to_string()), selected_description: Some("Keep this marketplace installed.".to_string()), actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { + tx.send(AppEvent::OpenPluginsList { cwd: cwd_for_cancel.clone(), - result: Ok(plugins_response_for_cancel.clone()), + response: plugins_response_for_cancel.clone(), }); })], ..Default::default() }, ], on_cancel: Some(Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { + tx.send(AppEvent::OpenPluginsList { cwd: cwd_for_on_cancel.clone(), - result: Ok(plugins_response_for_on_cancel.clone()), + response: plugins_response_for_on_cancel.clone(), }); })), ..Default::default() @@ -421,9 +602,9 @@ impl ChatWidget { description: Some("Return to the plugin list.".to_string()), selected_description: Some("Return to the plugin list.".to_string()), actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { + tx.send(AppEvent::OpenPluginsList { cwd: cwd.clone(), - result: Ok(plugins_response.clone()), + response: plugins_response.clone(), }); })], ..Default::default() @@ -478,9 +659,9 @@ impl ChatWidget { description: Some("Return to the plugin list.".to_string()), selected_description: Some("Return to the plugin list.".to_string()), actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { + tx.send(AppEvent::OpenPluginsList { cwd: cwd.clone(), - result: Ok(plugins_response.clone()), + response: plugins_response.clone(), }); })], ..Default::default() @@ -518,9 +699,9 @@ impl ChatWidget { description: Some("Return to the plugin list.".to_string()), selected_description: Some("Return to the plugin list.".to_string()), actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { + tx.send(AppEvent::OpenPluginsList { cwd: cwd.clone(), - result: Ok(plugins_response.clone()), + response: plugins_response.clone(), }); })], ..Default::default() @@ -542,19 +723,15 @@ impl ChatWidget { active_tab_id: Option, initial_selected_idx: Option, ) -> SelectionViewParams { - let marketplaces: Vec<&PluginMarketplaceEntry> = response.marketplaces.iter().collect(); + let marketplaces = &response.marketplaces; + let preferred_local_sources = preferred_local_plugin_sources(marketplaces); - let total: usize = marketplaces + let all_entries = plugin_entries_for_marketplaces(marketplaces); + let total = all_entries.len(); + let installed = all_entries .iter() - .map(|marketplace| marketplace.plugins.len()) - .sum(); - let installed = marketplaces - .iter() - .flat_map(|marketplace| marketplace.plugins.iter()) - .filter(|plugin| plugin.installed) + .filter(|(_, plugin, _)| plugin.installed) .count(); - - let all_entries = plugin_entries_for_marketplaces(marketplaces.iter().copied()); let name_column_width = all_entries .iter() .map(|(_, _, display_name)| { @@ -570,6 +747,14 @@ impl ChatWidget { let mut tabs = Vec::new(); let mut tab_footer_hints = Vec::new(); + let all_items = self.plugin_selection_items( + all_entries, + &preferred_local_sources, + /*include_marketplace_names*/ true, + "No marketplace plugins available", + "No plugins are available in the discovered marketplaces.", + ); + tabs.push(SelectionTab { id: ALL_PLUGINS_TAB_ID.to_string(), label: "All Plugins".to_string(), @@ -577,12 +762,7 @@ impl ChatWidget { "Browse plugins from available marketplaces.".to_string(), format!("Installed {installed} of {total} available plugins."), ), - items: self.plugin_selection_items( - all_entries, - /*include_marketplace_names*/ true, - "No marketplace plugins available", - "No plugins are available in the discovered marketplaces.", - ), + items: all_items, }); tabs.push(SelectionTab { @@ -594,24 +774,61 @@ impl ChatWidget { ), items: self.plugin_selection_items( installed_entries, + &preferred_local_sources, /*include_marketplace_names*/ true, "No installed plugins", "No installed plugins.", ), }); - let curated_marketplace = marketplaces - .iter() - .find(|marketplace| is_openai_curated_marketplace_name(&marketplace.name)) - .copied(); - let curated_entries = curated_marketplace - .map(|marketplace| plugin_entries_for_marketplaces([marketplace])) - .unwrap_or_default(); + let curated_entries = + plugin_entries_for_marketplaces(marketplaces.iter().filter(|marketplace| { + MarketplaceProduct::from_marketplace(marketplace).is_by_openai() + })); let curated_total = curated_entries.len(); let curated_installed = curated_entries .iter() .filter(|(_, plugin, _)| plugin.installed) .count(); + let curated_has_entries = !curated_entries.is_empty(); + let curated_loading = self.plugin_remote_sections_loading + && self.plugins_fetch_state.vertical_section_requested; + let by_openai_section_error = + plugin_remote_section_error(&self.plugin_remote_section_errors, "vertical"); + let (curated_empty_name, curated_empty_description) = + if curated_loading && !curated_has_entries { + ( + "Loading OpenAI Curated plugins...", + "This section updates when app-server returns it.", + ) + } else if let Some(section_error) = by_openai_section_error + && !curated_has_entries + { + ("OpenAI Curated unavailable", section_error.message.as_str()) + } else { + ( + "No OpenAI Curated plugins available", + "No OpenAI Curated plugins available.", + ) + }; + let mut curated_items = self.plugin_selection_items( + curated_entries, + &preferred_local_sources, + /*include_marketplace_names*/ false, + curated_empty_name, + curated_empty_description, + ); + if curated_loading && curated_has_entries { + curated_items.push(remote_section_loading_item("OpenAI Curated")); + } + if let Some(section_error) = by_openai_section_error + && curated_has_entries + { + curated_items.push(remote_section_error_item( + §ion_error.label, + §ion_error.message, + )); + } tabs.push(SelectionTab { id: OPENAI_CURATED_TAB_ID.to_string(), label: "OpenAI Curated".to_string(), @@ -619,27 +836,35 @@ impl ChatWidget { "OpenAI Curated marketplace.".to_string(), format!("Installed {curated_installed} of {curated_total} OpenAI Curated plugins."), ), - items: self.plugin_selection_items( - curated_entries, - /*include_marketplace_names*/ false, - "No OpenAI Curated plugins available", - "No OpenAI Curated plugins available.", - ), + items: curated_items, }); let mut additional_marketplaces: Vec<&PluginMarketplaceEntry> = marketplaces .iter() - .copied() - .filter(|marketplace| !is_openai_curated_marketplace_name(&marketplace.name)) + .filter(|marketplace| !MarketplaceProduct::from_marketplace(marketplace).is_by_openai()) .collect(); - additional_marketplaces.sort_by(|left, right| { - marketplace_display_name(left) - .to_ascii_lowercase() - .cmp(&marketplace_display_name(right).to_ascii_lowercase()) - .then_with(|| marketplace_display_name(left).cmp(&marketplace_display_name(right))) - .then_with(|| left.name.cmp(&right.name)) + additional_marketplaces.sort_by_cached_key(|marketplace| { + let display_name = marketplace_display_name(marketplace); + ( + MarketplaceProduct::from_marketplace(marketplace).tab_order(), + display_name.to_ascii_lowercase(), + display_name, + marketplace.name.clone(), + ) }); + let mut additional_tabs = Vec::new(); + for section in REMOTE_MARKETPLACE_SECTIONS { + if let Some(fallback_tab) = section.fallback_tab( + marketplaces, + self.plugin_remote_sections_loading, + self.plugin_remote_sections_loaded, + &self.plugin_remote_section_errors, + ) { + additional_tabs.push(fallback_tab); + } + } + let labels = disambiguate_duplicate_tab_labels( additional_marketplaces .iter() @@ -681,20 +906,28 @@ impl ChatWidget { ), ) }; - tabs.push(SelectionTab { - id: tab_id, - label: label.clone(), - header, - items: self.plugin_selection_items( - entries, - /*include_marketplace_names*/ false, - "No plugins available in this marketplace", - "No plugins available in this marketplace.", - ), - }); + additional_tabs.push(( + MarketplaceProduct::from_marketplace(marketplace).tab_order(), + SelectionTab { + id: tab_id, + label: label.clone(), + header, + items: self.plugin_selection_items( + entries, + &preferred_local_sources, + /*include_marketplace_names*/ false, + "No plugins available in this marketplace", + "No plugins available in this marketplace.", + ), + }, + )); } + additional_tabs.sort_by_key(|(tab_order, _)| *tab_order); + tabs.extend(additional_tabs.into_iter().map(|(_, tab)| tab)); tabs.push(self.marketplace_add_tab()); + let initial_tab_id = + active_tab_id.and_then(|tab_id| plugin_tab_id_matching_saved_id(&tab_id, &tabs)); SelectionViewParams { view_id: Some(PLUGINS_SELECTION_VIEW_ID), @@ -704,7 +937,7 @@ impl ChatWidget { )), tab_footer_hints, tabs, - initial_tab_id: active_tab_id, + initial_tab_id, is_searchable: true, search_placeholder: Some("Type to search plugins".to_string()), col_width_mode: ColumnWidthMode::AutoAllRows, @@ -744,24 +977,15 @@ impl ChatWidget { plugins_response: &PluginListResponse, plugin: &PluginDetail, ) -> SelectionViewParams { - let marketplace_label = plugin.marketplace_name.clone(); + let marketplace_label = MarketplaceProduct::from_marketplace_parts( + &plugin.marketplace_name, + plugin.marketplace_path.as_ref(), + ) + .label() + .map(str::to_string) + .unwrap_or_else(|| plugin.marketplace_name.clone()); let display_name = plugin_display_name(&plugin.summary); - let detail_status_label = - if plugin.summary.availability == PluginAvailability::DisabledByAdmin { - "Disabled by admin" - } else if plugin.summary.installed { - if plugin.summary.enabled { - "Installed" - } else { - "Disabled" - } - } else { - match plugin.summary.install_policy { - PluginInstallPolicy::NotAvailable => "Not installable", - PluginInstallPolicy::Available => "Can be installed", - PluginInstallPolicy::InstalledByDefault => "Available by default", - } - }; + let detail_status_label = plugin_detail_status_label(&plugin.summary); let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); header.push(Line::from( @@ -791,9 +1015,9 @@ impl ChatWidget { description: Some("Return to the plugin list.".to_string()), selected_description: Some("Return to the plugin list.".to_string()), actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { + tx.send(AppEvent::OpenPluginsList { cwd: cwd.clone(), - result: Ok(plugins_response.clone()), + response: plugins_response.clone(), }); })], ..Default::default() @@ -875,6 +1099,8 @@ impl ChatWidget { }); } + items.extend(plugin_metadata_items(plugin)); + items.push(SelectionItem { name: "Skills".to_string(), description: Some(plugin_skill_summary(plugin)), @@ -913,6 +1139,7 @@ impl ChatWidget { fn plugin_selection_items<'a>( &self, mut plugin_entries: Vec<(&'a PluginMarketplaceEntry, &'a PluginSummary, String)>, + preferred_local_sources: &HashMap, include_marketplace_names: bool, empty_name: &str, empty_description: &str, @@ -933,7 +1160,8 @@ impl ChatWidget { } else { plugin_brief_description_without_marketplace(plugin, status_label_width) }; - let plugin_detail_request = plugin_detail_request_for_entry(marketplace, plugin); + let plugin_detail_request = + plugin_detail_request_for_entry(marketplace, plugin, preferred_local_sources); let can_view_details = plugin_detail_request.is_some(); let disabled_by_admin = plugin.availability == PluginAvailability::DisabledByAdmin; let can_toggle_plugin = plugin.installed && !disabled_by_admin; @@ -1049,7 +1277,7 @@ pub(super) fn plugin_detail_hint_line() -> Line<'static> { Line::from("Press esc to close.") } -fn plugins_header(subtitle: String, count_line: String) -> Box { +pub(super) fn plugins_header(subtitle: String, count_line: String) -> Box { let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); header.push(Line::from(subtitle.dim())); @@ -1057,10 +1285,225 @@ fn plugins_header(subtitle: String, count_line: String) -> Box { Box::new(header) } +fn dedupe_plugin_entries<'a>( + entries: Vec<(&'a PluginMarketplaceEntry, &'a PluginSummary, String)>, +) -> Vec<(&'a PluginMarketplaceEntry, &'a PluginSummary, String)> { + // App-server should eventually normalize local/remote duplicates. Keep this + // display-only pass narrow so shared plugins do not appear twice meanwhile. + let mut deduped: Vec<(&PluginMarketplaceEntry, &PluginSummary, String)> = Vec::new(); + let mut remote_entry_indexes = HashMap::new(); + for entry in entries { + let Some(remote_plugin_id) = plugin_remote_identity(entry.1) else { + deduped.push(entry); + continue; + }; + if let Some(existing_index) = remote_entry_indexes.get(&remote_plugin_id).copied() { + if plugin_entry_preferred(&entry, &deduped[existing_index]) { + deduped[existing_index] = entry; + } + } else { + remote_entry_indexes.insert(remote_plugin_id, deduped.len()); + deduped.push(entry); + } + } + deduped +} + +fn plugin_entry_preferred( + candidate: &(&PluginMarketplaceEntry, &PluginSummary, String), + existing: &(&PluginMarketplaceEntry, &PluginSummary, String), +) -> bool { + if candidate.1.installed != existing.1.installed { + return candidate.1.installed; + } + + let candidate_is_local_share = + candidate.1.share_context.is_some() && !matches!(&candidate.1.source, PluginSource::Remote); + let existing_is_local_share = + existing.1.share_context.is_some() && !matches!(&existing.1.source, PluginSource::Remote); + if candidate_is_local_share != existing_is_local_share { + return candidate_is_local_share; + } + + !matches!(&candidate.1.source, PluginSource::Remote) + && matches!(&existing.1.source, PluginSource::Remote) +} + +fn preferred_local_plugin_sources( + marketplaces: &[PluginMarketplaceEntry], +) -> HashMap { + let mut sources = HashMap::new(); + for marketplace in marketplaces { + let Some(marketplace_path) = marketplace.path.as_ref() else { + continue; + }; + for plugin in &marketplace.plugins { + if matches!(&plugin.source, PluginSource::Remote) { + continue; + } + let Some(share_context) = plugin.share_context.as_ref() else { + continue; + }; + sources + .entry(share_context.remote_plugin_id.clone()) + .or_insert_with(|| PreferredLocalPluginSource { + marketplace_path: marketplace_path.clone(), + plugin_name: plugin.name.clone(), + installed: plugin.installed, + }); + } + } + sources +} + +fn plugin_detail_status_label(plugin: &PluginSummary) -> &'static str { + if plugin.availability == PluginAvailability::DisabledByAdmin { + return "Disabled by admin"; + } + if plugin.installed { + if plugin.enabled { + "Installed" + } else { + "Disabled" + } + } else { + match plugin.install_policy { + PluginInstallPolicy::NotAvailable => "Not installable", + PluginInstallPolicy::Available => "Can be installed", + PluginInstallPolicy::InstalledByDefault => "Available by default", + } + } +} + +fn plugin_metadata_items(plugin: &PluginDetail) -> Vec { + let mut items = Vec::new(); + items.push(SelectionItem { + name: "Source".to_string(), + description: Some(plugin_source_summary(plugin)), + is_disabled: true, + ..Default::default() + }); + items.push(SelectionItem { + name: "Auth".to_string(), + description: Some(plugin_auth_policy_summary(plugin.summary.auth_policy)), + is_disabled: true, + ..Default::default() + }); + if let Some(version) = plugin_version_summary(&plugin.summary) { + items.push(SelectionItem { + name: "Version".to_string(), + description: Some(version), + is_disabled: true, + ..Default::default() + }); + } + if let Some(share_context) = &plugin.summary.share_context { + items.push(SelectionItem { + name: "Sharing".to_string(), + description: Some(plugin_share_context_summary(share_context)), + is_disabled: true, + ..Default::default() + }); + } + items +} + +fn plugin_source_summary(plugin: &PluginDetail) -> String { + match &plugin.summary.source { + PluginSource::Local { .. } => "Local".to_string(), + PluginSource::Git { url, ref_name, .. } => match ref_name { + Some(ref_name) => format!("Git · {url}@{ref_name}"), + None => format!("Git · {url}"), + }, + PluginSource::Remote => { + let marketplace_label = + MarketplaceProduct::from_marketplace_name(&plugin.marketplace_name) + .label() + .unwrap_or(plugin.marketplace_name.as_str()); + format!("Remote · {marketplace_label}") + } + } +} + +fn plugin_auth_policy_summary(auth_policy: PluginAuthPolicy) -> String { + match auth_policy { + PluginAuthPolicy::OnInstall => "Auth on install".to_string(), + PluginAuthPolicy::OnUse => "Auth on use".to_string(), + } +} + +fn plugin_version_summary(plugin: &PluginSummary) -> Option { + let mut parts = Vec::new(); + if let Some(local_version) = plugin.local_version.as_deref() { + parts.push(format!("local {local_version}")); + } + if let Some(remote_version) = plugin + .share_context + .as_ref() + .and_then(|context| context.remote_version.as_deref()) + { + parts.push(format!("remote {remote_version}")); + } + (!parts.is_empty()).then(|| parts.join(" · ")) +} + +fn plugin_share_context_summary(context: &PluginShareContext) -> String { + let mut parts = Vec::new(); + if let Some(discoverability) = context.discoverability { + parts.push(plugin_share_discoverability_label(discoverability).to_string()); + } + if let Some(creator_summary) = plugin_share_creator_summary(context) { + parts.push(creator_summary); + } + if let Some(principals) = context.share_principals.as_ref() { + parts.push(plugin_share_principals_summary(principals)); + } + if let Some(share_url) = context + .share_url + .as_deref() + .filter(|url| !url.trim().is_empty()) + { + parts.push(share_url.to_string()); + } + if parts.is_empty() { + format!("Remote ID {}", context.remote_plugin_id) + } else { + parts.join(" · ") + } +} + +fn plugin_share_discoverability_label(discoverability: PluginShareDiscoverability) -> &'static str { + match discoverability { + PluginShareDiscoverability::Listed => "Listed", + PluginShareDiscoverability::Unlisted => "Workspace link", + PluginShareDiscoverability::Private => "Private", + } +} + +fn plugin_share_creator_summary(context: &PluginShareContext) -> Option { + match ( + context.creator_name.as_deref(), + context.creator_account_user_id.as_deref(), + ) { + (Some(name), Some(account_id)) => Some(format!("creator {name} ({account_id})")), + (Some(name), None) => Some(format!("creator {name}")), + (None, Some(account_id)) => Some(format!("creator account {account_id}")), + (None, None) => None, + } +} + +fn plugin_share_principals_summary(principals: &[PluginSharePrincipal]) -> String { + match principals.len() { + 0 => "No explicit principals".to_string(), + 1 => format!("1 principal: {}", principals[0].name), + count => format!("{count} principals"), + } +} + fn plugin_entries_for_marketplaces<'a>( marketplaces: impl IntoIterator, ) -> Vec<(&'a PluginMarketplaceEntry, &'a PluginSummary, String)> { - marketplaces + let entries = marketplaces .into_iter() .flat_map(|marketplace| { marketplace @@ -1068,7 +1511,8 @@ fn plugin_entries_for_marketplaces<'a>( .iter() .map(move |plugin| (marketplace, plugin, plugin_display_name(plugin))) }) - .collect() + .collect::>(); + dedupe_plugin_entries(entries) } fn sort_plugin_entries(entries: &mut [(&PluginMarketplaceEntry, &PluginSummary, String)]) { @@ -1103,6 +1547,10 @@ pub(super) fn marketplace_tab_id_matching_saved_id( saved_tab_id: &str, marketplaces: &[PluginMarketplaceEntry], ) -> Option { + if let Some(tab_id) = remote_section_marketplace_tab_id(saved_tab_id, marketplaces) { + return Some(tab_id); + } + if let Some(tab_id) = marketplaces.iter().find_map(|marketplace| { let tab_id = marketplace_tab_id(marketplace); (tab_id == saved_tab_id).then_some(tab_id) @@ -1124,6 +1572,43 @@ pub(super) fn marketplace_tab_id_matching_saved_id( }) } +fn remote_section_marketplace_tab_id( + saved_tab_id: &str, + marketplaces: &[PluginMarketplaceEntry], +) -> Option { + let section = REMOTE_MARKETPLACE_SECTIONS + .into_iter() + .find(|section| section.is_fallback_tab_id(saved_tab_id))?; + + section + .marketplace_names + .iter() + .find_map(|marketplace_name| { + marketplaces + .iter() + .find(|marketplace| marketplace.name.as_str() == *marketplace_name) + .map(marketplace_tab_id) + }) +} + +fn plugin_tab_id_matching_saved_id(saved_tab_id: &str, tabs: &[SelectionTab]) -> Option { + if let Some(tab_id) = tabs + .iter() + .find(|tab| tab.id.as_str() == saved_tab_id) + .map(|tab| tab.id.clone()) + { + return Some(tab_id); + } + + let section = REMOTE_MARKETPLACE_SECTIONS + .into_iter() + .find(|section| section.contains_tab_id(saved_tab_id))?; + + tabs.iter() + .find(|tab| section.contains_tab_id(&tab.id)) + .map(|tab| tab.id.clone()) +} + pub(super) fn merge_remote_marketplaces( response: &mut PluginListResponse, remote_marketplaces: Vec, @@ -1132,62 +1617,134 @@ pub(super) fn merge_remote_marketplaces( .iter() .map(|marketplace| marketplace.name.clone()) .collect::>(); + let remote_curated_present = remote_names.contains(REMOTE_GLOBAL_MARKETPLACE_NAME); response.marketplaces.retain(|marketplace| { + if remote_curated_present + && marketplace.path.is_some() + && is_openai_curated_marketplace_name(&marketplace.name) + { + return false; + } + marketplace.path.is_some() - || !remote_marketplace_is_remote_section(marketplace) + || !REMOTE_MARKETPLACE_SECTIONS + .into_iter() + .any(|section| section.contains_marketplace(&marketplace.name)) && !remote_names.contains(marketplace.name.as_str()) }); response.marketplaces.extend(remote_marketplaces); } -fn remote_marketplace_is_remote_section(marketplace: &PluginMarketplaceEntry) -> bool { - matches!( - marketplace.name.as_str(), - REMOTE_WORKSPACE_MARKETPLACE_NAME - | REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME - | REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME - | REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME - ) +fn is_personal_marketplace_path(marketplace_path: &AbsolutePathBuf) -> bool { + dirs::home_dir() + .and_then(|home| { + AbsolutePathBuf::try_from(home.join(PERSONAL_MARKETPLACE_RELATIVE_PATH)).ok() + }) + .is_some_and(|personal_path| personal_path.as_path() == marketplace_path.as_path()) +} + +fn remote_section_loading_item(label: &str) -> SelectionItem { + SelectionItem { + name: format!("Loading {label} plugins..."), + description: Some("This section updates when app-server returns it.".to_string()), + is_disabled: true, + ..Default::default() + } +} + +fn remote_section_error_item(label: &str, message: &str) -> SelectionItem { + SelectionItem { + name: format!("{label} unavailable"), + description: Some(message.to_string()), + is_disabled: true, + ..Default::default() + } +} + +fn plugin_remote_section_error<'a>( + section_errors: &'a [PluginRemoteSectionError], + section_id: &str, +) -> Option<&'a PluginRemoteSectionError> { + section_errors + .iter() + .find(|section_error| section_error.section_id == section_id) +} + +fn remote_section_loading_tab(id: &str, label: &str) -> SelectionTab { + SelectionTab { + id: format!("{REMOTE_LOADING_TAB_ID_PREFIX}{id}"), + label: label.to_string(), + header: plugins_header( + format!("Loading {label} plugins."), + "Local plugin functionality is already available.".to_string(), + ), + items: vec![remote_section_loading_item(label)], + } +} + +fn remote_section_empty_tab( + id: &str, + label: &str, + item_name: &str, + item_description: &str, +) -> SelectionTab { + SelectionTab { + id: format!("{REMOTE_EMPTY_TAB_ID_PREFIX}{id}"), + label: label.to_string(), + header: plugins_header( + format!("{label}."), + "This section loaded successfully.".to_string(), + ), + items: vec![SelectionItem { + name: item_name.to_string(), + description: Some(item_description.to_string()), + is_disabled: true, + ..Default::default() + }], + } +} + +fn remote_section_error_tab(section_error: &PluginRemoteSectionError) -> SelectionTab { + SelectionTab { + id: format!("{REMOTE_ERROR_TAB_ID_PREFIX}{}", section_error.section_id), + label: section_error.label.clone(), + header: plugins_header( + format!("{} unavailable.", section_error.label), + "Local plugin functionality is still available.".to_string(), + ), + items: vec![remote_section_error_item( + §ion_error.label, + §ion_error.message, + )], + } } fn disambiguate_duplicate_tab_labels(labels: Vec) -> Vec { - let mut counts: Vec<(String, usize)> = Vec::new(); + let mut counts = HashMap::new(); for label in &labels { - if let Some((_, count)) = counts.iter_mut().find(|(existing, _)| existing == label) { - *count += 1; - } else { - counts.push((label.clone(), 1)); - } + *counts.entry(label.clone()).or_insert(0) += 1; } - let mut seen: Vec<(String, usize)> = Vec::new(); + let mut seen = HashMap::new(); labels .into_iter() .map(|label| { - let total = counts - .iter() - .find(|(existing, _)| existing == &label) - .map(|(_, count)| *count) - .unwrap_or(1); + let total = counts[&label]; if total == 1 { return label; } - let current = if let Some((_, seen_count)) = - seen.iter_mut().find(|(existing, _)| existing == &label) - { - *seen_count += 1; - *seen_count - } else { - seen.push((label.clone(), 1)); - 1 - }; + let current = seen.entry(label.clone()).or_insert(0); + *current += 1; format!("{label} ({current}/{total})") }) .collect() } pub(super) fn marketplace_display_name(marketplace: &PluginMarketplaceEntry) -> String { + if let Some(label) = MarketplaceProduct::from_marketplace(marketplace).label() { + return label.to_string(); + } marketplace .interface .as_ref() @@ -1271,7 +1828,7 @@ fn plugin_status_label(plugin: &PluginSummary) -> &'static str { match plugin.install_policy { PluginInstallPolicy::NotAvailable => "Not installable", PluginInstallPolicy::Available => "Available", - PluginInstallPolicy::InstalledByDefault => "Available", + PluginInstallPolicy::InstalledByDefault => "Available by default", } } } @@ -1300,7 +1857,21 @@ fn plugin_detail_location(plugin: &PluginDetail) -> Option { fn plugin_detail_request_for_entry( marketplace: &PluginMarketplaceEntry, plugin: &PluginSummary, + preferred_local_sources: &HashMap, ) -> Option<(PluginLocation, String)> { + if matches!(&plugin.source, PluginSource::Remote) + && let Some(remote_plugin_id) = plugin_remote_identity(plugin) + && let Some(preferred_source) = preferred_local_sources.get(remote_plugin_id) + && preferred_source.installed == plugin.installed + { + return Some(( + PluginLocation::Local { + marketplace_path: preferred_source.marketplace_path.clone(), + }, + preferred_source.plugin_name.clone(), + )); + } + plugin_location_for_marketplace(marketplace, plugin) .map(|location| (location, plugin_request_name(plugin))) } @@ -1309,22 +1880,22 @@ fn plugin_request_name(plugin: &PluginSummary) -> String { if matches!(&plugin.source, PluginSource::Remote) && let Some(remote_plugin_id) = plugin_remote_identity(plugin) { - return remote_plugin_id; + return remote_plugin_id.to_string(); } plugin.name.clone() } -fn plugin_remote_identity(plugin: &PluginSummary) -> Option { +fn plugin_remote_identity(plugin: &PluginSummary) -> Option<&str> { plugin .share_context .as_ref() - .map(|context| context.remote_plugin_id.clone()) - .or_else(|| plugin.remote_plugin_id.clone()) + .map(|context| context.remote_plugin_id.as_str()) + .or(plugin.remote_plugin_id.as_deref()) } fn plugin_uninstall_id(plugin: &PluginSummary) -> Option { if matches!(&plugin.source, PluginSource::Remote) { - return plugin_remote_identity(plugin); + return plugin_remote_identity(plugin).map(str::to_string); } Some(plugin.id.clone()) } diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 8c259856fb..0ff8333ffc 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -42,6 +42,7 @@ pub(super) const ADD_MARKETPLACE_TAB_ID: &str = "add-marketplace"; pub(super) struct PluginListFetchState { pub(super) cache_cwd: Option, pub(super) in_flight_cwd: Option, + pub(super) vertical_section_requested: bool, } #[derive(Debug, Clone)] @@ -147,6 +148,7 @@ impl ChatWidget { Err(err) => { self.plugin_remote_sections_loading = false; self.plugin_remote_sections_loaded = false; + self.plugins_fetch_state.vertical_section_requested = false; if should_refresh_plugins_popup { self.plugins_fetch_state.cache_cwd = None; self.plugins_cache = PluginsCacheState::Failed(err.clone()); @@ -175,6 +177,7 @@ impl ChatWidget { .is_some(); self.plugin_remote_sections_loading = false; self.plugin_remote_sections_loaded = true; + self.plugins_fetch_state.vertical_section_requested = false; let refreshed_response = match &mut self.plugins_cache { PluginsCacheState::Ready(response) if self.plugins_fetch_state.cache_cwd.as_deref() == Some(cwd.as_path()) => @@ -212,6 +215,8 @@ impl ChatWidget { } self.plugins_fetch_state.in_flight_cwd = Some(cwd.clone()); + self.plugins_fetch_state.vertical_section_requested = + !self.config.features.enabled(Feature::RemotePlugin); if self.plugins_fetch_state.cache_cwd.as_deref() != Some(cwd.as_path()) { self.plugins_cache = PluginsCacheState::Loading; } @@ -245,6 +250,36 @@ impl ChatWidget { )); } + pub(crate) fn open_plugins_list(&mut self, cwd: PathBuf, response: PluginListResponse) { + if self.config.cwd.as_path() != cwd.as_path() { + return; + } + + let response = match self.plugins_cache_for_current_cwd() { + PluginsCacheState::Ready(current_response) => current_response, + PluginsCacheState::Uninitialized + | PluginsCacheState::Loading + | PluginsCacheState::Failed(_) => response, + }; + self.plugins_fetch_state.cache_cwd = Some(cwd); + self.plugins_cache = PluginsCacheState::Ready(response.clone()); + let active_tab_id = self + .bottom_pane + .active_tab_id_for_active_view(PLUGINS_SELECTION_VIEW_ID) + .map(str::to_string) + .or_else(|| self.plugins_active_tab_id.clone()) + .or_else(|| Some(ALL_PLUGINS_TAB_ID.to_string())); + self.plugins_active_tab_id = active_tab_id.clone(); + let params = + self.plugins_popup_params(&response, active_tab_id, /*initial_selected_idx*/ None); + if !self + .bottom_pane + .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params) + { + self.open_plugins_popup(&response); + } + } + pub(crate) fn open_marketplace_add_prompt(&mut self) { self.plugins_active_tab_id = Some(ADD_MARKETPLACE_TAB_ID.to_string()); let tx = self.app_event_tx.clone(); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap index c7edae1714..e5d14a4c51 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap @@ -3,13 +3,15 @@ source: tui/src/chatwidget/tests/popups_and_settings.rs expression: strip_osc8_for_snapshot(&popup) --- Plugins - Figma · Can be installed · ChatGPT Marketplace + Figma · Can be installed · Local Data shared with this app is subject to the app's terms of service and privacy policy. Learn more. Turn Figma files into implementation context. › 1. Back to plugins Return to the plugin list. 2. Install plugin Install this plugin now. + Source Local + Auth Auth on install Skills design-review, extract-copy Hooks PreToolUse (1), Stop (2) Apps Figma, Slack diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap index 4d91b17f34..2b69864cc5 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap @@ -8,6 +8,8 @@ expression: strip_osc8_for_snapshot(&popup) › 1. Back to plugins Return to the plugin list. 2. Uninstall plugin Remove this plugin now. + Source Local + Auth Auth on install Skills design-review, extract-copy Hooks PreToolUse (1), Stop (2) Apps Figma, Slack diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap index 40d26d3c9d..9f828ab3bc 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap @@ -9,9 +9,9 @@ expression: popup [All Plugins] Installed (1) OpenAI Curated Repo Marketplace Add Marketplace Type to search plugins -› [ ] Alpha Sync Disabled Space to enable; Enter view details. - [-] Bravo Search Available · ChatGPT Marketplace · Search docs and tickets. - [-] Hidden Repo Plugin Available · Repo Marketplace · Should not be shown in /plugins. - [-] Starter Available · ChatGPT Marketplace · Included by default. +› [ ] Alpha Sync Disabled Space to enable; Enter view details. + [-] Bravo Search Available · OpenAI Curated · Search docs and tickets. + [-] Hidden Repo Plugin Available · Repo Marketplace · Should not be shown in /plugi… + [-] Starter Available by default · OpenAI Curated · Included by default. space enable/disable · ←/→ select marketplace · enter view details · esc close diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_newly_installed_marketplace.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_newly_installed_marketplace.snap index 515b700925..803ff9fbe6 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_newly_installed_marketplace.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_newly_installed_marketplace.snap @@ -1,13 +1,13 @@ --- source: tui/src/chatwidget/tests/popups_and_settings.rs -assertion_line: 339 expression: popup --- Plugins Debug Marketplace installed successfully. Select the plugins you want to use and press Enter to install or view details. - All Plugins Installed (0) OpenAI Curated [Debug Marketplace] Add Marketplace + All Plugins Installed (0) OpenAI Curated Workspace Shared with me [Debug Marketplace] + Add Marketplace Type to search plugins › [-] Debug Plugin Available Press Enter to install or view plugin details. diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 4c225b3318..281d97305e 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -7,6 +7,7 @@ use codex_app_server_protocol::HooksListEntry; use codex_app_server_protocol::HooksListResponse; use codex_app_server_protocol::MarketplaceRemoveResponse; use codex_app_server_protocol::PluginAvailability; +use codex_app_server_protocol::PluginShareContext; use codex_features::Stage; use pretty_assertions::assert_eq; @@ -250,7 +251,7 @@ async fn plugins_popup_truncates_long_descriptions_in_list_rows() { .expect("expected verbose plugin row in popup"); insta::assert_snapshot!( verbose_row, - @" [-] Verbose Plugin Available · ChatGPT Marketplace · This descri…" + @" [-] Verbose Plugin Available · OpenAI Curated · This description…" ); assert!( !popup @@ -459,11 +460,20 @@ async fn marketplace_add_success_refreshes_to_new_marketplace_tab() { chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); chat.add_plugins_output(); - for _ in 0..3 { - chat.handle_key_event(KeyEvent::from(KeyCode::Right)); - } - - let reopened_popup = render_bottom_popup(&chat, /*width*/ 100); + let reopened_popup = (0..8) + .find_map(|_| { + let popup = render_bottom_popup(&chat, /*width*/ 100); + if popup.contains("[Debug Marketplace]") { + Some(popup) + } else { + chat.handle_key_event(KeyEvent::from(KeyCode::Right)); + None + } + }) + .unwrap_or_else(|| { + let popup = render_bottom_popup(&chat, /*width*/ 100); + panic!("expected Debug Marketplace tab after reopening, got:\n{popup}"); + }); assert!( reopened_popup.contains("Installed 0 of 1 Debug Marketplace plugins.") && !reopened_popup.contains("installed successfully"), @@ -584,10 +594,17 @@ async fn plugins_popup_removes_user_configured_marketplace_flow() { } #[tokio::test] -async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summaries() { +async fn plugin_detail_popup_snapshot_labels_personal_marketplace_as_local() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + let marketplace_name = "personal-marketplace"; + let personal_marketplace_path = AbsolutePathBuf::try_from( + dirs::home_dir() + .expect("home directory") + .join(".agents/plugins/marketplace.json"), + ) + .expect("absolute personal marketplace path"); let summary = plugins_test_summary( "plugin-figma", "figma", @@ -597,28 +614,29 @@ async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summa /*enabled*/ true, PluginInstallPolicy::Available, ); - let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ - summary.clone(), - ])]); + let response = plugins_test_response(vec![PluginMarketplaceEntry { + name: marketplace_name.to_string(), + path: Some(personal_marketplace_path.clone()), + interface: None, + plugins: vec![summary.clone()], + }]); let cwd = chat.config.cwd.clone(); chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response)); chat.add_plugins_output(); - chat.on_plugin_detail_loaded( - cwd.to_path_buf(), - Ok(PluginReadResponse { - plugin: plugins_test_detail( - summary, - Some("Turn Figma files into implementation context."), - &["design-review", "extract-copy"], - &[ - (codex_app_server_protocol::HookEventName::PreToolUse, 1), - (codex_app_server_protocol::HookEventName::Stop, 2), - ], - &["Figma", "Slack"], - &["figma-mcp", "docs-mcp"], - ), - }), + let mut plugin = plugins_test_detail( + summary, + Some("Turn Figma files into implementation context."), + &["design-review", "extract-copy"], + &[ + (codex_app_server_protocol::HookEventName::PreToolUse, 1), + (codex_app_server_protocol::HookEventName::Stop, 2), + ], + &["Figma", "Slack"], + &["figma-mcp", "docs-mcp"], ); + plugin.marketplace_name = marketplace_name.to_string(); + plugin.marketplace_path = Some(personal_marketplace_path); + chat.on_plugin_detail_loaded(cwd.to_path_buf(), Ok(PluginReadResponse { plugin })); let popup = render_bottom_popup(&chat, /*width*/ 100); assert_chatwidget_snapshot!( @@ -1022,6 +1040,222 @@ async fn plugins_popup_admin_disabled_installed_plugin_has_no_toggle_hint() { assert_eq!(after, before); } +#[tokio::test] +async fn plugins_popup_admin_disabled_available_plugin_has_view_only_hint() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let summary = PluginSummary { + availability: PluginAvailability::DisabledByAdmin, + ..plugins_test_summary( + "plugin-admin-blocked", + "admin-blocked", + Some("Admin Blocked"), + Some("Blocked by policy."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + ) + }; + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![plugins_test_curated_marketplace(vec![summary])]), + ); + + let popup = render_bottom_popup(&chat, /*width*/ 100); + let admin_blocked_row = popup + .lines() + .find(|line| line.contains("Admin Blocked")) + .expect("expected admin-disabled plugin row"); + assert!( + admin_blocked_row.contains("Press Enter to view plugin details.") + && !admin_blocked_row.contains("install or view"), + "expected admin-disabled available plugin to stay view-only, got:\n{admin_blocked_row}" + ); +} + +#[tokio::test] +async fn plugins_popup_remote_section_fallback_states_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let select_tab_containing = |chat: &mut ChatWidget, visible_text: &str| -> String { + for _ in 0..8 { + let popup = render_bottom_popup(chat, /*width*/ 100); + if popup.contains(visible_text) { + return popup; + } + chat.handle_key_event(KeyEvent::from(KeyCode::Right)); + } + + let popup = render_bottom_popup(chat, /*width*/ 100); + panic!("expected plugins tab containing {visible_text:?}, got:\n{popup}"); + }; + let remote_section_state = |popup: &str| -> String { + let header = popup + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .nth(1) + .expect("expected remote section header"); + let item = popup + .lines() + .find_map(|line| line.trim_start().strip_prefix('›')) + .expect("expected selected remote section item") + .trim(); + format!("{header}\n{item}") + }; + + chat.add_plugins_output(); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded( + cwd.to_path_buf(), + Ok(plugins_test_response(vec![ + plugins_test_curated_marketplace(Vec::new()), + ])), + ); + let curated_loading_popup = + select_tab_containing(&mut chat, "Loading OpenAI Curated plugins..."); + let workspace_loading_popup = select_tab_containing(&mut chat, "Loading Workspace plugins."); + + chat.on_plugin_remote_sections_loaded(cwd.to_path_buf(), Vec::new(), Vec::new()); + let shared_empty_popup = select_tab_containing(&mut chat, "Shared with me."); + + chat.on_plugin_remote_sections_loaded( + cwd.to_path_buf(), + Vec::new(), + vec![crate::app_event::PluginRemoteSectionError { + section_id: "workspace".to_string(), + label: "Workspace".to_string(), + message: "Sign in to ChatGPT to load workspace plugins.".to_string(), + }], + ); + let workspace_error_popup = select_tab_containing(&mut chat, "Workspace unavailable."); + + let (mut remote_chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + remote_chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + remote_chat.set_feature_enabled(Feature::RemotePlugin, /*enabled*/ true); + remote_chat.add_plugins_output(); + let remote_cwd = remote_chat.config.cwd.clone(); + remote_chat.on_plugins_loaded( + remote_cwd.to_path_buf(), + Ok(plugins_test_response(vec![ + plugins_test_curated_marketplace(Vec::new()), + ])), + ); + let remote_curated_empty_popup = + select_tab_containing(&mut remote_chat, "No OpenAI Curated plugins available"); + + insta::assert_snapshot!( + [ + remote_section_state(&curated_loading_popup), + remote_section_state(&workspace_loading_popup), + remote_section_state(&shared_empty_popup), + remote_section_state(&workspace_error_popup), + remote_section_state(&remote_curated_empty_popup), + ] + .join("\n\n"), + @r###" + OpenAI Curated marketplace. + Loading OpenAI Curated plugins... This section updates when app-server returns it. + + Loading Workspace plugins. + Loading Workspace plugins... This section updates when app-server returns it. + + Shared with me. + No shared plugins available No plugins have been shared with you. + + Workspace unavailable. + Workspace unavailable Sign in to ChatGPT to load workspace plugins. + + OpenAI Curated marketplace. + No OpenAI Curated plugins available No OpenAI Curated plugins available. + "### + ); +} + +#[tokio::test] +async fn plugins_popup_installed_remote_row_keeps_remote_detail_when_local_share_is_uninstalled() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let remote_plugin_id = "plugins~Plugin_docs"; + let remote_marketplace_name = "workspace-shared-with-me-private"; + let local_summary = PluginSummary { + share_context: Some(PluginShareContext { + remote_plugin_id: remote_plugin_id.to_string(), + remote_version: None, + discoverability: None, + share_url: None, + creator_account_user_id: None, + creator_name: None, + share_principals: None, + }), + ..plugins_test_summary( + "plugin-docs", + "docs", + Some("Docs"), + Some("Local editable docs plugin."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + ) + }; + let popup = render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![ + plugins_test_curated_marketplace(vec![local_summary]), + PluginMarketplaceEntry { + name: remote_marketplace_name.to_string(), + path: None, + interface: Some(MarketplaceInterface { + display_name: Some("Shared with me".to_string()), + }), + plugins: vec![plugins_test_remote_summary( + remote_plugin_id, + "docs", + Some("Docs"), + Some("Shared docs plugin."), + /*installed*/ true, + )], + }, + ]), + ); + let all_plugins_row = popup + .lines() + .find(|line| line.contains("Docs")) + .expect("expected all-plugins row"); + assert!( + popup.contains("Installed 1 of 1 available plugins.") + && all_plugins_row.contains("Installed") + && !all_plugins_row.contains("Available"), + "expected installed remote duplicate to win over local mapped share, got:\n{popup}" + ); + + while rx.try_recv().is_ok() {} + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + match rx.try_recv() { + Ok(AppEvent::OpenPluginDetailLoading { + plugin_display_name, + }) => { + assert_eq!(plugin_display_name, "Docs"); + } + other => panic!("expected OpenPluginDetailLoading event, got {other:?}"), + } + match rx.try_recv() { + Ok(AppEvent::FetchPluginDetail { params, .. }) => { + assert_eq!(params.marketplace_path, None); + assert_eq!( + params.remote_marketplace_name, + Some(remote_marketplace_name.to_string()) + ); + assert_eq!(params.plugin_name, remote_plugin_id); + } + other => panic!("expected FetchPluginDetail event, got {other:?}"), + } +} + #[tokio::test] async fn plugin_detail_error_popup_skips_disabled_row_numbering() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; From 9f06cf1a0907f650887ca26af5cee13b4f9a13ea Mon Sep 17 00:00:00 2001 From: jif Date: Mon, 22 Jun 2026 18:33:28 +0100 Subject: [PATCH 112/112] Report remote sandbox denials semantically (#29424) ## Why #29113 moved remote sandbox setup and enforcement to the exec server. That gives the executor ownership of the platform-specific work: a Linux executor chooses and runs a Linux sandbox even when the Codex orchestrator is running on macOS or Windows. It also means the orchestrator no longer knows which concrete sandbox the executor selected. When that sandbox blocks a remote command, the orchestrator currently sees only a failed process and can treat the denial as an ordinary command failure. The existing sandbox approval and retry path is then skipped. This PR lets the executor report one portable fact: > This command probably failed because the executor sandbox blocked it. The executor keeps its concrete sandbox type private. The protocol sends only the semantic result. ## Example Suppose a local macOS Codex session asks a Linux devbox to write outside the allowed workspace. Before this PR: ```text Linux sandbox blocks the write -> remote process exits with "Permission denied" -> local orchestrator sees an ordinary command failure -> the normal sandbox approval and retry path can be skipped ``` With this PR: ```text Linux sandbox blocks the write -> executor reports sandboxDenied: true -> unified exec returns UnifiedExecError::SandboxDenied -> the existing approval prompt is shown -> an approved retry runs through the existing unsandboxed retry path ``` ## What changes ### The executor remembers its selected sandbox The prepared remote process now retains the executor-selected `SandboxType`. This value never crosses the executor boundary. Commands started without a sandbox retain `SandboxType::None` and are never reported as sandbox denials. ### The executor uses the existing denial heuristic The existing local denial heuristic moves from `codex-core` into the shared `codex-sandboxing` crate. When a sandboxed remote process exits, the executor: 1. waits the same short output grace period used by local unified exec; 2. reads the output currently available in the existing retained output buffer; 3. runs the existing heuristic using the exit code and common denial messages; 4. stores the yes/no result before publishing the process exit. This deliberately matches the old local unified-exec behavior. It does not add a new streaming classifier, another output buffer, or stronger output-retention guarantees. ### The protocol reports a portable boolean `process/read` gains `sandboxDenied`: ```json { "exited": true, "exitCode": 1, "closed": false, "sandboxDenied": true } ``` The field defaults to `false` when an older executor omits it. The response does not expose the executor sandbox implementation or executor-native paths. ### Unified exec uses the existing error path The exec-server client carries `sandboxDenied` into the unified process state. If it is true, unified exec returns the existing `SandboxDenied` error instead of trying to classify remote output using an orchestrator-side sandbox type. Remote process exit remains visible as soon as the process exits. This PR does not wait for stdout or stderr to close and does not change the existing process lifecycle. ## Scope This PR is intentionally limited to matching the existing local unified-exec behavior for the initial command execution path. It does not add: - incremental denial tracking across the full output stream; - new denial handling for commands completed later through `write_stdin`; - new guarantees for preserving the semantic flag during the narrow reconnect-recovery race. Those can be considered separately if the same behavior is added for local execution. ## Test coverage One remote end-to-end integration test covers the complete intended flow: ```text remote read-only sandbox -> denied write -> executor reports the denial -> Codex requests approval -> user approves -> retry succeeds on the remote executor ``` Existing lifecycle coverage continues to verify that remote process exit is reported before late output streams close. --- codex-rs/core/src/exec.rs | 63 +------- codex-rs/core/src/unified_exec/mod_tests.rs | 20 ++- codex-rs/core/src/unified_exec/process.rs | 19 ++- .../core/src/unified_exec/process_manager.rs | 15 +- .../src/unified_exec/process_manager_tests.rs | 19 ++- .../core/src/unified_exec/process_state.rs | 3 + .../core/src/unified_exec/process_tests.rs | 7 +- codex-rs/core/tests/suite/remote_env.rs | 152 +++++++++++++++++- codex-rs/exec-server/src/client.rs | 2 + codex-rs/exec-server/src/client_recovery.rs | 1 + codex-rs/exec-server/src/local_process.rs | 46 ++++++ codex-rs/exec-server/src/process_sandbox.rs | 3 + codex-rs/exec-server/src/protocol.rs | 3 + codex-rs/exec-server/tests/exec_process.rs | 18 +-- codex-rs/exec-server/tests/process.rs | 4 +- codex-rs/exec-server/tests/relay.rs | 2 +- codex-rs/sandboxing/src/denial.rs | 57 +++++++ codex-rs/sandboxing/src/lib.rs | 2 + 18 files changed, 330 insertions(+), 106 deletions(-) create mode 100644 codex-rs/sandboxing/src/denial.rs diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 99c87cdd99..647203db2e 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -42,6 +42,7 @@ use codex_sandboxing::SandboxTransformRequest; use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; use codex_sandboxing::WindowsSandboxFilesystemOverrides; +pub(crate) use codex_sandboxing::is_likely_sandbox_denied; #[cfg(test)] use codex_sandboxing::permission_profile_supports_windows_restricted_token_sandbox; use codex_sandboxing::resolve_windows_elevated_filesystem_overrides; @@ -819,68 +820,6 @@ fn finalize_exec_result( } } -/// We don't have a fully deterministic way to tell if our command failed -/// because of the sandbox - a command in the user's zshrc file might hit an -/// error, but the command itself might fail or succeed for other reasons. -/// For now, we conservatively check for well known command failure exit codes and -/// also look for common sandbox denial keywords in the command output. -pub(crate) fn is_likely_sandbox_denied( - sandbox_type: SandboxType, - exec_output: &ExecToolCallOutput, -) -> bool { - if sandbox_type == SandboxType::None || exec_output.exit_code == 0 { - return false; - } - - // Quick rejects: well-known non-sandbox shell exit codes - // 2: misuse of shell builtins - // 126: permission denied - // 127: command not found - const SANDBOX_DENIED_KEYWORDS: [&str; 7] = [ - "operation not permitted", - "permission denied", - "read-only file system", - "seccomp", - "sandbox", - "landlock", - "failed to write file", - ]; - - let has_sandbox_keyword = [ - &exec_output.stderr.text, - &exec_output.stdout.text, - &exec_output.aggregated_output.text, - ] - .into_iter() - .any(|section| { - let lower = section.to_lowercase(); - SANDBOX_DENIED_KEYWORDS - .iter() - .any(|needle| lower.contains(needle)) - }); - - if has_sandbox_keyword { - return true; - } - - const QUICK_REJECT_EXIT_CODES: [i32; 3] = [2, 126, 127]; - if QUICK_REJECT_EXIT_CODES.contains(&exec_output.exit_code) { - return false; - } - - #[cfg(unix)] - { - const SIGSYS_CODE: i32 = libc::SIGSYS; - if sandbox_type == SandboxType::LinuxSeccomp - && exec_output.exit_code == EXIT_CODE_SIGNAL_BASE + SIGSYS_CODE - { - return true; - } - } - - false -} - #[derive(Debug)] struct RawExecToolCallOutput { pub exit_status: ExitStatus, diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index d5b813cb42..2dc0afd265 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -228,6 +228,7 @@ impl BlockingTerminateExecProcess { exit_code: None, closed: false, failure: None, + sandbox_denied: false, }) } @@ -286,17 +287,14 @@ async fn blocking_terminate_unified_process( ) -> anyhow::Result> { let (wake_tx, _wake_rx) = watch::channel(0); Ok(Arc::new( - UnifiedExecProcess::from_exec_server_started( - StartedExecProcess { - process: Arc::new(BlockingTerminateExecProcess { - process_id: process_id.to_string().into(), - terminate_started, - allow_terminate, - wake_tx, - }), - }, - SandboxType::None, - ) + UnifiedExecProcess::from_exec_server_started(StartedExecProcess { + process: Arc::new(BlockingTerminateExecProcess { + process_id: process_id.to_string().into(), + terminate_started, + allow_terminate, + wake_tx, + }), + }) .await?, )) } diff --git a/codex-rs/core/src/unified_exec/process.rs b/codex-rs/core/src/unified_exec/process.rs index 725be9eed2..5f785adfa7 100644 --- a/codex-rs/core/src/unified_exec/process.rs +++ b/codex-rs/core/src/unified_exec/process.rs @@ -285,8 +285,9 @@ impl UnifiedExecProcess { &self, text: &str, ) -> Result<(), UnifiedExecError> { + let executor_reported_denial = self.state_rx.borrow().sandbox_denied; let sandbox_type = self.sandbox_type(); - if sandbox_type == SandboxType::None || !self.has_exited() { + if !self.has_exited() || (!executor_reported_denial && sandbox_type == SandboxType::None) { return Ok(()); } @@ -297,7 +298,7 @@ impl UnifiedExecProcess { aggregated_output: StreamOutput::new(text.to_string()), ..Default::default() }; - if is_likely_sandbox_denied(sandbox_type, &exec_output) { + if executor_reported_denial || is_likely_sandbox_denied(sandbox_type, &exec_output) { let snippet = formatted_truncate_text( text, TruncationPolicy::Tokens(UNIFIED_EXEC_OUTPUT_MAX_TOKENS), @@ -374,10 +375,13 @@ impl UnifiedExecProcess { pub(super) async fn from_exec_server_started( started: StartedExecProcess, - sandbox_type: SandboxType, ) -> Result { let process_handle = ProcessHandle::ExecServer(Arc::clone(&started.process)); - let mut managed = Self::new(process_handle, sandbox_type, /*spawn_lifecycle*/ None); + let mut managed = Self::new( + process_handle, + SandboxType::None, + /*spawn_lifecycle*/ None, + ); let output_handles = managed.output_handles(); managed.output_task = Some(Self::spawn_exec_server_output_task( started, @@ -437,6 +441,7 @@ impl UnifiedExecProcess { exit_code, closed, failure, + sandbox_denied, } = response; for chunk in chunks { @@ -457,6 +462,12 @@ impl UnifiedExecProcess { break; } + if sandbox_denied { + let mut state = state_tx.borrow().clone(); + state.sandbox_denied = true; + let _ = state_tx.send_replace(state); + } + if exited { let state = state_tx.borrow().clone(); let _ = state_tx.send_replace(state.exited(exit_code)); diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index a3b4bcf9c7..2cb62a6d4b 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -10,6 +10,7 @@ use tokio::sync::watch; use tokio::time::Duration; use tokio::time::Instant; use tokio_util::sync::CancellationToken; +use uuid::Uuid; use crate::codex_thread::BackgroundTerminalInfo; use crate::exec_env::CODEX_THREAD_ID_ENV_VAR; @@ -157,8 +158,14 @@ fn exec_server_params_for_request( tty: bool, ) -> codex_exec_server::ExecParams { let (env_policy, env) = exec_server_env_for_request(request); + // Sandbox retries reuse the unified-exec ID but start a distinct executor process. + let exec_server_process_id = if request.exec_server_sandbox.is_some() { + format!("{process_id}-{}", Uuid::new_v4()) + } else { + process_id.to_string() + }; codex_exec_server::ExecParams { - process_id: exec_server_process_id(process_id).into(), + process_id: exec_server_process_id.into(), argv: request.command.clone(), cwd: request.cwd.clone(), env_policy, @@ -198,10 +205,6 @@ impl Drop for InitialExecCommandGuard { } } -fn exec_server_process_id(process_id: i32) -> String { - process_id.to_string() -} - async fn unregister_network_approval_for_entry(entry: &ProcessEntry) { if let Some(network_approval) = entry.network_approval.as_ref() && let Some(session) = entry.session.upgrade() @@ -1041,7 +1044,7 @@ impl UnifiedExecProcessManager { .await .map_err(|err| UnifiedExecError::create_process(err.to_string()))?; spawn_lifecycle.after_spawn(); - return UnifiedExecProcess::from_exec_server_started(started, request.sandbox).await; + return UnifiedExecProcess::from_exec_server_started(started).await; } // TODO(anp): Keep PathUri through the local PTY/process launch boundary. diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index b6afdc85ba..cd48d01c7c 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -76,7 +76,7 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { codex_protocol::permissions::FileSystemSandboxPolicy::unrestricted(); let network_sandbox_policy = codex_protocol::permissions::NetworkSandboxPolicy::Restricted; let permission_profile = codex_protocol::models::PermissionProfile::Disabled; - let request = ExecRequest { + let mut request = ExecRequest { command: vec!["bash".to_string(), "-lc".to_string(), "true".to_string()], cwd: cwd.clone().into(), env: HashMap::from([ @@ -106,7 +106,7 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { windows_sandbox_workspace_roots: vec![cwd], windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, - permission_profile, + permission_profile: permission_profile.clone(), file_system_sandbox_policy, network_sandbox_policy, windows_sandbox_filesystem_overrides: None, @@ -128,11 +128,16 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { ("CODEX_THREAD_ID".to_string(), "thread-1".to_string()), ]) ); -} - -#[test] -fn exec_server_process_id_matches_unified_exec_process_id() { - assert_eq!(exec_server_process_id(/*process_id*/ 4321), "4321"); + request.exec_server_sandbox = Some( + codex_exec_server::FileSystemSandboxContext::from_permission_profile(permission_profile), + ); + let first = + exec_server_params_for_request(/*process_id*/ 123, &request, /*tty*/ true); + let second = + exec_server_params_for_request(/*process_id*/ 123, &request, /*tty*/ true); + assert!(first.process_id.as_str().starts_with("123-")); + assert!(second.process_id.as_str().starts_with("123-")); + assert_ne!(first.process_id, second.process_id); } #[cfg(windows)] diff --git a/codex-rs/core/src/unified_exec/process_state.rs b/codex-rs/core/src/unified_exec/process_state.rs index 267406da29..65e11b6f3e 100644 --- a/codex-rs/core/src/unified_exec/process_state.rs +++ b/codex-rs/core/src/unified_exec/process_state.rs @@ -3,6 +3,7 @@ pub(crate) struct ProcessState { pub(crate) has_exited: bool, pub(crate) exit_code: Option, pub(crate) failure_message: Option, + pub(crate) sandbox_denied: bool, } impl ProcessState { @@ -11,6 +12,7 @@ impl ProcessState { has_exited: true, exit_code, failure_message: self.failure_message.clone(), + sandbox_denied: self.sandbox_denied, } } @@ -19,6 +21,7 @@ impl ProcessState { has_exited: true, exit_code: self.exit_code, failure_message: Some(message), + sandbox_denied: self.sandbox_denied, } } } diff --git a/codex-rs/core/src/unified_exec/process_tests.rs b/codex-rs/core/src/unified_exec/process_tests.rs index 37ec5d858b..db64d461ec 100644 --- a/codex-rs/core/src/unified_exec/process_tests.rs +++ b/codex-rs/core/src/unified_exec/process_tests.rs @@ -10,7 +10,6 @@ use codex_exec_server::ReadResponse; use codex_exec_server::StartedExecProcess; use codex_exec_server::WriteResponse; use codex_exec_server::WriteStatus; -use codex_sandboxing::SandboxType; use pretty_assertions::assert_eq; use std::collections::VecDeque; use std::sync::Arc; @@ -40,6 +39,7 @@ impl MockExecProcess { exit_code: None, closed: false, failure: None, + sandbox_denied: false, })) } @@ -103,7 +103,7 @@ async fn remote_process( }), }; - UnifiedExecProcess::from_exec_server_started(started, SandboxType::None) + UnifiedExecProcess::from_exec_server_started(started) .await .expect("remote process should start") } @@ -190,6 +190,7 @@ async fn remote_process_waits_for_early_exit_event() { exit_code: Some(17), closed: true, failure: None, + sandbox_denied: false, }])), terminate_error: None, wake_tx: wake_tx.clone(), @@ -201,7 +202,7 @@ async fn remote_process_waits_for_early_exit_event() { let _ = wake_tx.send(1); }); - let process = UnifiedExecProcess::from_exec_server_started(started, SandboxType::None) + let process = UnifiedExecProcess::from_exec_server_started(started) .await .expect("remote process should observe early exit"); diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 337ca8d405..fa805fa446 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -87,6 +87,7 @@ async fn submit_turn_with_approval_and_environments( test: &TestCodex, prompt: &str, environments: Vec, + approval_policy: AskForApproval, ) -> Result<()> { let turn_environment_selections = codex_protocol::protocol::TurnEnvironmentSelections::new( test.config.cwd.clone(), @@ -103,7 +104,7 @@ async fn submit_turn_with_approval_and_environments( additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { environments: Some(turn_environment_selections), - approval_policy: Some(AskForApproval::OnRequest), + approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(SandboxPolicy::new_read_only_policy()), collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { @@ -312,6 +313,151 @@ async fn explicit_remote_shell_runs_in_remote_cwd() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_sandbox_denial_requests_approval_and_retries() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_wine_exec!(Ok(()), "requires the Docker-backed POSIX executor"); + let Some(_remote_env) = get_remote_test_env() else { + return Ok(()); + }; + + const CALL_ID: &str = "remote-sandbox-denial"; + const CONTENTS: &str = "remote sandbox retry succeeded"; + + let server = start_mock_server().await; + let test = unified_exec_test(&server).await?; + let nonce = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis(); + let remote_cwd = PathBuf::from(format!("/tmp/codex-remote-denial-cwd-{nonce}")).abs(); + let target_path = PathBuf::from(format!("/tmp/codex-remote-denial-target-{nonce}")).abs(); + let remote_cwd_uri = PathUri::from_path(&remote_cwd)?; + let target_uri = PathUri::from_path(&target_path)?; + test.fs() + .create_directory( + &remote_cwd_uri, + CreateDirectoryOptions { recursive: true }, + /*sandbox*/ None, + ) + .await?; + test.fs() + .remove( + &target_uri, + RemoveOptions { + recursive: false, + force: true, + }, + /*sandbox*/ None, + ) + .await?; + + let command = format!("printf {CONTENTS:?} > {target_path:?} && cat {target_path:?}"); + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-remote-denial-1"), + ev_function_call( + CALL_ID, + "exec_command", + &json!({ + "shell": "/bin/sh", + "cmd": command, + "login": false, + "yield_time_ms": 5_000, + "environment_id": REMOTE_ENVIRONMENT_ID, + }) + .to_string(), + ), + ev_completed("resp-remote-denial-1"), + ]), + sse(vec![ + ev_response_created("resp-remote-denial-2"), + ev_assistant_message("msg-remote-denial", "done"), + ev_completed("resp-remote-denial-2"), + ]), + ], + ) + .await; + + submit_turn_with_approval_and_environments( + &test, + "retry a sandbox-denied command in the remote environment", + vec![TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: PathUri::from_abs_path(&remote_cwd), + }], + AskForApproval::OnFailure, + ) + .await?; + + let event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) + ) + }) + .await; + let EventMsg::ExecApprovalRequest(approval) = event else { + panic!("expected remote sandbox approval before completion: {event:?}"); + }; + assert_eq!(approval.call_id, CALL_ID); + assert_eq!( + approval.environment_id.as_deref(), + Some(REMOTE_ENVIRONMENT_ID) + ); + assert_eq!( + approval.reason.as_deref(), + Some("command failed; retry without sandbox?") + ); + + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: ReviewDecision::Approved, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + assert!( + response_mock + .function_call_output_text(CALL_ID) + .is_some_and(|output| output.contains(CONTENTS)), + "approved retry should return the remote command output" + ); + assert_eq!( + test.fs() + .read_file_text(&target_uri, /*sandbox*/ None) + .await?, + CONTENTS + ); + + test.fs() + .remove( + &target_uri, + RemoveOptions { + recursive: false, + force: true, + }, + /*sandbox*/ None, + ) + .await?; + test.fs() + .remove( + &remote_cwd_uri, + RemoveOptions { + recursive: true, + force: true, + }, + /*sandbox*/ None, + ) + .await?; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn deferred_executor_does_not_duplicate_initial_environment_context() -> Result<()> { let server = start_mock_server().await; @@ -834,6 +980,7 @@ async fn remote_request_permissions_grant_unblocks_later_remote_exec() -> Result cwd: PathUri::from_abs_path(&remote_cwd), }, ], + AskForApproval::OnRequest, ) .await?; @@ -1115,6 +1262,7 @@ async fn apply_patch_approvals_are_remembered_per_environment() -> Result<()> { &test, "apply patch in local environment", environments.clone(), + AskForApproval::OnRequest, ) .await?; let approval = expect_patch_approval(&test, "call-local").await; @@ -1134,6 +1282,7 @@ async fn apply_patch_approvals_are_remembered_per_environment() -> Result<()> { &test, "apply patch in remote environment", environments.clone(), + AskForApproval::OnRequest, ) .await?; let approval = expect_patch_approval(&test, "call-remote").await; @@ -1158,6 +1307,7 @@ async fn apply_patch_approvals_are_remembered_per_environment() -> Result<()> { &test, "apply patch again in remote environment", environments, + AskForApproval::OnRequest, ) .await?; wait_for_completion_without_patch_approval(&test).await; diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index 88c0c80342..25d1b54e8b 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -926,6 +926,7 @@ impl SessionState { exit_code: None, closed: true, failure: Some(message), + sandbox_denied: false, } } @@ -1869,6 +1870,7 @@ mod tests { exit_code: None, closed: false, failure: None, + sandbox_denied: false, }) .expect("read response should serialize"), }), diff --git a/codex-rs/exec-server/src/client_recovery.rs b/codex-rs/exec-server/src/client_recovery.rs index 77feb0a4ba..f58e365728 100644 --- a/codex-rs/exec-server/src/client_recovery.rs +++ b/codex-rs/exec-server/src/client_recovery.rs @@ -58,6 +58,7 @@ impl SessionState { exit_code, closed, failure, + sandbox_denied: _, } = response; if let Some(message) = failure { return Err(ExecServerError::Protocol(format!( diff --git a/codex-rs/exec-server/src/local_process.rs b/codex-rs/exec-server/src/local_process.rs index 802c8e69cf..c7e99de53c 100644 --- a/codex-rs/exec-server/src/local_process.rs +++ b/codex-rs/exec-server/src/local_process.rs @@ -10,7 +10,11 @@ use std::time::Duration; use codex_app_server_protocol::JSONRPCErrorError; use codex_protocol::config_types::EnvironmentVariablePattern; use codex_protocol::config_types::ShellEnvironmentPolicy; +use codex_protocol::exec_output::ExecToolCallOutput; +use codex_protocol::exec_output::StreamOutput; use codex_protocol::shell_environment; +use codex_sandboxing::SandboxType; +use codex_sandboxing::is_likely_sandbox_denied; use codex_utils_pty::ExecCommandSession; use codex_utils_pty::ProcessSignal as PtyProcessSignal; use codex_utils_pty::TerminalSize; @@ -87,6 +91,8 @@ struct RunningProcess { output_notify: Arc, open_streams: usize, closed: bool, + sandbox: SandboxType, + sandbox_denied: bool, } /// Bounded cache of stdin write ids that have already been accepted for one process. @@ -313,6 +319,8 @@ impl LocalProcess { output_notify: Arc::clone(&output_notify), open_streams: 2, closed: false, + sandbox: prepared.sandbox, + sandbox_denied: false, })), ); } @@ -407,6 +415,7 @@ impl LocalProcess { exit_code: process.exit_code, closed: process.closed, failure: None, + sandbox_denied: process.sandbox_denied, }, Arc::clone(&process.output_notify), ) @@ -797,12 +806,46 @@ async fn watch_exit( output_notify: Arc, ) { let exit_code = exit_rx.await.unwrap_or(-1); + let sandboxed = { + let processes = inner.processes.lock().await; + matches!( + processes.get(&process_id), + Some(ProcessEntry::Running(process)) if process.sandbox != SandboxType::None + ) + }; + if sandboxed { + let _ = tokio::time::timeout(Duration::from_millis(20), output_notify.notified()).await; + } let notification = { let mut processes = inner.processes.lock().await; if let Some(ProcessEntry::Running(process)) = processes.get_mut(&process_id) { let seq = process.next_seq; process.next_seq += 1; process.exit_code = Some(exit_code); + if process.sandbox != SandboxType::None { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let mut aggregated = Vec::new(); + for chunk in &process.output { + match chunk.stream { + ExecOutputStream::Stdout | ExecOutputStream::Pty => { + stdout.extend_from_slice(&chunk.chunk); + } + ExecOutputStream::Stderr => stderr.extend_from_slice(&chunk.chunk), + } + aggregated.extend_from_slice(&chunk.chunk); + } + let exec_output = ExecToolCallOutput { + exit_code, + stdout: StreamOutput::new(String::from_utf8_lossy(&stdout).into_owned()), + stderr: StreamOutput::new(String::from_utf8_lossy(&stderr).into_owned()), + aggregated_output: StreamOutput::new( + String::from_utf8_lossy(&aggregated).into_owned(), + ), + ..Default::default() + }; + process.sandbox_denied = is_likely_sandbox_denied(process.sandbox, &exec_output); + } let _ = process.wake_tx.send(seq); process .events @@ -1000,6 +1043,7 @@ mod tests { exit_code: Some(0), closed: false, failure: None, + sandbox_denied: false, } ); @@ -1124,6 +1168,8 @@ mod tests { output_notify: Arc::clone(&output_notify), open_streams: 2, closed: false, + sandbox: SandboxType::None, + sandbox_denied: false, })), ); assert!(previous.is_none()); diff --git a/codex-rs/exec-server/src/process_sandbox.rs b/codex-rs/exec-server/src/process_sandbox.rs index be95b92d34..d157f4c6dc 100644 --- a/codex-rs/exec-server/src/process_sandbox.rs +++ b/codex-rs/exec-server/src/process_sandbox.rs @@ -20,6 +20,7 @@ pub(crate) struct PreparedExecRequest { pub(crate) cwd: AbsolutePathBuf, pub(crate) env: HashMap, pub(crate) arg0: Option, + pub(crate) sandbox: SandboxType, } pub(crate) fn prepare_exec_request( @@ -33,6 +34,7 @@ pub(crate) fn prepare_exec_request( cwd: native_path(¶ms.cwd, "cwd")?, env, arg0: params.arg0.clone(), + sandbox: SandboxType::None, }); }; let runtime_paths = runtime_paths @@ -116,6 +118,7 @@ pub(crate) fn prepare_exec_request( cwd: native_path(&request.cwd, "cwd")?, env: request.env, arg0: request.arg0, + sandbox: request.sandbox, }) } diff --git a/codex-rs/exec-server/src/protocol.rs b/codex-rs/exec-server/src/protocol.rs index e05595f273..99ee3431aa 100644 --- a/codex-rs/exec-server/src/protocol.rs +++ b/codex-rs/exec-server/src/protocol.rs @@ -153,6 +153,9 @@ pub struct ReadResponse { pub exit_code: Option, pub closed: bool, pub failure: Option, + /// Whether the executor classified the process failure as a sandbox denial. + #[serde(default)] + pub sandbox_denied: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/codex-rs/exec-server/tests/exec_process.rs b/codex-rs/exec-server/tests/exec_process.rs index ac38f50eaf..f35923815d 100644 --- a/codex-rs/exec-server/tests/exec_process.rs +++ b/codex-rs/exec-server/tests/exec_process.rs @@ -230,7 +230,7 @@ async fn assert_exec_process_streams_output(use_remote: bool) -> Result<()> { .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?; assert_eq!(output, "session output\n"); @@ -263,7 +263,7 @@ async fn assert_exec_process_pushes_events(use_remote: bool) -> Result<()> { .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let actual = collect_process_event_snapshots(process).await?; assert_eq!( actual, @@ -312,7 +312,7 @@ async fn assert_exec_process_replays_events_after_close(use_remote: bool) -> Res .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let read_result = collect_process_output_from_reads(Arc::clone(&process), wake_rx).await?; assert_eq!( @@ -362,7 +362,7 @@ async fn assert_exec_process_retains_output_after_exit_until_streams_close( .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let exit_response = timeout( Duration::from_secs(2), @@ -439,7 +439,7 @@ async fn assert_exec_process_write_then_read(use_remote: bool) -> Result<()> { tokio::time::sleep(Duration::from_millis(200)).await; session.process.write(b"hello\n".to_vec()).await?; - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?; @@ -479,7 +479,7 @@ async fn assert_exec_process_write_then_read_without_tty(use_remote: bool) -> Re tokio::time::sleep(Duration::from_millis(200)).await; let write_response = session.process.write(b"hello\n".to_vec()).await?; assert_eq!(write_response.status, WriteStatus::Accepted); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let actual = collect_process_output_from_reads(process, wake_rx).await?; @@ -513,7 +513,7 @@ async fn assert_exec_process_rejects_write_without_pipe_stdin(use_remote: bool) let write_response = session.process.write(b"ignored\n".to_vec()).await?; assert_eq!(write_response.status, WriteStatus::StdinClosed); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?; @@ -547,7 +547,7 @@ async fn assert_exec_process_signal_interrupts_process(use_remote: bool) -> Resu .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let mut wake_rx = process.subscribe_wake(); let mut ready_output = String::new(); let mut after_seq = None; @@ -645,7 +645,7 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe( tokio::time::sleep(Duration::from_millis(200)).await; - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?; assert_eq!(output, "queued output\n"); diff --git a/codex-rs/exec-server/tests/process.rs b/codex-rs/exec-server/tests/process.rs index 571fd427fa..ba7f64a610 100644 --- a/codex-rs/exec-server/tests/process.rs +++ b/codex-rs/exec-server/tests/process.rs @@ -70,7 +70,7 @@ async fn exec_server_starts_process_over_websocket() -> anyhow::Result<()> { assert_eq!( process_start_response, ExecResponse { - process_id: ProcessId::from("proc-1") + process_id: ProcessId::from("proc-1"), } ); @@ -135,7 +135,7 @@ async fn exec_server_defaults_omitted_pipe_stdin_to_closed_stdin() -> anyhow::Re assert_eq!( process_start_response, ExecResponse { - process_id: ProcessId::from("proc-default-stdin") + process_id: ProcessId::from("proc-default-stdin"), } ); diff --git a/codex-rs/exec-server/tests/relay.rs b/codex-rs/exec-server/tests/relay.rs index 5f49655e39..c9f873c158 100644 --- a/codex-rs/exec-server/tests/relay.rs +++ b/codex-rs/exec-server/tests/relay.rs @@ -157,7 +157,7 @@ async fn remote_environment_routes_encrypted_exec_server_rpc() -> Result<()> { assert_eq!( response, ExecResponse { - process_id: ProcessId::from("proc-1") + process_id: ProcessId::from("proc-1"), } ); diff --git a/codex-rs/sandboxing/src/denial.rs b/codex-rs/sandboxing/src/denial.rs new file mode 100644 index 0000000000..f355fa1c02 --- /dev/null +++ b/codex-rs/sandboxing/src/denial.rs @@ -0,0 +1,57 @@ +use codex_protocol::exec_output::ExecToolCallOutput; + +use crate::SandboxType; + +/// Returns whether a failed command was likely denied by the selected sandbox. +pub fn is_likely_sandbox_denied( + sandbox_type: SandboxType, + exec_output: &ExecToolCallOutput, +) -> bool { + if sandbox_type == SandboxType::None || exec_output.exit_code == 0 { + return false; + } + + const SANDBOX_DENIED_KEYWORDS: [&str; 7] = [ + "operation not permitted", + "permission denied", + "read-only file system", + "seccomp", + "sandbox", + "landlock", + "failed to write file", + ]; + + let has_sandbox_keyword = [ + &exec_output.stderr.text, + &exec_output.stdout.text, + &exec_output.aggregated_output.text, + ] + .into_iter() + .any(|section| { + let lower = section.to_lowercase(); + SANDBOX_DENIED_KEYWORDS + .iter() + .any(|needle| lower.contains(needle)) + }); + + if has_sandbox_keyword { + return true; + } + + const QUICK_REJECT_EXIT_CODES: [i32; 3] = [2, 126, 127]; + if QUICK_REJECT_EXIT_CODES.contains(&exec_output.exit_code) { + return false; + } + + #[cfg(unix)] + { + const EXIT_CODE_SIGNAL_BASE: i32 = 128; + if sandbox_type == SandboxType::LinuxSeccomp + && exec_output.exit_code == EXIT_CODE_SIGNAL_BASE + libc::SIGSYS + { + return true; + } + } + + false +} diff --git a/codex-rs/sandboxing/src/lib.rs b/codex-rs/sandboxing/src/lib.rs index 22c4984c5a..0688d0f920 100644 --- a/codex-rs/sandboxing/src/lib.rs +++ b/codex-rs/sandboxing/src/lib.rs @@ -1,5 +1,6 @@ #[cfg(target_os = "linux")] mod bwrap; +mod denial; pub mod landlock; mod manager; pub mod policy_transforms; @@ -11,6 +12,7 @@ mod windows; pub use bwrap::find_system_bwrap_in_path; #[cfg(target_os = "linux")] pub use bwrap::system_bwrap_warning; +pub use denial::is_likely_sandbox_denied; pub use manager::SandboxCommand; pub use manager::SandboxDirectSpawnTransformRequest; pub use manager::SandboxExecRequest;