diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index eb2439401b..83ca0d1191 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2748,6 +2748,7 @@ dependencies = [ "axum", "base64 0.22.1", "bytes", + "codex-api", "codex-app-server-protocol", "codex-client", "codex-file-system", @@ -2759,6 +2760,7 @@ dependencies = [ "codex-utils-rustls-provider", "ctor 0.6.3", "futures", + "http 1.4.0", "pretty_assertions", "prost 0.14.3", "reqwest", 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 8614140b35..a38290748d 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -596,23 +596,17 @@ impl TurnRequestProcessor { .map_err(|err| invalid_request(format!("invalid turn context override: {err}")))?; } + let turn_context = resolved_overrides + .map(op_turn_context_overrides) + .unwrap_or_default(); + // Start the turn by submitting the user input. Return its submission id as turn_id. - let turn_op = if let Some(overrides) = resolved_overrides { - Op::UserInput { - items: mapped_items, - environments: environment_selections, - final_output_json_schema: params.output_schema, - responsesapi_client_metadata: params.responsesapi_client_metadata, - turn_context: op_turn_context_overrides(overrides), - } - } else { - Op::UserInput { - items: mapped_items, - environments: environment_selections, - final_output_json_schema: params.output_schema, - responsesapi_client_metadata: params.responsesapi_client_metadata, - turn_context: Default::default(), - } + let turn_op = Op::UserInput { + items: mapped_items, + environments: environment_selections, + final_output_json_schema: params.output_schema, + responsesapi_client_metadata: params.responsesapi_client_metadata, + turn_context, }; let turn_id = Uuid::now_v7().to_string(); let turn_context_applied = if has_turn_context_overrides { diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 7ffdff0011..6c79c34c99 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -73,6 +73,8 @@ use codex_features::FEATURES; use codex_features::Stage; use codex_features::is_known_feature_key; use codex_login::AuthManager; +use codex_login::CodexAuth; +use codex_login::read_codex_access_token_from_env; use codex_memories_write::clear_memory_roots_contents; use codex_models_manager::bundled_models_response; use codex_models_manager::manager::RefreshStrategy; @@ -489,6 +491,10 @@ struct ExecServerCommand { /// Human-readable executor name. #[arg(long = "name", value_name = "NAME")] name: Option, + + /// Use Agent Identity auth from CODEX_ACCESS_TOKEN for remote registration. + #[arg(long = "use-agent-identity-auth", requires = "remote")] + use_agent_identity_auth: bool, } #[derive(Debug, clap::Subcommand)] @@ -1388,7 +1394,13 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { root_remote_auth_token_env.as_deref(), "exec-server", )?; - run_exec_server_command(cmd, &arg0_paths).await?; + run_exec_server_command( + cmd, + &arg0_paths, + &root_config_overrides, + interactive.config_profile.clone(), + ) + .await?; } Some(Subcommand::Features(FeaturesCli { sub })) => match sub { FeaturesSubcommand::List => { @@ -1485,6 +1497,8 @@ fn profile_v2_for_subcommand<'a>( async fn run_exec_server_command( cmd: ExecServerCommand, arg0_paths: &Arg0DispatchPaths, + root_config_overrides: &CliConfigOverrides, + config_profile: Option, ) -> anyhow::Result<()> { let codex_self_exe = arg0_paths .codex_self_exe @@ -1498,8 +1512,14 @@ async fn run_exec_server_command( let executor_id = cmd .executor_id .ok_or_else(|| anyhow::anyhow!("--executor-id is required when --remote is set"))?; + let auth_provider = load_exec_server_remote_auth_provider( + root_config_overrides, + config_profile, + cmd.use_agent_identity_auth, + ) + .await?; let mut remote_config = - codex_exec_server::RemoteExecutorConfig::new(base_url, executor_id)?; + codex_exec_server::RemoteExecutorConfig::new(base_url, executor_id, auth_provider)?; if let Some(name) = cmd.name { remote_config.name = name; } @@ -1515,6 +1535,75 @@ async fn run_exec_server_command( .map_err(anyhow::Error::from_boxed) } +async fn load_exec_server_remote_auth_provider( + root_config_overrides: &CliConfigOverrides, + config_profile: Option, + use_agent_identity_auth: bool, +) -> anyhow::Result { + let config = load_exec_server_remote_config(root_config_overrides, config_profile).await?; + if use_agent_identity_auth { + 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?; + return Ok(codex_model_provider::auth_provider_from_auth(&auth)); + } + + let auth = load_exec_server_remote_auth( + &config, + "remote exec-server registration requires ChatGPT authentication; run `codex login` first", + ) + .await?; + + if !auth.is_chatgpt_auth() { + anyhow::bail!( + "remote exec-server registration requires ChatGPT authentication; API key and Agent Identity auth are not supported" + ); + } + + Ok(codex_model_provider::auth_provider_from_auth(&auth)) +} + +async fn load_exec_server_remote_config( + root_config_overrides: &CliConfigOverrides, + config_profile: Option, +) -> anyhow::Result { + let cli_kv_overrides = root_config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + Ok(ConfigBuilder::default() + .cli_overrides(cli_kv_overrides) + .harness_overrides(ConfigOverrides { + config_profile, + ..Default::default() + }) + .build() + .await?) +} + +async fn load_exec_server_remote_auth( + config: &codex_core::config::Config, + missing_auth_error: &'static str, +) -> anyhow::Result { + let auth_manager = + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ true).await; + + let auth = match auth_manager.auth().await { + Some(auth) => auth, + None => { + auth_manager.reload().await; + auth_manager + .auth() + .await + .ok_or_else(|| anyhow::anyhow!(missing_auth_error))? + } + }; + + Ok(auth) +} + async fn enable_feature_in_config(interactive: &TuiCli, feature: &str) -> anyhow::Result<()> { FeatureToggles::validate_feature(feature)?; let codex_home = find_codex_home()?; 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 111ab67798..e1295b50de 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -9,6 +9,7 @@ use std::collections::BTreeMap; const SPAWN_AGENT_INHERITED_MODEL_GUIDANCE: &str = "Spawned agents inherit your current model by default. Omit `model` to use that preferred default; set `model` only when an explicit override is needed."; const SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION: &str = "Optional model override for the new agent. Leave unset to inherit the same model as the parent, which is the preferred default. Only set this when the user explicitly asks for a different model or the task clearly requires one."; const SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION: &str = "Optional service tier override for the new agent. Leave unset unless the user explicitly asks for one."; +const MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT_DESCRIPTION: usize = 5; #[derive(Debug, Clone, Default)] pub struct SpawnAgentToolOptions { @@ -711,8 +712,11 @@ The new agent's canonical task name will be provided to it along with the messag } fn spawn_agent_models_description(models: &[ModelPreset]) -> String { - let visible_models: Vec<&ModelPreset> = - models.iter().filter(|model| model.show_in_picker).collect(); + let visible_models: Vec<&ModelPreset> = models + .iter() + .filter(|model| model.show_in_picker) + .take(MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT_DESCRIPTION) + .collect(); if visible_models.is_empty() { return "No picker-visible model overrides are currently loaded.".to_string(); } @@ -720,30 +724,40 @@ fn spawn_agent_models_description(models: &[ModelPreset]) -> String { let model_descriptions = visible_models .into_iter() .map(|model| { + let default_reasoning_effort = model.default_reasoning_effort; let efforts = model .supported_reasoning_efforts .iter() - .map(|preset| format!("{} ({})", preset.effort, preset.description)) + .map(|preset| { + let effort = preset.effort; + if effort == default_reasoning_effort { + format!("{effort} (default)") + } else { + effort.to_string() + } + }) .collect::>() .join(", "); - let service_tiers = if model.service_tiers.is_empty() { - "none".to_string() + let reasoning_efforts_suffix = if efforts.is_empty() { + String::new() } else { - model - .service_tiers - .iter() - .map(|tier| format!("{} ({}: {})", tier.id, tier.name, tier.description)) - .collect::>() - .join(", ") + format!(" Reasoning efforts: {efforts}.") }; + let service_tiers = model + .service_tiers + .iter() + .map(|tier| tier.id.as_str()) + .collect::>() + .join(", "); + let service_tiers_suffix = if service_tiers.is_empty() { + String::new() + } else { + format!(" Service tiers: {service_tiers}.") + }; + let model_slug = &model.model; + let description = &model.description; format!( - "- {} (`{}`): {} Default reasoning effort: {}. Supported reasoning efforts: {}. Supported service tiers: {}.", - model.display_name, - model.model, - model.description, - model.default_reasoning_effort, - efforts, - service_tiers + "- `{model_slug}`: {description}{reasoning_efforts_suffix}{service_tiers_suffix}" ) }) .collect::>() 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 aaa29d6d4c..711db2fd11 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 @@ -74,12 +74,10 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() { description .contains("Available model overrides (optional; inherited parent model is preferred):") ); - assert!(description.contains("visible display (`visible-model`)")); - assert!( - description - .contains("Supported service tiers: priority (Fast: 1.5x speed, increased usage).") - ); - assert!(!description.contains("hidden display (`hidden-model`)")); + assert!(description.contains( + "- `visible-model`: visible description Reasoning efforts: medium (default). Service tiers: priority." + )); + assert!(!description.contains("hidden-model")); assert!(properties.contains_key("task_name")); assert!(properties.contains_key("message")); assert!(properties.contains_key("fork_turns")); @@ -150,6 +148,37 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() { ); } +#[test] +fn spawn_agent_tool_caps_visible_model_summaries() { + let tool = create_spawn_agent_tool_v2(SpawnAgentToolOptions { + available_models: vec![ + model_preset("first", /*show_in_picker*/ true), + model_preset("second", /*show_in_picker*/ true), + model_preset("third", /*show_in_picker*/ true), + model_preset("fourth", /*show_in_picker*/ true), + model_preset("fifth", /*show_in_picker*/ true), + model_preset("sixth", /*show_in_picker*/ true), + ], + agent_type_description: "role help".to_string(), + hide_agent_type_model_reasoning: false, + include_usage_hint: true, + usage_hint_text: None, + max_concurrent_threads_per_session: Some(4), + }); + + let ToolSpec::Function(ResponsesApiTool { description, .. }) = tool else { + panic!("spawn_agent should be a function tool"); + }; + + for model in ["first", "second", "third", "fourth", "fifth"] { + assert!( + description.contains(&format!("`{model}-model`")), + "expected {model} model summary in spawn_agent description: {description:?}" + ); + } + assert!(!description.contains("`sixth-model`")); +} + #[test] fn spawn_agent_tool_hides_service_tier_with_spawn_metadata() { let tool = create_spawn_agent_tool_v2(SpawnAgentToolOptions { diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index 5aab77cd7d..c70f9bce37 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -123,6 +123,10 @@ async fn spawn_agent_description_lists_visible_models_and_reasoning_efforts() -> effort: ReasoningEffort::Low, description: "Quick scan".to_string(), }, + ReasoningEffortPreset { + effort: ReasoningEffort::Medium, + description: "Balanced".to_string(), + }, ReasoningEffortPreset { effort: ReasoningEffort::High, description: "Deep dive".to_string(), @@ -175,7 +179,7 @@ async fn spawn_agent_description_lists_visible_models_and_reasoning_efforts() -> spawn_agent_description(&body).expect("spawn_agent description should be present"); assert!( - description.contains("- Visible Model (`visible-model`): Fast and capable"), + description.contains("- `visible-model`: Fast and capable"), "expected visible model summary in spawn_agent description: {description:?}" ); assert!( @@ -196,20 +200,15 @@ async fn spawn_agent_description_lists_visible_models_and_reasoning_efforts() -> "expected model override usage guidance in spawn_agent description: {description:?}" ); assert!( - description.contains("Default reasoning effort: medium."), + description.contains("Reasoning efforts: low, medium (default), high."), "expected default reasoning effort in spawn_agent description: {description:?}" ); assert!( - description.contains("low (Quick scan), high (Deep dive)."), - "expected reasoning efforts in spawn_agent description: {description:?}" - ); - assert!( - description - .contains("Supported service tiers: priority (Fast: 1.5x speed, increased usage)."), + description.contains("Service tiers: priority."), "expected service tier guidance in spawn_agent description: {description:?}" ); assert!( - !description.contains("Hidden Model"), + !description.contains("hidden-model"), "hidden picker model should be omitted from spawn_agent description: {description:?}" ); assert!( diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index 09a9a71ea0..d842094a16 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -17,6 +17,7 @@ axum = { workspace = true, features = ["http1", "tokio", "ws"] } base64 = { workspace = true } bytes = { workspace = true } codex-app-server-protocol = { workspace = true } +codex-api = { workspace = true } codex-client = { workspace = true } codex-file-system = { workspace = true } codex-protocol = { workspace = true } @@ -51,6 +52,7 @@ uuid = { workspace = true, features = ["v4"] } anyhow = { workspace = true } codex-test-binary-support = { workspace = true } ctor = { workspace = true } +http = { workspace = true } pretty_assertions = { workspace = true } serial_test = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/exec-server/README.md b/codex-rs/exec-server/README.md index 1eaf6e69eb..01439108f8 100644 --- a/codex-rs/exec-server/README.md +++ b/codex-rs/exec-server/README.md @@ -26,7 +26,11 @@ The CLI entrypoint supports: Remote mode registers the local exec-server with the executor registry, then reconnects to the service-provided rendezvous websocket as the executor. -It requires a bearer token in `CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN`. +It uses the standard Codex ChatGPT sign-in state; run `codex login` first when +remote registration needs authentication. Containerized callers that receive an +Agent Identity JWT in `CODEX_ACCESS_TOKEN` can opt into that auth path with +`--use-agent-identity-auth`; Codex then registers an Agent task and sends the +derived AgentAssertion headers on the registry request. Wire framing: @@ -376,7 +380,9 @@ The crate exports: Callers must pass `ExecServerRuntimePaths` to `run_main()`. The top-level `codex exec-server` command builds these paths from the `codex` arg0 dispatch -state. +state. `RemoteExecutorConfig::new(...)` also takes the auth provider that +remote registration should use; the CLI builds that provider from Codex auth +state before starting remote mode. ## Example session diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index 872f16ce32..bd556638ea 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -91,7 +91,6 @@ pub use protocol::TerminateResponse; pub use protocol::WriteParams; pub use protocol::WriteResponse; pub use protocol::WriteStatus; -pub use remote::CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN_ENV_VAR; pub use remote::RemoteExecutorConfig; pub use remote::run_remote_executor; pub use runtime_paths::ExecServerRuntimePaths; diff --git a/codex-rs/exec-server/src/remote.rs b/codex-rs/exec-server/src/remote.rs index 32c0d5bc8e..2493135c9a 100644 --- a/codex-rs/exec-server/src/remote.rs +++ b/codex-rs/exec-server/src/remote.rs @@ -1,6 +1,6 @@ -use std::env; use std::time::Duration; +use codex_api::SharedAuthProvider; use reqwest::StatusCode; use serde::Deserialize; use tokio::time::sleep; @@ -14,15 +14,12 @@ use crate::ExecServerRuntimePaths; use crate::relay::run_multiplexed_executor; use crate::server::ConnectionProcessor; -pub const CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN_ENV_VAR: &str = - "CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN"; - const ERROR_BODY_PREVIEW_BYTES: usize = 4096; #[derive(Clone)] struct ExecutorRegistryClient { base_url: String, - bearer_token: String, + auth_provider: SharedAuthProvider, http: reqwest::Client, } @@ -30,17 +27,17 @@ impl std::fmt::Debug for ExecutorRegistryClient { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ExecutorRegistryClient") .field("base_url", &self.base_url) - .field("bearer_token", &"") + .field("auth_provider", &"") .finish_non_exhaustive() } } impl ExecutorRegistryClient { - fn new(base_url: String, bearer_token: String) -> Result { + fn new(base_url: String, auth_provider: SharedAuthProvider) -> Result { let base_url = normalize_base_url(base_url)?; Ok(Self { base_url, - bearer_token, + auth_provider, http: reqwest::Client::new(), }) } @@ -55,7 +52,7 @@ impl ExecutorRegistryClient { &self.base_url, &format!("/cloud/executor/{executor_id}/register"), )) - .bearer_auth(&self.bearer_token) + .headers(self.auth_provider.to_auth_headers()) .send() .await?; self.parse_json_response(response).await @@ -89,12 +86,12 @@ struct ExecutorRegistryExecutorRegistrationResponse { } /// Configuration for registering an exec-server for remote use. -#[derive(Clone, Eq, PartialEq)] +#[derive(Clone)] pub struct RemoteExecutorConfig { pub base_url: String, pub executor_id: String, pub name: String, - bearer_token: String, + auth_provider: SharedAuthProvider, } impl std::fmt::Debug for RemoteExecutorConfig { @@ -103,28 +100,23 @@ impl std::fmt::Debug for RemoteExecutorConfig { .field("base_url", &self.base_url) .field("executor_id", &self.executor_id) .field("name", &self.name) - .field("bearer_token", &"") + .field("auth_provider", &"") .finish() } } impl RemoteExecutorConfig { - pub fn new(base_url: String, executor_id: String) -> Result { - Self::with_bearer_token(base_url, executor_id, read_remote_bearer_token_from_env()?) - } - - pub fn with_bearer_token( + pub fn new( base_url: String, executor_id: String, - bearer_token: String, + auth_provider: SharedAuthProvider, ) -> Result { let executor_id = normalize_executor_id(executor_id)?; - let bearer_token = normalize_bearer_token(bearer_token)?; Ok(Self { base_url, executor_id, name: "codex-exec-server".to_string(), - bearer_token, + auth_provider, }) } } @@ -136,7 +128,8 @@ pub async fn run_remote_executor( runtime_paths: ExecServerRuntimePaths, ) -> Result<(), ExecServerError> { ensure_rustls_crypto_provider(); - let client = ExecutorRegistryClient::new(config.base_url.clone(), config.bearer_token.clone())?; + let client = + ExecutorRegistryClient::new(config.base_url.clone(), config.auth_provider.clone())?; let processor = ConnectionProcessor::new(runtime_paths); let mut backoff = Duration::from_secs(1); @@ -162,32 +155,6 @@ pub async fn run_remote_executor( } } -fn read_remote_bearer_token_from_env() -> Result { - read_remote_bearer_token_from_env_with(|name| env::var(name)) -} - -fn read_remote_bearer_token_from_env_with(get_var: F) -> Result -where - F: FnOnce(&str) -> Result, -{ - let bearer_token = get_var(CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN_ENV_VAR).map_err(|_| { - ExecServerError::ExecutorRegistryAuth(format!( - "executor registry bearer token environment variable `{CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN_ENV_VAR}` is not set" - )) - })?; - normalize_bearer_token(bearer_token) -} - -fn normalize_bearer_token(bearer_token: String) -> Result { - let bearer_token = bearer_token.trim().to_string(); - if bearer_token.is_empty() { - return Err(ExecServerError::ExecutorRegistryAuth(format!( - "executor registry bearer token environment variable `{CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN_ENV_VAR}` is empty" - ))); - } - Ok(bearer_token) -} - fn normalize_executor_id(executor_id: String) -> Result { let executor_id = executor_id.trim().to_string(); if executor_id.is_empty() { @@ -274,6 +241,11 @@ fn preview_error_body(body: &str) -> Option { #[cfg(test)] mod tests { + use std::sync::Arc; + + use codex_api::AuthProvider; + use http::HeaderMap; + use http::HeaderValue; use pretty_assertions::assert_eq; use wiremock::Mock; use wiremock::MockServer; @@ -284,25 +256,46 @@ mod tests { use super::*; + #[derive(Debug)] + struct StaticRegistryAuthProvider; + + impl AuthProvider for StaticRegistryAuthProvider { + fn add_auth_headers(&self, headers: &mut HeaderMap) { + let _ = headers.insert( + http::header::AUTHORIZATION, + HeaderValue::from_static("Bearer registry-token"), + ); + let _ = headers.insert( + "ChatGPT-Account-ID", + HeaderValue::from_static("workspace-123"), + ); + } + } + + fn static_registry_auth_provider() -> SharedAuthProvider { + Arc::new(StaticRegistryAuthProvider) + } + #[tokio::test] - async fn register_executor_posts_with_bearer_token_header() { + async fn register_executor_posts_with_auth_provider_headers() { let server = MockServer::start().await; - let config = RemoteExecutorConfig::with_bearer_token( + let config = RemoteExecutorConfig::new( server.uri(), "exec-requested".to_string(), - "registry-token".to_string(), + static_registry_auth_provider(), ) .expect("config"); Mock::given(method("POST")) .and(path("/cloud/executor/exec-requested/register")) .and(header("authorization", "Bearer registry-token")) + .and(header("chatgpt-account-id", "workspace-123")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "executor_id": "exec-1", "url": "wss://rendezvous.test/executor/exec-1?role=executor&sig=abc" }))) .mount(&server) .await; - let client = ExecutorRegistryClient::new(server.uri(), "registry-token".to_string()) + let client = ExecutorRegistryClient::new(server.uri(), static_registry_auth_provider()) .expect("client"); let response = client @@ -320,17 +313,17 @@ mod tests { } #[test] - fn debug_output_redacts_bearer_token() { - let config = RemoteExecutorConfig::with_bearer_token( + fn debug_output_redacts_auth_provider() { + let config = RemoteExecutorConfig::new( "https://registry.example".to_string(), "exec-1".to_string(), - "secret-token".to_string(), + static_registry_auth_provider(), ) .expect("config"); let debug = format!("{config:?}"); assert!(debug.contains("")); - assert!(!debug.contains("secret-token")); + assert!(!debug.contains("workspace-123")); } } diff --git a/codex-rs/exec-server/tests/relay.rs b/codex-rs/exec-server/tests/relay.rs index d228db9ee5..9f985e6982 100644 --- a/codex-rs/exec-server/tests/relay.rs +++ b/codex-rs/exec-server/tests/relay.rs @@ -10,6 +10,7 @@ use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use anyhow::bail; +use codex_api::AuthProvider; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCNotification; @@ -22,12 +23,15 @@ use codex_exec_server::InitializeResponse; use codex_exec_server::RemoteExecutorConfig; use futures::SinkExt; use futures::StreamExt; +use http::HeaderMap; +use http::HeaderValue; use pretty_assertions::assert_eq; use prost::Message as ProstMessage; use relay_proto::RelayData; use relay_proto::RelayMessageFrame; use relay_proto::RelayReset; use relay_proto::relay_message_frame; +use std::sync::Arc; use tokio::net::TcpListener; use tokio::time::timeout; use tokio_tungstenite::WebSocketStream; @@ -46,6 +50,22 @@ const REGISTRY_TOKEN: &str = "registry-token"; const RELAY_MESSAGE_FRAME_VERSION: u32 = 1; const TEST_TIMEOUT: Duration = Duration::from_secs(5); +#[derive(Debug)] +struct StaticRegistryAuthProvider; + +impl AuthProvider for StaticRegistryAuthProvider { + fn add_auth_headers(&self, headers: &mut HeaderMap) { + let _ = headers.insert( + http::header::AUTHORIZATION, + HeaderValue::from_static("Bearer registry-token"), + ); + } +} + +fn static_registry_auth_provider() -> codex_api::SharedAuthProvider { + Arc::new(StaticRegistryAuthProvider) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn multiplexed_remote_executor_routes_independent_virtual_streams() -> Result<()> { let listener = TcpListener::bind("127.0.0.1:0").await?; @@ -63,10 +83,10 @@ async fn multiplexed_remote_executor_routes_independent_virtual_streams() -> Res let (codex_exe, codex_linux_sandbox_exe) = common::current_test_binary_helper_paths()?; let runtime_paths = ExecServerRuntimePaths::new(codex_exe, codex_linux_sandbox_exe)?; - let config = RemoteExecutorConfig::with_bearer_token( + let config = RemoteExecutorConfig::new( registry.uri(), EXECUTOR_ID.to_string(), - REGISTRY_TOKEN.to_string(), + static_registry_auth_provider(), )?; let remote_executor = tokio::spawn(codex_exec_server::run_remote_executor( config, diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index 045baacc7b..89e41eacb1 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -223,6 +223,7 @@ impl EventProcessorWithJsonOutput { arguments, result: result.map(|result| McpToolCallItemResult { content: result.content, + meta: result.meta, structured_content: result.structured_content, }), error: error.map(|error| McpToolCallItemError { 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 88fd042f7c..6bd358f6be 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 @@ -1,5 +1,6 @@ use super::*; use pretty_assertions::assert_eq; +use serde_json::json; use tempfile::tempdir; #[test] @@ -57,3 +58,53 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { "keep existing contents" ); } + +#[test] +fn mcp_tool_call_result_preserves_meta_in_jsonl_event() { + let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + codex_app_server_protocol::ItemCompletedNotification { + item: ThreadItem::McpToolCall { + id: "mcp-1".to_string(), + server: "search service".to_string(), + tool: "web_run".to_string(), + status: McpToolCallStatus::Completed, + arguments: json!({"search_query": [{"q": "OpenAI Codex CLI documentation"}]}), + mcp_app_resource_uri: None, + result: Some(Box::new(codex_app_server_protocol::McpToolCallResult { + content: vec![json!({"type": "text", "text": "search result"})], + structured_content: None, + meta: Some(json!({"raw_messages": [{"ref_id": "turn0search0"}]})), + })), + error: None, + duration_ms: Some(42), + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + completed_at_ms: 0, + }, + )); + + assert_eq!(collected.status, CodexStatus::Running); + assert_eq!(collected.events.len(), 1); + + let ThreadEvent::ItemCompleted(ItemCompletedEvent { item }) = &collected.events[0] else { + panic!("expected item.completed event"); + }; + let ThreadItemDetails::McpToolCall(item) = &item.details else { + panic!("expected MCP tool call item"); + }; + let result = item.result.as_ref().expect("expected MCP tool result"); + assert_eq!( + result.meta, + Some(json!({"raw_messages": [{"ref_id": "turn0search0"}]})) + ); + + let serialized = serde_json::to_value(&collected.events[0]).expect("serialize event"); + assert_eq!( + serialized["item"]["result"]["_meta"], + json!({"raw_messages": [{"ref_id": "turn0search0"}]}) + ); + assert!(serialized["item"]["result"].get("meta").is_none()); +} diff --git a/codex-rs/exec/src/exec_events.rs b/codex-rs/exec/src/exec_events.rs index 4a84ef7494..078a876ef4 100644 --- a/codex-rs/exec/src/exec_events.rs +++ b/codex-rs/exec/src/exec_events.rs @@ -266,6 +266,9 @@ pub struct McpToolCallItemResult { // representations). Using `JsonValue` keeps the payload wire-shaped and // easy to export. pub content: Vec, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub meta: Option, pub structured_content: Option, } 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 efda58f412..acbac8254c 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -540,6 +540,7 @@ fn mcp_tool_call_begin_and_end_emit_item_events() { arguments: json!({ "key": "value" }), result: Some(McpToolCallItemResult { content: Vec::new(), + meta: None, structured_content: None, }), error: None, @@ -681,6 +682,7 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() { "type": "text", "text": "done", })], + meta: None, structured_content: Some(json!({ "status": "ok" })), }), error: None, diff --git a/sdk/typescript/src/items.ts b/sdk/typescript/src/items.ts index 182878f55b..8fd3b2c7ff 100644 --- a/sdk/typescript/src/items.ts +++ b/sdk/typescript/src/items.ts @@ -60,6 +60,7 @@ export type McpToolCallItem = { /** Result payload returned by the MCP server for successful calls. */ result?: { content: McpContentBlock[]; + _meta?: unknown; structured_content: unknown; }; /** Error message reported for failed calls. */