From 4b01f0f50a640192e37c492464e4f76e9cc80fb6 Mon Sep 17 00:00:00 2001 From: Dylan Date: Thu, 16 Oct 2025 11:23:38 -0700 Subject: [PATCH 001/287] fix: tui default trusted settings should respect workspace write config (#3341) ## Summary When using the trusted state during tui startup, we created a new WorkspaceWrite policy without checking the config.toml for a `sandbox_workspace_write` field. This would result in us setting the sandbox_mode as workspace-write, but ignoring the field if the user had set `sandbox_workspace_write` without also setting `sandbox_mode` in the config.toml. This PR adds support for respecting `sandbox_workspace_write` setting in config.toml in the trusted directory flow, and adds tests to cover this case. ## Testing - [x] Added unit tests --- codex-rs/Cargo.lock | 1 + codex-rs/core/src/config.rs | 195 +++++++++++++++++++++++++++--------- codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/lib.rs | 151 ++++++++++------------------ 4 files changed, 203 insertions(+), 145 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 5576b7cba9..70514dd5da 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1422,6 +1422,7 @@ dependencies = [ "textwrap 0.16.2", "tokio", "tokio-stream", + "toml", "tracing", "tracing-appender", "tracing-subscriber", diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index f422e4ab81..47456a64b1 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -42,6 +42,7 @@ use codex_protocol::config_types::Verbosity; use codex_rmcp_client::OAuthCredentialsStoreMode; use dirs::home_dir; use serde::Deserialize; +use similar::DiffableStr; use std::collections::BTreeMap; use std::collections::HashMap; use std::io::ErrorKind; @@ -100,6 +101,10 @@ pub struct Config { pub sandbox_policy: SandboxPolicy, + /// True if the user passed in an override or set a value in config.toml + /// for either of approval_policy or sandbox_mode. + pub did_user_set_custom_approval_policy_or_sandbox_mode: bool, + pub shell_environment_policy: ShellEnvironmentPolicy, /// When `true`, `AgentReasoning` events emitted by the backend will be @@ -230,6 +235,10 @@ pub struct Config { /// The active profile name used to derive this `Config` (if any). pub active_profile: Option, + /// The currently active project config, resolved by checking if cwd: + /// is (1) part of a git repo, (2) a git worktree, or (3) just using the cwd + pub active_project: ProjectConfig, + /// Tracks whether the Windows onboarding screen has been acknowledged. pub windows_wsl_setup_acknowledged: bool, @@ -859,6 +868,15 @@ pub struct ProjectConfig { pub trust_level: Option, } +impl ProjectConfig { + pub fn is_trusted(&self) -> bool { + match &self.trust_level { + Some(trust_level) => trust_level == "trusted", + None => false, + } + } +} + #[derive(Deserialize, Debug, Clone, Default, PartialEq)] pub struct ToolsToml { #[serde(default, alias = "web_search_request")] @@ -880,9 +898,23 @@ impl From for Tools { impl ConfigToml { /// Derive the effective sandbox policy from the configuration. - fn derive_sandbox_policy(&self, sandbox_mode_override: Option) -> SandboxPolicy { + fn derive_sandbox_policy( + &self, + sandbox_mode_override: Option, + resolved_cwd: &Path, + ) -> SandboxPolicy { let resolved_sandbox_mode = sandbox_mode_override .or(self.sandbox_mode) + .or_else(|| { + // if no sandbox_mode is set, but user has marked directory as trusted, use WorkspaceWrite + self.get_active_project(resolved_cwd).and_then(|p| { + if p.is_trusted() { + Some(SandboxMode::WorkspaceWrite) + } else { + None + } + }) + }) .unwrap_or_default(); match resolved_sandbox_mode { SandboxMode::ReadOnly => SandboxPolicy::new_read_only_policy(), @@ -904,30 +936,26 @@ impl ConfigToml { } } - pub fn is_cwd_trusted(&self, resolved_cwd: &Path) -> bool { + /// Resolves the cwd to an existing project, or returns None if ConfigToml + /// does not contain a project corresponding to cwd or a git repo for cwd + pub fn get_active_project(&self, resolved_cwd: &Path) -> Option { let projects = self.projects.clone().unwrap_or_default(); - let is_path_trusted = |path: &Path| { - let path_str = path.to_string_lossy().to_string(); - projects - .get(&path_str) - .map(|p| p.trust_level.as_deref() == Some("trusted")) - .unwrap_or(false) - }; - - // Fast path: exact cwd match - if is_path_trusted(resolved_cwd) { - return true; + if let Some(project_config) = projects.get(&resolved_cwd.to_string_lossy().to_string()) { + return Some(project_config.clone()); } - // If cwd lives inside a git worktree, check whether the root git project + // If cwd lives inside a git repo/worktree, check whether the root git project // (the primary repository working directory) is trusted. This lets // worktrees inherit trust from the main project. - if let Some(root_project) = resolve_root_git_project_for_trust(resolved_cwd) { - return is_path_trusted(&root_project); + if let Some(repo_root) = resolve_root_git_project_for_trust(resolved_cwd) + && let Some(project_config_for_root) = + projects.get(&repo_root.to_string_lossy().to_string_lossy().to_string()) + { + return Some(project_config_for_root.clone()); } - false + None } pub fn get_config_profile( @@ -986,7 +1014,7 @@ impl Config { model, review_model: override_review_model, cwd, - approval_policy, + approval_policy: approval_policy_override, sandbox_mode, model_provider, config_profile: config_profile_key, @@ -1026,7 +1054,47 @@ impl Config { let features = Features::from_config(&cfg, &config_profile, feature_overrides); - let sandbox_policy = cfg.derive_sandbox_policy(sandbox_mode); + let resolved_cwd = { + use std::env; + + match cwd { + None => { + tracing::info!("cwd not set, using current dir"); + env::current_dir()? + } + Some(p) if p.is_absolute() => p, + Some(p) => { + // Resolve relative path against the current working directory. + tracing::info!("cwd is relative, resolving against current dir"); + let mut current = env::current_dir()?; + current.push(p); + current + } + } + }; + let active_project = cfg + .get_active_project(&resolved_cwd) + .unwrap_or(ProjectConfig { trust_level: None }); + + let sandbox_policy = cfg.derive_sandbox_policy(sandbox_mode, &resolved_cwd); + let mut approval_policy = approval_policy_override + .or(config_profile.approval_policy) + .or(cfg.approval_policy) + .unwrap_or_else(|| { + if active_project.is_trusted() { + // If no explicit approval policy is set, but we trust cwd, default to OnRequest + AskForApproval::OnRequest + } else { + AskForApproval::default() + } + }); + let did_user_set_custom_approval_policy_or_sandbox_mode = approval_policy_override + .is_some() + || config_profile.approval_policy.is_some() + || cfg.approval_policy.is_some() + // TODO(#3034): profile.sandbox_mode is not implemented + || sandbox_mode.is_some() + || cfg.sandbox_mode.is_some(); let mut model_providers = built_in_model_providers(); // Merge user-defined providers into the built-in list. @@ -1050,25 +1118,6 @@ impl Config { let shell_environment_policy = cfg.shell_environment_policy.into(); - let resolved_cwd = { - use std::env; - - match cwd { - None => { - tracing::info!("cwd not set, using current dir"); - env::current_dir()? - } - Some(p) if p.is_absolute() => p, - Some(p) => { - // Resolve relative path against the current working directory. - tracing::info!("cwd is relative, resolving against current dir"); - let mut current = env::current_dir()?; - current.push(p); - current - } - } - }; - let history = cfg.history.unwrap_or_default(); let include_plan_tool_flag = features.enabled(Feature::PlanTool); @@ -1125,11 +1174,6 @@ impl Config { .or(cfg.review_model) .unwrap_or_else(default_review_model); - let mut approval_policy = approval_policy - .or(config_profile.approval_policy) - .or(cfg.approval_policy) - .unwrap_or_else(AskForApproval::default); - if features.enabled(Feature::ApproveAll) { approval_policy = AskForApproval::OnRequest; } @@ -1146,6 +1190,7 @@ impl Config { cwd: resolved_cwd, approval_policy, sandbox_policy, + did_user_set_custom_approval_policy_or_sandbox_mode, shell_environment_policy, notify: cfg.notify, user_instructions, @@ -1200,6 +1245,7 @@ impl Config { include_view_image_tool: include_view_image_tool_flag, features, active_profile: active_profile_name, + active_project, windows_wsl_setup_acknowledged: cfg.windows_wsl_setup_acknowledged.unwrap_or(false), disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false), tui_notifications: cfg @@ -1395,7 +1441,8 @@ network_access = false # This should be ignored. let sandbox_mode_override = None; assert_eq!( SandboxPolicy::DangerFullAccess, - sandbox_full_access_cfg.derive_sandbox_policy(sandbox_mode_override) + sandbox_full_access_cfg + .derive_sandbox_policy(sandbox_mode_override, &PathBuf::from("/tmp/test")) ); let sandbox_read_only = r#" @@ -1410,7 +1457,8 @@ network_access = true # This should be ignored. let sandbox_mode_override = None; assert_eq!( SandboxPolicy::ReadOnly, - sandbox_read_only_cfg.derive_sandbox_policy(sandbox_mode_override) + sandbox_read_only_cfg + .derive_sandbox_policy(sandbox_mode_override, &PathBuf::from("/tmp/test")) ); let sandbox_workspace_write = r#" @@ -1434,7 +1482,36 @@ exclude_slash_tmp = true exclude_tmpdir_env_var: true, exclude_slash_tmp: true, }, - sandbox_workspace_write_cfg.derive_sandbox_policy(sandbox_mode_override) + sandbox_workspace_write_cfg + .derive_sandbox_policy(sandbox_mode_override, &PathBuf::from("/tmp/test")) + ); + + let sandbox_workspace_write = r#" +sandbox_mode = "workspace-write" + +[sandbox_workspace_write] +writable_roots = [ + "/my/workspace", +] +exclude_tmpdir_env_var = true +exclude_slash_tmp = true + +[projects."/tmp/test"] +trust_level = "trusted" +"#; + + let sandbox_workspace_write_cfg = toml::from_str::(sandbox_workspace_write) + .expect("TOML deserialization should succeed"); + let sandbox_mode_override = None; + assert_eq!( + SandboxPolicy::WorkspaceWrite { + writable_roots: vec![PathBuf::from("/my/workspace")], + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }, + sandbox_workspace_write_cfg + .derive_sandbox_policy(sandbox_mode_override, &PathBuf::from("/tmp/test")) ); } @@ -2221,6 +2298,7 @@ model_verbosity = "high" model_provider: fixture.openai_provider.clone(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), + did_user_set_custom_approval_policy_or_sandbox_mode: true, shell_environment_policy: ShellEnvironmentPolicy::default(), user_instructions: None, notify: None, @@ -2250,6 +2328,7 @@ model_verbosity = "high" include_view_image_tool: true, features: Features::with_defaults(), active_profile: Some("o3".to_string()), + active_project: ProjectConfig { trust_level: None }, windows_wsl_setup_acknowledged: false, disable_paste_burst: false, tui_notifications: Default::default(), @@ -2285,6 +2364,7 @@ model_verbosity = "high" model_provider: fixture.openai_chat_completions_provider.clone(), approval_policy: AskForApproval::UnlessTrusted, sandbox_policy: SandboxPolicy::new_read_only_policy(), + did_user_set_custom_approval_policy_or_sandbox_mode: true, shell_environment_policy: ShellEnvironmentPolicy::default(), user_instructions: None, notify: None, @@ -2314,6 +2394,7 @@ model_verbosity = "high" include_view_image_tool: true, features: Features::with_defaults(), active_profile: Some("gpt3".to_string()), + active_project: ProjectConfig { trust_level: None }, windows_wsl_setup_acknowledged: false, disable_paste_burst: false, tui_notifications: Default::default(), @@ -2364,6 +2445,7 @@ model_verbosity = "high" model_provider: fixture.openai_provider.clone(), approval_policy: AskForApproval::OnFailure, sandbox_policy: SandboxPolicy::new_read_only_policy(), + did_user_set_custom_approval_policy_or_sandbox_mode: true, shell_environment_policy: ShellEnvironmentPolicy::default(), user_instructions: None, notify: None, @@ -2393,6 +2475,7 @@ model_verbosity = "high" include_view_image_tool: true, features: Features::with_defaults(), active_profile: Some("zdr".to_string()), + active_project: ProjectConfig { trust_level: None }, windows_wsl_setup_acknowledged: false, disable_paste_burst: false, tui_notifications: Default::default(), @@ -2429,6 +2512,7 @@ model_verbosity = "high" model_provider: fixture.openai_provider.clone(), approval_policy: AskForApproval::OnFailure, sandbox_policy: SandboxPolicy::new_read_only_policy(), + did_user_set_custom_approval_policy_or_sandbox_mode: true, shell_environment_policy: ShellEnvironmentPolicy::default(), user_instructions: None, notify: None, @@ -2458,6 +2542,7 @@ model_verbosity = "high" include_view_image_tool: true, features: Features::with_defaults(), active_profile: Some("gpt5".to_string()), + active_project: ProjectConfig { trust_level: None }, windows_wsl_setup_acknowledged: false, disable_paste_burst: false, tui_notifications: Default::default(), @@ -2469,6 +2554,24 @@ model_verbosity = "high" Ok(()) } + #[test] + fn test_did_user_set_custom_approval_policy_or_sandbox_mode_defaults_no() -> anyhow::Result<()> + { + let fixture = create_test_fixture()?; + + let config = Config::load_from_base_config_with_overrides( + fixture.cfg.clone(), + ConfigOverrides { + ..Default::default() + }, + fixture.codex_home(), + )?; + + assert!(config.did_user_set_custom_approval_policy_or_sandbox_mode); + + Ok(()) + } + #[test] fn test_set_project_trusted_writes_explicit_tables() -> anyhow::Result<()> { let project_dir = Path::new("/some/path"); diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 2c6f32a20a..1ba3f0867b 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -78,6 +78,7 @@ tokio = { workspace = true, features = [ "signal", ] } tokio-stream = { workspace = true } +toml = { workspace = true } tracing = { workspace = true, features = ["log"] } tracing-appender = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter"] } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 01b1bd976a..7447ef5feb 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -13,12 +13,8 @@ use codex_core::INTERACTIVE_SESSION_SOURCES; use codex_core::RolloutRecorder; use codex_core::config::Config; use codex_core::config::ConfigOverrides; -use codex_core::config::ConfigToml; -use codex_core::config::find_codex_home; -use codex_core::config::load_config_as_toml_with_cli_overrides; use codex_core::find_conversation_path_by_id_str; use codex_core::protocol::AskForApproval; -use codex_core::protocol::SandboxPolicy; use codex_ollama::DEFAULT_OSS_MODEL; use codex_protocol::config_types::SandboxMode; use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge; @@ -192,52 +188,9 @@ pub async fn run_main( } }; - let mut config = { - // Load configuration and support CLI overrides. - - #[allow(clippy::print_stderr)] - match Config::load_with_cli_overrides(cli_kv_overrides.clone(), overrides).await { - Ok(config) => config, - Err(err) => { - eprintln!("Error loading configuration: {err}"); - std::process::exit(1); - } - } - }; - - // we load config.toml here to determine project state. - #[allow(clippy::print_stderr)] - let config_toml = { - let codex_home = match find_codex_home() { - Ok(codex_home) => codex_home, - Err(err) => { - eprintln!("Error finding codex home: {err}"); - std::process::exit(1); - } - }; - - match load_config_as_toml_with_cli_overrides(&codex_home, cli_kv_overrides).await { - Ok(config_toml) => config_toml, - Err(err) => { - eprintln!("Error loading config.toml: {err}"); - std::process::exit(1); - } - } - }; - - let cli_profile_override = cli.config_profile.clone(); - let active_profile = cli_profile_override - .clone() - .or_else(|| config_toml.profile.clone()); - - let should_show_trust_screen = determine_repo_trust_state( - &mut config, - &config_toml, - approval_policy, - sandbox_mode, - cli_profile_override, - )?; + let config = load_config_or_exit(cli_kv_overrides.clone(), overrides.clone()).await; + let active_profile = config.active_profile.clone(); let log_dir = codex_core::config::log_dir(&config)?; std::fs::create_dir_all(&log_dir)?; // Open (or create) your log file, appending to it. @@ -303,18 +256,18 @@ pub async fn run_main( let _ = tracing_subscriber::registry().with(file_layer).try_init(); }; - run_ratatui_app(cli, config, active_profile, should_show_trust_screen) + run_ratatui_app(cli, config, overrides, cli_kv_overrides, active_profile) .await .map_err(|err| std::io::Error::other(err.to_string())) } async fn run_ratatui_app( cli: Cli, - config: Config, + initial_config: Config, + overrides: ConfigOverrides, + cli_kv_overrides: Vec<(String, toml::Value)>, active_profile: Option, - should_show_trust_screen: bool, ) -> color_eyre::Result { - let mut config = config; color_eyre::install()?; // Forward panic reports through tracing so they appear in the UI status @@ -337,7 +290,7 @@ async fn run_ratatui_app( let skip_update_prompt = cli.prompt.as_ref().is_some_and(|prompt| !prompt.is_empty()); if !skip_update_prompt { - match update_prompt::run_update_prompt_if_needed(&mut tui, &config).await? { + match update_prompt::run_update_prompt_if_needed(&mut tui, &initial_config).await? { UpdatePromptOutcome::Continue => {} UpdatePromptOutcome::RunUpdate(action) => { crate::tui::restore()?; @@ -354,7 +307,7 @@ async fn run_ratatui_app( // Show update banner in terminal history (instead of stderr) so it is visible // within the TUI scrollback. Building spans keeps styling consistent. #[cfg(not(debug_assertions))] - if let Some(latest_version) = updates::get_upgrade_version(&config) { + if let Some(latest_version) = updates::get_upgrade_version(&initial_config) { use crate::history_cell::padded_emoji; use crate::history_cell::with_border_with_inner_width; use ratatui::style::Stylize as _; @@ -402,27 +355,29 @@ async fn run_ratatui_app( } // Initialize high-fidelity session event logging if enabled. - session_log::maybe_init(&config); + session_log::maybe_init(&initial_config); - let auth_manager = AuthManager::shared(config.codex_home.clone(), false); - let login_status = get_login_status(&config); + let auth_manager = AuthManager::shared(initial_config.codex_home.clone(), false); + let login_status = get_login_status(&initial_config); + let should_show_trust_screen = should_show_trust_screen(&initial_config); let should_show_windows_wsl_screen = - cfg!(target_os = "windows") && !config.windows_wsl_setup_acknowledged; + cfg!(target_os = "windows") && !initial_config.windows_wsl_setup_acknowledged; let should_show_onboarding = should_show_onboarding( login_status, - &config, + &initial_config, should_show_trust_screen, should_show_windows_wsl_screen, ); - if should_show_onboarding { + + let config = if should_show_onboarding { let onboarding_result = run_onboarding_app( OnboardingScreenArgs { + show_login_screen: should_show_login_screen(login_status, &initial_config), show_windows_wsl_screen: should_show_windows_wsl_screen, - show_login_screen: should_show_login_screen(login_status, &config), show_trust_screen: should_show_trust_screen, login_status, auth_manager: auth_manager.clone(), - config: config.clone(), + config: initial_config.clone(), }, &mut tui, ) @@ -440,14 +395,20 @@ async fn run_ratatui_app( update_action: None, }); } - if should_show_windows_wsl_screen { - config.windows_wsl_setup_acknowledged = true; + // if the user acknowledged windows or made an explicit decision ato trust the directory, reload the config accordingly + if should_show_windows_wsl_screen + || onboarding_result + .directory_trust_decision + .map(|d| d == TrustDirectorySelection::Trust) + .unwrap_or(false) + { + load_config_or_exit(cli_kv_overrides, overrides).await + } else { + initial_config } - if let Some(TrustDirectorySelection::Trust) = onboarding_result.directory_trust_decision { - config.approval_policy = AskForApproval::OnRequest; - config.sandbox_policy = SandboxPolicy::new_workspace_write_policy(); - } - } + } else { + initial_config + }; // Determine resume behavior: explicit id, then resume last, then picker. let resume_selection = if let Some(id_str) = cli.resume_session_id.as_deref() { @@ -588,39 +549,31 @@ fn get_login_status(config: &Config) -> LoginStatus { } } -/// Determine if user has configured a sandbox / approval policy, -/// or if the current cwd project is trusted, and updates the config -/// accordingly. -fn determine_repo_trust_state( - config: &mut Config, - config_toml: &ConfigToml, - approval_policy_overide: Option, - sandbox_mode_override: Option, - config_profile_override: Option, -) -> std::io::Result { - let config_profile = config_toml.get_config_profile(config_profile_override)?; +async fn load_config_or_exit( + cli_kv_overrides: Vec<(String, toml::Value)>, + overrides: ConfigOverrides, +) -> Config { + #[allow(clippy::print_stderr)] + match Config::load_with_cli_overrides(cli_kv_overrides, overrides).await { + Ok(config) => config, + Err(err) => { + eprintln!("Error loading configuration: {err}"); + std::process::exit(1); + } + } +} - if approval_policy_overide.is_some() || sandbox_mode_override.is_some() { +/// Determine if user has configured a sandbox / approval policy, +/// or if the current cwd project is already trusted. If not, we need to +/// show the trust screen. +fn should_show_trust_screen(config: &Config) -> bool { + if config.did_user_set_custom_approval_policy_or_sandbox_mode { // if the user has overridden either approval policy or sandbox mode, // skip the trust flow - Ok(false) - } else if config_profile.approval_policy.is_some() { - // if the user has specified settings in a config profile, skip the trust flow - // todo: profile sandbox mode? - Ok(false) - } else if config_toml.approval_policy.is_some() || config_toml.sandbox_mode.is_some() { - // if the user has specified either approval policy or sandbox mode in config.toml - // skip the trust flow - Ok(false) - } else if config_toml.is_cwd_trusted(&config.cwd) { - // if the current cwd project is trusted and no config has been set - // skip the trust flow and set the approval policy and sandbox mode - config.approval_policy = AskForApproval::OnRequest; - config.sandbox_policy = SandboxPolicy::new_workspace_write_policy(); - Ok(false) + false } else { - // if none of the above conditions are met, show the trust screen - Ok(true) + // otherwise, skip iff the active project is trusted + !config.active_project.is_trusted() } } From ed5b0bfeb3b1826f1c3e37efcb3fbe8479eb65fb Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 16 Oct 2025 14:51:42 -0700 Subject: [PATCH 002/287] Improve error decoding response body error (#5263) Split Reqwest error into separate error: 1. One for streaming response 2. One for initial connection failing Include request_id where possible. image --- codex-rs/core/src/chat_completions.rs | 13 +++++++-- codex-rs/core/src/client.rs | 13 +++++++-- codex-rs/core/src/error.rs | 40 +++++++++++++++++++++++++-- 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs index d6f394fb86..de0b9d4151 100644 --- a/codex-rs/core/src/chat_completions.rs +++ b/codex-rs/core/src/chat_completions.rs @@ -5,6 +5,8 @@ use crate::client_common::Prompt; use crate::client_common::ResponseEvent; use crate::client_common::ResponseStream; use crate::error::CodexErr; +use crate::error::ConnectionFailedError; +use crate::error::ResponseStreamFailed; use crate::error::Result; use crate::error::RetryLimitReachedError; use crate::error::UnexpectedResponseError; @@ -309,7 +311,12 @@ pub(crate) async fn stream_chat_completions( match res { Ok(resp) if resp.status().is_success() => { let (tx_event, rx_event) = mpsc::channel::>(1600); - let stream = resp.bytes_stream().map_err(CodexErr::Reqwest); + let stream = resp.bytes_stream().map_err(|e| { + CodexErr::ResponseStreamFailed(ResponseStreamFailed { + source: e, + request_id: None, + }) + }); tokio::spawn(process_chat_sse( stream, tx_event, @@ -349,7 +356,9 @@ pub(crate) async fn stream_chat_completions( } Err(e) => { if attempt > max_retries { - return Err(e.into()); + return Err(CodexErr::ConnectionFailed(ConnectionFailedError { + source: e, + })); } let delay = backoff(attempt); tokio::time::sleep(delay).await; diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index f59f30c5cf..3a259fce67 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -5,6 +5,8 @@ use std::time::Duration; use crate::AuthManager; use crate::auth::CodexAuth; +use crate::error::ConnectionFailedError; +use crate::error::ResponseStreamFailed; use crate::error::RetryLimitReachedError; use crate::error::UnexpectedResponseError; use bytes::Bytes; @@ -351,7 +353,12 @@ impl ModelClient { } // spawn task to process SSE - let stream = resp.bytes_stream().map_err(CodexErr::Reqwest); + let stream = resp.bytes_stream().map_err(move |e| { + CodexErr::ResponseStreamFailed(ResponseStreamFailed { + source: e, + request_id: request_id.clone(), + }) + }); tokio::spawn(process_sse( stream, tx_event, @@ -431,7 +438,9 @@ impl ModelClient { request_id, }) } - Err(e) => Err(StreamAttemptError::RetryableTransportError(e.into())), + Err(e) => Err(StreamAttemptError::RetryableTransportError( + CodexErr::ConnectionFailed(ConnectionFailedError { source: e }), + )), } } diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 786e20523a..da2a868d70 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -91,6 +91,12 @@ pub enum CodexErr { #[error("{0}")] UsageLimitReached(UsageLimitReachedError), + #[error("{0}")] + ResponseStreamFailed(ResponseStreamFailed), + + #[error("{0}")] + ConnectionFailed(ConnectionFailedError), + #[error( "To use Codex with your ChatGPT plan, upgrade to Plus: https://openai.com/chatgpt/pricing." )] @@ -126,9 +132,6 @@ pub enum CodexErr { #[error(transparent)] Io(#[from] io::Error), - #[error(transparent)] - Reqwest(#[from] reqwest::Error), - #[error(transparent)] Json(#[from] serde_json::Error), @@ -147,6 +150,37 @@ pub enum CodexErr { EnvVar(EnvVarError), } +#[derive(Debug)] +pub struct ConnectionFailedError { + pub source: reqwest::Error, +} + +impl std::fmt::Display for ConnectionFailedError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Connection failed: {}", self.source) + } +} + +#[derive(Debug)] +pub struct ResponseStreamFailed { + pub source: reqwest::Error, + pub request_id: Option, +} + +impl std::fmt::Display for ResponseStreamFailed { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Error while reading the server response: {}{}", + self.source, + self.request_id + .as_ref() + .map(|id| format!(", request id: {id}")) + .unwrap_or_default() + ) + } +} + #[derive(Debug)] pub struct UnexpectedResponseError { pub status: StatusCode, From fc1723f13185a06e29ee263c05b6e0750024daaf Mon Sep 17 00:00:00 2001 From: Javi <666807+JaviSoto@users.noreply.github.com> Date: Thu, 16 Oct 2025 14:58:44 -0700 Subject: [PATCH 003/287] Revert "feat(CI): Allow running `rust-release` manually and in dry-run mode (#5232)" (#5266) This reverts commit 5fa7844ad728946b2760bb08b4fa1db98f778ebf. --- .github/workflows/rust-release.yml | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 7654e696cf..94143acb49 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -10,16 +10,6 @@ on: push: tags: - "rust-v*.*.*" - workflow_dispatch: - inputs: - branch: - description: "Branch or tag to build" - required: true - default: "main" - dry_run: - description: "Skip publishing the release (dry run)" - type: boolean - default: false concurrency: group: ${{ github.workflow }} @@ -30,11 +20,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - with: - ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch || github.ref }} - name: Validate tag matches Cargo.toml version - if: ${{ github.event_name == 'push' }} shell: bash run: | set -euo pipefail @@ -58,10 +45,6 @@ jobs: echo "✅ Tag and Cargo.toml agree (${tag_ver})" echo "::endgroup::" - - name: Skip tag validation (manual run) - if: ${{ github.event_name != 'push' }} - run: echo "Tag validation skipped for workflow_dispatch runs." - build: needs: tag-check name: Build - ${{ matrix.runner }} - ${{ matrix.target }} @@ -94,8 +77,6 @@ jobs: steps: - uses: actions/checkout@v5 - with: - ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch || github.ref }} - uses: dtolnay/rust-toolchain@1.90 with: targets: ${{ matrix.target }} @@ -381,7 +362,6 @@ jobs: needs: build name: release runs-on: ubuntu-latest - if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/rust-v') }} permissions: contents: write actions: read @@ -476,7 +456,7 @@ jobs: # npm docs: https://docs.npmjs.com/trusted-publishers publish-npm: # Publish to npm for stable releases and alpha pre-releases with numeric suffixes. - if: ${{ needs.release.result == 'success' && needs.release.outputs.should_publish_npm == 'true' }} + if: ${{ needs.release.outputs.should_publish_npm == 'true' }} name: publish-npm needs: release runs-on: ubuntu-latest @@ -545,7 +525,6 @@ jobs: contents: write needs: release runs-on: ubuntu-latest - if: ${{ needs.release.result == 'success' }} steps: - name: Update latest-alpha-cli branch From 78f2785595331b79f24278193846e51436356336 Mon Sep 17 00:00:00 2001 From: Dylan Date: Thu, 16 Oct 2025 17:31:46 -0700 Subject: [PATCH 004/287] feat(tui): Add confirmation prompt for enabling full access approvals (#4980) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds a confirmation screen when a user attempts to select Full Access via the `/approvals` flow in the TUI. If the user selects the remember option, the preference is persisted to config.toml as `full_access_warning_acknowledged`, so they will not be prompted again. ## Testing - [x] Adds snapshot test coverage for the approvals flow and the confirmation flow Screenshot 2025-10-08 at 6 04 59 PM ------ https://chatgpt.com/codex/tasks/task_i_68e6c5c458088322a28efa3207058180 --------- Co-authored-by: Fouad Matin <169186268+fouad-openai@users.noreply.github.com> Co-authored-by: Fouad Matin --- codex-rs/core/src/config.rs | 61 +++++++++ codex-rs/core/src/config_types.rs | 14 ++ codex-rs/tui/src/app.rs | 21 +++ codex-rs/tui/src/app_event.rs | 15 +++ codex-rs/tui/src/chatwidget.rs | 121 ++++++++++++++++-- ...get__tests__approvals_selection_popup.snap | 17 +++ ...tests__full_access_confirmation_popup.snap | 15 +++ codex-rs/tui/src/chatwidget/tests.rs | 26 ++++ 8 files changed, 276 insertions(+), 14 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 47456a64b1..ae41407968 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -7,6 +7,7 @@ use crate::config_types::DEFAULT_OTEL_ENVIRONMENT; use crate::config_types::History; use crate::config_types::McpServerConfig; use crate::config_types::McpServerTransportConfig; +use crate::config_types::Notice; use crate::config_types::Notifications; use crate::config_types::OtelConfig; use crate::config_types::OtelConfigToml; @@ -242,6 +243,9 @@ pub struct Config { /// Tracks whether the Windows onboarding screen has been acknowledged. pub windows_wsl_setup_acknowledged: bool, + /// Collection of various notices we show the user + pub notices: Notice, + /// When true, disables burst-paste detection for typed input entirely. /// All characters are inserted as they are received, and no buffering /// or placeholder replacement will occur for fast keypress bursts. @@ -557,6 +561,54 @@ pub fn set_windows_wsl_setup_acknowledged( Ok(()) } +/// Persist the acknowledgement flag for the full access warning prompt. +pub fn set_hide_full_access_warning(codex_home: &Path, acknowledged: bool) -> anyhow::Result<()> { + let config_path = codex_home.join(CONFIG_TOML_FILE); + let mut doc = match std::fs::read_to_string(config_path.clone()) { + Ok(s) => s.parse::()?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(), + Err(e) => return Err(e.into()), + }; + + let notices_table = load_or_create_top_level_table(&mut doc, Notice::TABLE_KEY)?; + + notices_table["hide_full_access_warning"] = toml_edit::value(acknowledged); + + std::fs::create_dir_all(codex_home)?; + let tmp_file = NamedTempFile::new_in(codex_home)?; + std::fs::write(tmp_file.path(), doc.to_string())?; + tmp_file.persist(config_path)?; + + Ok(()) +} + +fn load_or_create_top_level_table<'a>( + doc: &'a mut DocumentMut, + key: &str, +) -> anyhow::Result<&'a mut toml_edit::Table> { + let mut created_table = false; + + let root = doc.as_table_mut(); + let needs_table = + !root.contains_key(key) || root.get(key).and_then(|item| item.as_table()).is_none(); + if needs_table { + root.insert(key, toml_edit::table()); + created_table = true; + } + + let Some(table) = doc[key].as_table_mut() else { + return Err(anyhow::anyhow!(format!( + "table [{key}] missing after initialization" + ))); + }; + + if created_table { + table.set_implicit(true); + } + + Ok(table) +} + fn ensure_profile_table<'a>( doc: &'a mut DocumentMut, profile_name: &str, @@ -832,6 +884,10 @@ pub struct ConfigToml { /// Tracks whether the Windows onboarding screen has been acknowledged. pub windows_wsl_setup_acknowledged: Option, + /// Collection of in-product notices (different from notifications) + /// See [`crate::config_types::Notices`] for more details + pub notice: Option, + /// Legacy, now use features pub experimental_instructions_file: Option, pub experimental_use_exec_command_tool: Option, @@ -1247,6 +1303,7 @@ impl Config { active_profile: active_profile_name, active_project, windows_wsl_setup_acknowledged: cfg.windows_wsl_setup_acknowledged.unwrap_or(false), + notices: cfg.notice.unwrap_or_default(), disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false), tui_notifications: cfg .tui @@ -2330,6 +2387,7 @@ model_verbosity = "high" active_profile: Some("o3".to_string()), active_project: ProjectConfig { trust_level: None }, windows_wsl_setup_acknowledged: false, + notices: Default::default(), disable_paste_burst: false, tui_notifications: Default::default(), otel: OtelConfig::default(), @@ -2396,6 +2454,7 @@ model_verbosity = "high" active_profile: Some("gpt3".to_string()), active_project: ProjectConfig { trust_level: None }, windows_wsl_setup_acknowledged: false, + notices: Default::default(), disable_paste_burst: false, tui_notifications: Default::default(), otel: OtelConfig::default(), @@ -2477,6 +2536,7 @@ model_verbosity = "high" active_profile: Some("zdr".to_string()), active_project: ProjectConfig { trust_level: None }, windows_wsl_setup_acknowledged: false, + notices: Default::default(), disable_paste_burst: false, tui_notifications: Default::default(), otel: OtelConfig::default(), @@ -2544,6 +2604,7 @@ model_verbosity = "high" active_profile: Some("gpt5".to_string()), active_project: ProjectConfig { trust_level: None }, windows_wsl_setup_acknowledged: false, + notices: Default::default(), disable_paste_burst: false, tui_notifications: Default::default(), otel: OtelConfig::default(), diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index 0ef9248511..b724086a51 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -322,6 +322,20 @@ pub struct Tui { pub notifications: Notifications, } +/// Settings for notices we display to users via the tui and app-server clients +/// (primarily the Codex IDE extension). NOTE: these are different from +/// notifications - notices are warnings, NUX screens, acknowledgements, etc. +#[derive(Deserialize, Debug, Clone, PartialEq, Default)] +pub struct Notice { + /// Tracks whether the user has acknowledged the full access warning prompt. + pub hide_full_access_warning: Option, +} + +impl Notice { + /// used by set_hide_full_access_warning until we refactor config updates + pub(crate) const TABLE_KEY: &'static str = "notice"; +} + #[derive(Deserialize, Debug, Clone, PartialEq, Default)] pub struct SandboxWorkspaceWrite { #[serde(default)] diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 6124f377ec..11a10d6d11 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -18,6 +18,7 @@ use codex_core::AuthManager; use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::config::persist_model_selection; +use codex_core::config::set_hide_full_access_warning; use codex_core::model_family::find_family_for_model; use codex_core::protocol::SessionSource; use codex_core::protocol::TokenUsage; @@ -334,6 +335,9 @@ impl App { AppEvent::OpenReasoningPopup { model, presets } => { self.chat_widget.open_reasoning_popup(model, presets); } + AppEvent::OpenFullAccessConfirmation { preset } => { + self.chat_widget.open_full_access_confirmation(preset); + } AppEvent::PersistModelSelection { model, effort } => { let profile = self.active_profile.as_deref(); match persist_model_selection(&self.config.codex_home, profile, &model, effort) @@ -379,6 +383,23 @@ impl App { AppEvent::UpdateSandboxPolicy(policy) => { self.chat_widget.set_sandbox_policy(policy); } + AppEvent::UpdateFullAccessWarningAcknowledged(ack) => { + self.chat_widget.set_full_access_warning_acknowledged(ack); + } + AppEvent::PersistFullAccessWarningAcknowledged => { + if let Err(err) = set_hide_full_access_warning(&self.config.codex_home, true) { + tracing::error!( + error = %err, + "failed to persist full access warning acknowledgement" + ); + self.chat_widget.add_error_message(format!( + "Failed to save full access confirmation preference: {err}" + )); + } + } + AppEvent::OpenApprovalsPopup => { + self.chat_widget.open_approvals_popup(); + } AppEvent::OpenReviewBranchPicker(cwd) => { self.chat_widget.show_review_branch_picker(&cwd).await; } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 9d79c8ae13..402b0fa46d 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +use codex_common::approval_presets::ApprovalPreset; use codex_common::model_presets::ModelPreset; use codex_core::protocol::ConversationPathResponseEvent; use codex_core::protocol::Event; @@ -67,12 +68,26 @@ pub(crate) enum AppEvent { presets: Vec, }, + /// Open the confirmation prompt before enabling full access mode. + OpenFullAccessConfirmation { + preset: ApprovalPreset, + }, + /// Update the current approval policy in the running app and widget. UpdateAskForApprovalPolicy(AskForApproval), /// Update the current sandbox policy in the running app and widget. UpdateSandboxPolicy(SandboxPolicy), + /// Update whether the full access warning prompt has been acknowledged. + UpdateFullAccessWarningAcknowledged(bool), + + /// Persist the acknowledgement flag for the full access warning prompt. + PersistFullAccessWarningAcknowledged, + + /// Re-open the approval presets popup. + OpenApprovalsPopup, + /// Forwarded conversation history snapshot from the current conversation. ConversationHistory(ConversationPathResponseEvent), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 88249f139b..775242a597 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -54,10 +54,13 @@ use ratatui::buffer::Buffer; use ratatui::layout::Constraint; use ratatui::layout::Layout; use ratatui::layout::Rect; +use ratatui::style::Color; use ratatui::style::Stylize; use ratatui::text::Line; +use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; +use ratatui::widgets::Wrap; use tokio::sync::mpsc::UnboundedSender; use tracing::debug; @@ -85,6 +88,7 @@ use crate::history_cell::HistoryCell; use crate::history_cell::McpToolCallCell; use crate::markdown::append_markdown; use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; use crate::slash_command::SlashCommand; use crate::status::RateLimitSnapshotDisplay; use crate::text_formatting::truncate_text; @@ -1819,22 +1823,24 @@ impl ChatWidget { for preset in presets.into_iter() { let is_current = current_approval == preset.approval && current_sandbox == preset.sandbox; - let approval = preset.approval; - let sandbox = preset.sandbox.clone(); let name = preset.label.to_string(); let description = Some(preset.description.to_string()); - let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { - cwd: None, - approval_policy: Some(approval), - sandbox_policy: Some(sandbox.clone()), - model: None, - effort: None, - summary: None, - })); - tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); - tx.send(AppEvent::UpdateSandboxPolicy(sandbox.clone())); - })]; + let requires_confirmation = preset.id == "full-access" + && !self + .config + .notices + .hide_full_access_warning + .unwrap_or(false); + let actions: Vec = if requires_confirmation { + let preset_clone = preset.clone(); + vec![Box::new(move |tx| { + tx.send(AppEvent::OpenFullAccessConfirmation { + preset: preset_clone.clone(), + }); + })] + } else { + Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + }; items.push(SelectionItem { name, description, @@ -1853,6 +1859,89 @@ impl ChatWidget { }); } + fn approval_preset_actions( + approval: AskForApproval, + sandbox: SandboxPolicy, + ) -> Vec { + vec![Box::new(move |tx| { + let sandbox_clone = sandbox.clone(); + tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { + cwd: None, + approval_policy: Some(approval), + sandbox_policy: Some(sandbox_clone.clone()), + model: None, + effort: None, + summary: None, + })); + tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); + tx.send(AppEvent::UpdateSandboxPolicy(sandbox_clone)); + })] + } + + pub(crate) fn open_full_access_confirmation(&mut self, preset: ApprovalPreset) { + let approval = preset.approval; + let sandbox = preset.sandbox; + let mut header_children: Vec> = Vec::new(); + let title_line = Line::from("Enable full access?").bold(); + let info_line = Line::from(vec![ + "When Codex runs with full access, it can edit any file on your computer and run commands with network, without your approval. " + .into(), + "Exercise caution when enabling full access. This significantly increases the risk of data loss, leaks, or unexpected behavior." + .fg(Color::Red), + ]); + header_children.push(Box::new(title_line)); + header_children.push(Box::new( + Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }), + )); + let header = ColumnRenderable::with(header_children); + + let mut accept_actions = Self::approval_preset_actions(approval, sandbox.clone()); + accept_actions.push(Box::new(|tx| { + tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); + })); + + let mut accept_and_remember_actions = Self::approval_preset_actions(approval, sandbox); + accept_and_remember_actions.push(Box::new(|tx| { + tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); + tx.send(AppEvent::PersistFullAccessWarningAcknowledged); + })); + + let deny_actions: Vec = vec![Box::new(|tx| { + tx.send(AppEvent::OpenApprovalsPopup); + })]; + + let items = vec![ + SelectionItem { + name: "Yes, continue anyway".to_string(), + description: Some("Apply full access for this session".to_string()), + actions: accept_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Yes, and don't ask again".to_string(), + description: Some("Enable full access and remember this choice".to_string()), + actions: accept_and_remember_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Cancel".to_string(), + description: Some("Go back without enabling full access".to_string()), + actions: deny_actions, + dismiss_on_select: true, + ..Default::default() + }, + ]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + footer_hint: Some(standard_popup_hint_line()), + items, + header: Box::new(header), + ..Default::default() + }); + } + /// Set the approval policy in the widget's config copy. pub(crate) fn set_approval_policy(&mut self, policy: AskForApproval) { self.config.approval_policy = policy; @@ -1863,6 +1952,10 @@ impl ChatWidget { self.config.sandbox_policy = policy; } + pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) { + self.config.notices.hide_full_access_warning = Some(acknowledged); + } + /// Set the reasoning effort in the widget's config copy. pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { self.config.model_reasoning_effort = effort; diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap new file mode 100644 index 0000000000..1d612966de --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Select Approval Mode + +› 1. Read Only (current) Codex can read files and answer questions. Codex + requires approval to make edits, run commands, or + access network + 2. Auto Codex can read files, make edits, and run commands + in the workspace. Codex requires approval to work + outside the workspace or access network + 3. Full Access Codex can read files, make edits, and run commands + with network access, without approval. Exercise + caution + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap new file mode 100644 index 0000000000..71dac5f590 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Enable full access? + When Codex runs with full access, it can edit any file on your computer and + run commands with network, without your approval. Exercise caution when + enabling full access. This significantly increases the risk of data loss, + leaks, or unexpected behavior. + +› 1. Yes, continue anyway Apply full access for this session + 2. Yes, and don't ask again Enable full access and remember this choice + 3. Cancel Go back without enabling full access + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index a9102fdb4b..adadcd0a0f 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -4,6 +4,7 @@ use crate::app_event_sender::AppEventSender; use crate::test_backend::VT100Backend; use crate::tui::FrameRequester; use assert_matches::assert_matches; +use codex_common::approval_presets::builtin_approval_presets; use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::config::Config; @@ -1087,6 +1088,31 @@ fn model_selection_popup_snapshot() { assert_snapshot!("model_selection_popup", popup); } +#[test] +fn approvals_selection_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + + chat.config.notices.hide_full_access_warning = None; + chat.open_approvals_popup(); + + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("approvals_selection_popup", popup); +} + +#[test] +fn full_access_confirmation_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + + let preset = builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == "full-access") + .expect("full access preset"); + chat.open_full_access_confirmation(preset); + + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("full_access_confirmation_popup", popup); +} + #[test] fn model_reasoning_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); From a5d48a775bb934f57cf38b99d48e12538999cfdf Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Thu, 16 Oct 2025 20:15:47 -0700 Subject: [PATCH 005/287] [MCP] Allow specifying custom headers with streamable http servers (#5241) This adds two new config fields to streamable http mcp servers: `http_headers`: a map of key to value `env_http_headers` a map of key to env var which will be resolved at request time All headers will be passed to all MCP requests to that server just like authorization headers. There is a test ensuring that headers are not passed to other servers. Fixes #5180 --- codex-rs/cli/src/mcp_cmd.rs | 68 ++++- codex-rs/cli/tests/mcp_add_remove.rs | 8 + codex-rs/core/src/config.rs | 241 +++++++++++++++++- codex-rs/core/src/config_types.rs | 70 ++++- codex-rs/core/src/mcp/auth.rs | 4 + codex-rs/core/src/mcp_connection_manager.rs | 23 +- codex-rs/core/tests/suite/rmcp_client.rs | 4 + codex-rs/rmcp-client/src/auth_status.rs | 17 +- .../rmcp-client/src/perform_oauth_login.rs | 11 +- codex-rs/rmcp-client/src/rmcp_client.rs | 31 ++- codex-rs/rmcp-client/src/utils.rs | 73 ++++++ codex-rs/tui/src/history_cell.rs | 31 ++- 12 files changed, 560 insertions(+), 21 deletions(-) diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 888a3092a5..e7fd7b8dc1 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -239,6 +239,8 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re } => McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers: None, + env_http_headers: None, }, AddMcpTransportArgs { .. } => bail!("exactly one of --command or --url must be provided"), }; @@ -260,11 +262,20 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re if let McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var: None, + http_headers, + env_http_headers, } = transport && matches!(supports_oauth_login(&url).await, Ok(true)) { println!("Detected OAuth support. Starting OAuth flow…"); - perform_oauth_login(&name, &url, config.mcp_oauth_credentials_store_mode).await?; + perform_oauth_login( + &name, + &url, + config.mcp_oauth_credentials_store_mode, + http_headers.clone(), + env_http_headers.clone(), + ) + .await?; println!("Successfully logged in."); } @@ -317,12 +328,24 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs) bail!("No MCP server named '{name}' found."); }; - let url = match &server.transport { - McpServerTransportConfig::StreamableHttp { url, .. } => url.clone(), + let (url, http_headers, env_http_headers) = match &server.transport { + McpServerTransportConfig::StreamableHttp { + url, + http_headers, + env_http_headers, + .. + } => (url.clone(), http_headers.clone(), env_http_headers.clone()), _ => bail!("OAuth login is only supported for streamable HTTP servers."), }; - perform_oauth_login(&name, &url, config.mcp_oauth_credentials_store_mode).await?; + perform_oauth_login( + &name, + &url, + config.mcp_oauth_credentials_store_mode, + http_headers, + env_http_headers, + ) + .await?; println!("Successfully logged in to MCP server '{name}'."); Ok(()) } @@ -386,11 +409,15 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers, + env_http_headers, } => { serde_json::json!({ "type": "streamable_http", "url": url, "bearer_token_env_var": bearer_token_env_var, + "http_headers": http_headers, + "env_http_headers": env_http_headers, }) } }; @@ -465,6 +492,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + .. } => { let status = if cfg.enabled { "enabled".to_string() @@ -610,10 +638,14 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers, + env_http_headers, } => serde_json::json!({ "type": "streamable_http", "url": url, "bearer_token_env_var": bearer_token_env_var, + "http_headers": http_headers, + "env_http_headers": env_http_headers, }), }; let output = serde_json::to_string_pretty(&serde_json::json!({ @@ -661,11 +693,39 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers, + env_http_headers, } => { println!(" transport: streamable_http"); println!(" url: {url}"); let env_var = bearer_token_env_var.as_deref().unwrap_or("-"); println!(" bearer_token_env_var: {env_var}"); + let headers_display = match http_headers { + Some(map) if !map.is_empty() => { + let mut pairs: Vec<_> = map.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs + .into_iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join(", ") + } + _ => "-".to_string(), + }; + println!(" http_headers: {headers_display}"); + let env_headers_display = match env_http_headers { + Some(map) if !map.is_empty() => { + let mut pairs: Vec<_> = map.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs + .into_iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join(", ") + } + _ => "-".to_string(), + }; + println!(" env_http_headers: {env_headers_display}"); } } if let Some(timeout) = server.startup_timeout_sec { diff --git a/codex-rs/cli/tests/mcp_add_remove.rs b/codex-rs/cli/tests/mcp_add_remove.rs index 705509abf5..83abe72c29 100644 --- a/codex-rs/cli/tests/mcp_add_remove.rs +++ b/codex-rs/cli/tests/mcp_add_remove.rs @@ -112,9 +112,13 @@ async fn add_streamable_http_without_manual_token() -> Result<()> { McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers, + env_http_headers, } => { assert_eq!(url, "https://example.com/mcp"); assert!(bearer_token_env_var.is_none()); + assert!(http_headers.is_none()); + assert!(env_http_headers.is_none()); } other => panic!("unexpected transport: {other:?}"), } @@ -150,9 +154,13 @@ async fn add_streamable_http_with_custom_env_var() -> Result<()> { McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers, + env_http_headers, } => { assert_eq!(url, "https://example.com/issues"); assert_eq!(bearer_token_env_var.as_deref(), Some("GITHUB_TOKEN")); + assert!(http_headers.is_none()); + assert!(env_http_headers.is_none()); } other => panic!("unexpected transport: {other:?}"), } diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index ae41407968..570cd5151c 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -415,11 +415,37 @@ pub fn write_global_mcp_servers( McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers, + env_http_headers, } => { entry["url"] = toml_edit::value(url.clone()); if let Some(env_var) = bearer_token_env_var { entry["bearer_token_env_var"] = toml_edit::value(env_var.clone()); } + if let Some(headers) = http_headers + && !headers.is_empty() + { + let mut table = TomlTable::new(); + table.set_implicit(false); + let mut pairs: Vec<_> = headers.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + for (key, value) in pairs { + table.insert(key, toml_edit::value(value.clone())); + } + entry["http_headers"] = TomlItem::Table(table); + } + if let Some(headers) = env_http_headers + && !headers.is_empty() + { + let mut table = TomlTable::new(); + table.set_implicit(false); + let mut pairs: Vec<_> = headers.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + for (key, value) in pairs { + table.insert(key, toml_edit::value(value.clone())); + } + entry["env_http_headers"] = TomlItem::Table(table); + } } } @@ -1948,15 +1974,18 @@ ZIG_VAR = "3" } #[tokio::test] - async fn write_global_mcp_servers_serializes_streamable_http() -> anyhow::Result<()> { + async fn write_global_mcp_servers_streamable_http_serializes_bearer_token() -> anyhow::Result<()> + { let codex_home = TempDir::new()?; - let mut servers = BTreeMap::from([( + let servers = BTreeMap::from([( "docs".to_string(), McpServerConfig { transport: McpServerTransportConfig::StreamableHttp { url: "https://example.com/mcp".to_string(), bearer_token_env_var: Some("MCP_TOKEN".to_string()), + http_headers: None, + env_http_headers: None, }, enabled: true, startup_timeout_sec: Some(Duration::from_secs(2)), @@ -1983,20 +2012,127 @@ startup_timeout_sec = 2.0 McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers, + env_http_headers, } => { assert_eq!(url, "https://example.com/mcp"); assert_eq!(bearer_token_env_var.as_deref(), Some("MCP_TOKEN")); + assert!(http_headers.is_none()); + assert!(env_http_headers.is_none()); } other => panic!("unexpected transport {other:?}"), } assert_eq!(docs.startup_timeout_sec, Some(Duration::from_secs(2))); + Ok(()) + } + + #[tokio::test] + async fn write_global_mcp_servers_streamable_http_serializes_custom_headers() + -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let servers = BTreeMap::from([( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: Some("MCP_TOKEN".to_string()), + http_headers: Some(HashMap::from([("X-Doc".to_string(), "42".to_string())])), + env_http_headers: Some(HashMap::from([( + "X-Auth".to_string(), + "DOCS_AUTH".to_string(), + )])), + }, + enabled: true, + startup_timeout_sec: Some(Duration::from_secs(2)), + tool_timeout_sec: None, + }, + )]); + write_global_mcp_servers(codex_home.path(), &servers)?; + + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + let serialized = std::fs::read_to_string(&config_path)?; + assert_eq!( + serialized, + r#"[mcp_servers.docs] +url = "https://example.com/mcp" +bearer_token_env_var = "MCP_TOKEN" +startup_timeout_sec = 2.0 + +[mcp_servers.docs.http_headers] +X-Doc = "42" + +[mcp_servers.docs.env_http_headers] +X-Auth = "DOCS_AUTH" +"# + ); + + let loaded = load_global_mcp_servers(codex_home.path()).await?; + let docs = loaded.get("docs").expect("docs entry"); + match &docs.transport { + McpServerTransportConfig::StreamableHttp { + http_headers, + env_http_headers, + .. + } => { + assert_eq!( + http_headers, + &Some(HashMap::from([("X-Doc".to_string(), "42".to_string())])) + ); + assert_eq!( + env_http_headers, + &Some(HashMap::from([( + "X-Auth".to_string(), + "DOCS_AUTH".to_string() + )])) + ); + } + other => panic!("unexpected transport {other:?}"), + } + + Ok(()) + } + + #[tokio::test] + async fn write_global_mcp_servers_streamable_http_removes_optional_sections() + -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + + let mut servers = BTreeMap::from([( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: Some("MCP_TOKEN".to_string()), + http_headers: Some(HashMap::from([("X-Doc".to_string(), "42".to_string())])), + env_http_headers: Some(HashMap::from([( + "X-Auth".to_string(), + "DOCS_AUTH".to_string(), + )])), + }, + enabled: true, + startup_timeout_sec: Some(Duration::from_secs(2)), + tool_timeout_sec: None, + }, + )]); + + write_global_mcp_servers(codex_home.path(), &servers)?; + let serialized_with_optional = std::fs::read_to_string(&config_path)?; + assert!(serialized_with_optional.contains("bearer_token_env_var = \"MCP_TOKEN\"")); + assert!(serialized_with_optional.contains("[mcp_servers.docs.http_headers]")); + assert!(serialized_with_optional.contains("[mcp_servers.docs.env_http_headers]")); + servers.insert( "docs".to_string(), McpServerConfig { transport: McpServerTransportConfig::StreamableHttp { url: "https://example.com/mcp".to_string(), bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, }, enabled: true, startup_timeout_sec: None, @@ -2019,9 +2155,110 @@ url = "https://example.com/mcp" McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers, + env_http_headers, } => { assert_eq!(url, "https://example.com/mcp"); assert!(bearer_token_env_var.is_none()); + assert!(http_headers.is_none()); + assert!(env_http_headers.is_none()); + } + other => panic!("unexpected transport {other:?}"), + } + + assert!(docs.startup_timeout_sec.is_none()); + + Ok(()) + } + + #[tokio::test] + async fn write_global_mcp_servers_streamable_http_isolates_headers_between_servers() + -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + + let servers = BTreeMap::from([ + ( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: Some("MCP_TOKEN".to_string()), + http_headers: Some(HashMap::from([( + "X-Doc".to_string(), + "42".to_string(), + )])), + env_http_headers: Some(HashMap::from([( + "X-Auth".to_string(), + "DOCS_AUTH".to_string(), + )])), + }, + enabled: true, + startup_timeout_sec: Some(Duration::from_secs(2)), + tool_timeout_sec: None, + }, + ), + ( + "logs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "logs-server".to_string(), + args: vec!["--follow".to_string()], + env: None, + }, + enabled: true, + startup_timeout_sec: None, + tool_timeout_sec: None, + }, + ), + ]); + + write_global_mcp_servers(codex_home.path(), &servers)?; + + let serialized = std::fs::read_to_string(&config_path)?; + assert!( + serialized.contains("[mcp_servers.docs.http_headers]"), + "serialized config missing docs headers section:\n{serialized}" + ); + assert!( + !serialized.contains("[mcp_servers.logs.http_headers]"), + "serialized config should not add logs headers section:\n{serialized}" + ); + assert!( + !serialized.contains("[mcp_servers.logs.env_http_headers]"), + "serialized config should not add logs env headers section:\n{serialized}" + ); + assert!( + !serialized.contains("mcp_servers.logs.bearer_token_env_var"), + "serialized config should not add bearer token to logs:\n{serialized}" + ); + + let loaded = load_global_mcp_servers(codex_home.path()).await?; + let docs = loaded.get("docs").expect("docs entry"); + match &docs.transport { + McpServerTransportConfig::StreamableHttp { + http_headers, + env_http_headers, + .. + } => { + assert_eq!( + http_headers, + &Some(HashMap::from([("X-Doc".to_string(), "42".to_string())])) + ); + assert_eq!( + env_http_headers, + &Some(HashMap::from([( + "X-Auth".to_string(), + "DOCS_AUTH".to_string() + )])) + ); + } + other => panic!("unexpected transport {other:?}"), + } + let logs = loaded.get("logs").expect("logs entry"); + match &logs.transport { + McpServerTransportConfig::Stdio { env, .. } => { + assert!(env.is_none()); } other => panic!("unexpected transport {other:?}"), } diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index b724086a51..3a14d77a66 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -49,6 +49,10 @@ impl<'de> Deserialize<'de> for McpServerConfig { args: Option>, #[serde(default)] env: Option>, + #[serde(default)] + http_headers: Option>, + #[serde(default)] + env_http_headers: Option>, url: Option, bearer_token: Option, @@ -94,6 +98,8 @@ impl<'de> Deserialize<'de> for McpServerConfig { env, url, bearer_token_env_var, + http_headers, + env_http_headers, .. } => { throw_if_set("stdio", "url", url.as_ref())?; @@ -102,6 +108,8 @@ impl<'de> Deserialize<'de> for McpServerConfig { "bearer_token_env_var", bearer_token_env_var.as_ref(), )?; + throw_if_set("stdio", "http_headers", http_headers.as_ref())?; + throw_if_set("stdio", "env_http_headers", env_http_headers.as_ref())?; McpServerTransportConfig::Stdio { command, args: args.unwrap_or_default(), @@ -115,6 +123,8 @@ impl<'de> Deserialize<'de> for McpServerConfig { command, args, env, + http_headers, + env_http_headers, .. } => { throw_if_set("streamable_http", "command", command.as_ref())?; @@ -124,6 +134,8 @@ impl<'de> Deserialize<'de> for McpServerConfig { McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers, + env_http_headers, } } _ => return Err(SerdeError::custom("invalid transport")), @@ -161,6 +173,12 @@ pub enum McpServerTransportConfig { /// The actual secret value must be provided via the environment. #[serde(default, skip_serializing_if = "Option::is_none")] bearer_token_env_var: Option, + /// Additional HTTP headers to include in requests to this server. + #[serde(default, skip_serializing_if = "Option::is_none")] + http_headers: Option>, + /// HTTP headers where the value is sourced from an environment variable. + #[serde(default, skip_serializing_if = "Option::is_none")] + env_http_headers: Option>, }, } @@ -557,7 +575,9 @@ mod tests { cfg.transport, McpServerTransportConfig::StreamableHttp { url: "https://example.com/mcp".to_string(), - bearer_token_env_var: None + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, } ); assert!(cfg.enabled); @@ -577,12 +597,39 @@ mod tests { cfg.transport, McpServerTransportConfig::StreamableHttp { url: "https://example.com/mcp".to_string(), - bearer_token_env_var: Some("GITHUB_TOKEN".to_string()) + bearer_token_env_var: Some("GITHUB_TOKEN".to_string()), + http_headers: None, + env_http_headers: None, } ); assert!(cfg.enabled); } + #[test] + fn deserialize_streamable_http_server_config_with_headers() { + let cfg: McpServerConfig = toml::from_str( + r#" + url = "https://example.com/mcp" + http_headers = { "X-Foo" = "bar" } + env_http_headers = { "X-Token" = "TOKEN_ENV" } + "#, + ) + .expect("should deserialize http config with headers"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: None, + http_headers: Some(HashMap::from([("X-Foo".to_string(), "bar".to_string())])), + env_http_headers: Some(HashMap::from([( + "X-Token".to_string(), + "TOKEN_ENV".to_string() + )])), + } + ); + } + #[test] fn deserialize_rejects_command_and_url() { toml::from_str::( @@ -605,6 +652,25 @@ mod tests { .expect_err("should reject env for http transport"); } + #[test] + fn deserialize_rejects_headers_for_stdio() { + toml::from_str::( + r#" + command = "echo" + http_headers = { "X-Foo" = "bar" } + "#, + ) + .expect_err("should reject http_headers for stdio transport"); + + toml::from_str::( + r#" + command = "echo" + env_http_headers = { "X-Foo" = "BAR_ENV" } + "#, + ) + .expect_err("should reject env_http_headers for stdio transport"); + } + #[test] fn deserialize_rejects_inline_bearer_token_field() { let err = toml::from_str::( diff --git a/codex-rs/core/src/mcp/auth.rs b/codex-rs/core/src/mcp/auth.rs index dbb9db804f..22d1f5f51f 100644 --- a/codex-rs/core/src/mcp/auth.rs +++ b/codex-rs/core/src/mcp/auth.rs @@ -45,11 +45,15 @@ async fn compute_auth_status( McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers, + env_http_headers, } => { determine_streamable_http_auth_status( server_name, url, bearer_token_env_var.as_deref(), + http_headers.clone(), + env_http_headers.clone(), store_mode, ) .await diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 768c6b01a6..8ddb0366fb 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -121,17 +121,27 @@ impl McpClientAdapter { } } + #[allow(clippy::too_many_arguments)] async fn new_streamable_http_client( server_name: String, url: String, bearer_token: Option, + http_headers: Option>, + env_http_headers: Option>, params: mcp_types::InitializeRequestParams, startup_timeout: Duration, store_mode: OAuthCredentialsStoreMode, ) -> Result { let client = Arc::new( - RmcpClient::new_streamable_http_client(&server_name, &url, bearer_token, store_mode) - .await?, + RmcpClient::new_streamable_http_client( + &server_name, + &url, + bearer_token, + http_headers, + env_http_headers, + store_mode, + ) + .await?, ); client.initialize(params, Some(startup_timeout)).await?; Ok(McpClientAdapter::Rmcp(client)) @@ -259,11 +269,18 @@ impl McpConnectionManager { ) .await } - McpServerTransportConfig::StreamableHttp { url, .. } => { + McpServerTransportConfig::StreamableHttp { + url, + http_headers, + env_http_headers, + .. + } => { McpClientAdapter::new_streamable_http_client( server_name.clone(), url, resolved_bearer_token.unwrap_or_default(), + http_headers, + env_http_headers, params, startup_timeout, store_mode, diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 9dd921b91f..b2559cd2ee 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -235,6 +235,8 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { transport: McpServerTransportConfig::StreamableHttp { url: server_url, bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, }, enabled: true, startup_timeout_sec: Some(Duration::from_secs(10)), @@ -416,6 +418,8 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> { transport: McpServerTransportConfig::StreamableHttp { url: server_url, bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, }, enabled: true, startup_timeout_sec: Some(Duration::from_secs(10)), diff --git a/codex-rs/rmcp-client/src/auth_status.rs b/codex-rs/rmcp-client/src/auth_status.rs index 5e32eed485..77c33f6959 100644 --- a/codex-rs/rmcp-client/src/auth_status.rs +++ b/codex-rs/rmcp-client/src/auth_status.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::time::Duration; use anyhow::Error; @@ -6,11 +7,14 @@ use codex_protocol::protocol::McpAuthStatus; use reqwest::Client; use reqwest::StatusCode; use reqwest::Url; +use reqwest::header::HeaderMap; use serde::Deserialize; use tracing::debug; use crate::OAuthCredentialsStoreMode; use crate::oauth::has_oauth_tokens; +use crate::utils::apply_default_headers; +use crate::utils::build_default_headers; const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5); const OAUTH_DISCOVERY_HEADER: &str = "MCP-Protocol-Version"; @@ -21,6 +25,8 @@ pub async fn determine_streamable_http_auth_status( server_name: &str, url: &str, bearer_token_env_var: Option<&str>, + http_headers: Option>, + env_http_headers: Option>, store_mode: OAuthCredentialsStoreMode, ) -> Result { if bearer_token_env_var.is_some() { @@ -31,7 +37,9 @@ pub async fn determine_streamable_http_auth_status( return Ok(McpAuthStatus::OAuth); } - match supports_oauth_login(url).await { + let default_headers = build_default_headers(http_headers, env_http_headers)?; + + match supports_oauth_login_with_headers(url, &default_headers).await { Ok(true) => Ok(McpAuthStatus::NotLoggedIn), Ok(false) => Ok(McpAuthStatus::Unsupported), Err(error) => { @@ -45,8 +53,13 @@ pub async fn determine_streamable_http_auth_status( /// Attempt to determine whether a streamable HTTP MCP server advertises OAuth login. pub async fn supports_oauth_login(url: &str) -> Result { + supports_oauth_login_with_headers(url, &HeaderMap::new()).await +} + +async fn supports_oauth_login_with_headers(url: &str, default_headers: &HeaderMap) -> Result { let base_url = Url::parse(url)?; - let client = Client::builder().timeout(DISCOVERY_TIMEOUT).build()?; + let builder = Client::builder().timeout(DISCOVERY_TIMEOUT); + let client = apply_default_headers(builder, default_headers).build()?; let mut last_error: Option = None; for candidate_path in discovery_paths(base_url.path()) { diff --git a/codex-rs/rmcp-client/src/perform_oauth_login.rs b/codex-rs/rmcp-client/src/perform_oauth_login.rs index c2d39a214f..c5276227aa 100644 --- a/codex-rs/rmcp-client/src/perform_oauth_login.rs +++ b/codex-rs/rmcp-client/src/perform_oauth_login.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::string::String; use std::sync::Arc; use std::time::Duration; @@ -5,6 +6,7 @@ use std::time::Duration; use anyhow::Context; use anyhow::Result; use anyhow::anyhow; +use reqwest::ClientBuilder; use rmcp::transport::auth::OAuthState; use tiny_http::Response; use tiny_http::Server; @@ -16,6 +18,8 @@ use crate::OAuthCredentialsStoreMode; use crate::StoredOAuthTokens; use crate::WrappedOAuthTokenResponse; use crate::save_oauth_tokens; +use crate::utils::apply_default_headers; +use crate::utils::build_default_headers; struct CallbackServerGuard { server: Arc, @@ -31,6 +35,8 @@ pub async fn perform_oauth_login( server_name: &str, server_url: &str, store_mode: OAuthCredentialsStoreMode, + http_headers: Option>, + env_http_headers: Option>, ) -> Result<()> { let server = Arc::new(Server::http("127.0.0.1:0").map_err(|err| anyhow!(err))?); let guard = CallbackServerGuard { @@ -51,7 +57,10 @@ pub async fn perform_oauth_login( let (tx, rx) = oneshot::channel(); spawn_callback_server(server, tx); - let mut oauth_state = OAuthState::new(server_url, None).await?; + let default_headers = build_default_headers(http_headers, env_http_headers)?; + let http_client = apply_default_headers(ClientBuilder::new(), &default_headers).build()?; + + let mut oauth_state = OAuthState::new(server_url, Some(http_client)).await?; oauth_state .start_authorization(&[], &redirect_uri, Some("Codex")) .await?; diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index 3d12e50868..038f32ef9c 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -14,6 +14,7 @@ use mcp_types::InitializeRequestParams; use mcp_types::InitializeResult; use mcp_types::ListToolsRequestParams; use mcp_types::ListToolsResult; +use reqwest::header::HeaderMap; use rmcp::model::CallToolRequestParam; use rmcp::model::InitializeRequestParam; use rmcp::model::PaginatedRequestParam; @@ -38,6 +39,8 @@ use crate::logging_client_handler::LoggingClientHandler; use crate::oauth::OAuthCredentialsStoreMode; use crate::oauth::OAuthPersistor; use crate::oauth::StoredOAuthTokens; +use crate::utils::apply_default_headers; +use crate::utils::build_default_headers; use crate::utils::convert_call_tool_result; use crate::utils::convert_to_mcp; use crate::utils::convert_to_rmcp; @@ -116,12 +119,17 @@ impl RmcpClient { }) } + #[allow(clippy::too_many_arguments)] pub async fn new_streamable_http_client( server_name: &str, url: &str, bearer_token: Option, + http_headers: Option>, + env_http_headers: Option>, store_mode: OAuthCredentialsStoreMode, ) -> Result { + let default_headers = build_default_headers(http_headers, env_http_headers)?; + let initial_oauth_tokens = match bearer_token { Some(_) => None, None => match load_oauth_tokens(server_name, url, store_mode) { @@ -132,21 +140,30 @@ impl RmcpClient { } }, }; + let transport = if let Some(initial_tokens) = initial_oauth_tokens.clone() { - let (transport, oauth_persistor) = - create_oauth_transport_and_runtime(server_name, url, initial_tokens, store_mode) - .await?; + let (transport, oauth_persistor) = create_oauth_transport_and_runtime( + server_name, + url, + initial_tokens, + store_mode, + default_headers.clone(), + ) + .await?; PendingTransport::StreamableHttpWithOAuth { transport, oauth_persistor, } } else { let mut http_config = StreamableHttpClientTransportConfig::with_uri(url.to_string()); - if let Some(bearer_token) = bearer_token { + if let Some(bearer_token) = bearer_token.clone() { http_config = http_config.auth_header(bearer_token); } - let transport = StreamableHttpClientTransport::from_config(http_config); + let http_client = + apply_default_headers(reqwest::Client::builder(), &default_headers).build()?; + + let transport = StreamableHttpClientTransport::with_client(http_client, http_config); PendingTransport::StreamableHttp { transport } }; Ok(Self { @@ -290,11 +307,13 @@ async fn create_oauth_transport_and_runtime( url: &str, initial_tokens: StoredOAuthTokens, credentials_store: OAuthCredentialsStoreMode, + default_headers: HeaderMap, ) -> Result<( StreamableHttpClientTransport>, OAuthPersistor, )> { - let http_client = reqwest::Client::builder().build()?; + let http_client = + apply_default_headers(reqwest::Client::builder(), &default_headers).build()?; let mut oauth_state = OAuthState::new(url.to_string(), Some(http_client.clone())).await?; oauth_state diff --git a/codex-rs/rmcp-client/src/utils.rs b/codex-rs/rmcp-client/src/utils.rs index 6b7bd89424..cccb12c05a 100644 --- a/codex-rs/rmcp-client/src/utils.rs +++ b/codex-rs/rmcp-client/src/utils.rs @@ -6,6 +6,10 @@ use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use mcp_types::CallToolResult; +use reqwest::ClientBuilder; +use reqwest::header::HeaderMap; +use reqwest::header::HeaderName; +use reqwest::header::HeaderValue; use rmcp::model::CallToolResult as RmcpCallToolResult; use rmcp::service::ServiceError; use serde_json::Value; @@ -78,6 +82,75 @@ pub(crate) fn create_env_for_mcp_server( .collect() } +pub(crate) fn build_default_headers( + http_headers: Option>, + env_http_headers: Option>, +) -> Result { + let mut headers = HeaderMap::new(); + + if let Some(static_headers) = http_headers { + for (name, value) in static_headers { + let header_name = match HeaderName::from_bytes(name.as_bytes()) { + Ok(name) => name, + Err(err) => { + tracing::warn!("invalid HTTP header name `{name}`: {err}"); + continue; + } + }; + let header_value = match HeaderValue::from_str(value.as_str()) { + Ok(value) => value, + Err(err) => { + tracing::warn!("invalid HTTP header value for `{name}`: {err}"); + continue; + } + }; + headers.insert(header_name, header_value); + } + } + + if let Some(env_headers) = env_http_headers { + for (name, env_var) in env_headers { + if let Ok(value) = env::var(&env_var) { + if value.trim().is_empty() { + continue; + } + + let header_name = match HeaderName::from_bytes(name.as_bytes()) { + Ok(name) => name, + Err(err) => { + tracing::warn!("invalid HTTP header name `{name}`: {err}"); + continue; + } + }; + + let header_value = match HeaderValue::from_str(value.as_str()) { + Ok(value) => value, + Err(err) => { + tracing::warn!( + "invalid HTTP header value read from {env_var} for `{name}`: {err}" + ); + continue; + } + }; + headers.insert(header_name, header_value); + } + } + } + + Ok(headers) +} + +pub(crate) fn apply_default_headers( + builder: ClientBuilder, + default_headers: &HeaderMap, +) -> ClientBuilder { + if default_headers.is_empty() { + builder + } else { + builder.default_headers(default_headers.clone()) + } +} + #[cfg(unix)] pub(crate) const DEFAULT_ENV_VARS: &[&str] = &[ "HOME", diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index fe37d5fa09..2dd2b57857 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1031,8 +1031,37 @@ pub(crate) fn new_mcp_tools_output( lines.push(vec![" • Env: ".into(), env_pairs.join(" ").into()].into()); } } - McpServerTransportConfig::StreamableHttp { url, .. } => { + McpServerTransportConfig::StreamableHttp { + url, + http_headers, + env_http_headers, + .. + } => { lines.push(vec![" • URL: ".into(), url.clone().into()].into()); + if let Some(headers) = http_headers.as_ref() + && !headers.is_empty() + { + let mut pairs: Vec<_> = headers.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + let display = pairs + .into_iter() + .map(|(name, value)| format!("{name}={value}")) + .collect::>() + .join(", "); + lines.push(vec![" • HTTP headers: ".into(), display.into()].into()); + } + if let Some(headers) = env_http_headers.as_ref() + && !headers.is_empty() + { + let mut pairs: Vec<_> = headers.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + let display = pairs + .into_iter() + .map(|(name, env_var)| format!("{name}={env_var}")) + .collect::>() + .join(", "); + lines.push(vec![" • Env HTTP headers: ".into(), display.into()].into()); + } } } From da5492694b1c8488e230ba79aa95238f753b67c7 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 16 Oct 2025 21:03:23 -0700 Subject: [PATCH 006/287] Add log upload support (#5257) --- codex-rs/Cargo.lock | 190 ++++++++++++++ codex-rs/Cargo.toml | 5 +- codex-rs/feedback/Cargo.toml | 13 + codex-rs/feedback/src/lib.rs | 231 ++++++++++++++++++ codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/app.rs | 9 +- codex-rs/tui/src/app_backtrack.rs | 1 + codex-rs/tui/src/bottom_pane/feedback_view.rs | 224 +++++++++++++++++ codex-rs/tui/src/bottom_pane/mod.rs | 2 + ...ack_view__tests__feedback_view_render.snap | 17 ++ codex-rs/tui/src/chatwidget.rs | 26 ++ codex-rs/tui/src/chatwidget/tests.rs | 2 + codex-rs/tui/src/lib.rs | 32 ++- codex-rs/tui/src/slash_command.rs | 11 +- 14 files changed, 750 insertions(+), 14 deletions(-) create mode 100644 codex-rs/feedback/Cargo.toml create mode 100644 codex-rs/feedback/src/lib.rs create mode 100644 codex-rs/tui/src/bottom_pane/feedback_view.rs create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 70514dd5da..da19466ce4 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1144,6 +1144,17 @@ dependencies = [ "tempfile", ] +[[package]] +name = "codex-feedback" +version = "0.0.0" +dependencies = [ + "anyhow", + "codex-protocol", + "pretty_assertions", + "sentry", + "tracing-subscriber", +] + [[package]] name = "codex-file-search" version = "0.0.0" @@ -1388,6 +1399,7 @@ dependencies = [ "codex-arg0", "codex-common", "codex-core", + "codex-feedback", "codex-file-search", "codex-git-tooling", "codex-login", @@ -1824,6 +1836,16 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "serde", + "uuid", +] + [[package]] name = "debugserver-types" version = "0.5.0" @@ -2302,6 +2324,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "findshlibs" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40b9e59cd0f7e0806cca4be089683ecb6434e602038df21fe6bf6711b2f07f64" +dependencies = [ + "cc", + "lazy_static", + "libc", + "winapi", +] + [[package]] name = "fixed_decimal" version = "0.7.0" @@ -2670,6 +2704,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "hostname" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65" +dependencies = [ + "cfg-if", + "libc", + "windows-link 0.1.3", +] + [[package]] name = "http" version = "1.3.1" @@ -4906,6 +4951,15 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" @@ -5220,6 +5274,120 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "sentry" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5484316556650182f03b43d4c746ce0e3e48074a21e2f51244b648b6542e1066" +dependencies = [ + "httpdate", + "native-tls", + "reqwest", + "sentry-backtrace", + "sentry-contexts", + "sentry-core", + "sentry-debug-images", + "sentry-panic", + "sentry-tracing", + "tokio", + "ureq", +] + +[[package]] +name = "sentry-backtrace" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40aa225bb41e2ec9d7c90886834367f560efc1af028f1c5478a6cce6a59c463a" +dependencies = [ + "backtrace", + "once_cell", + "regex", + "sentry-core", +] + +[[package]] +name = "sentry-contexts" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8dd746da3d16cb8c39751619cefd4fcdbd6df9610f3310fd646b55f6e39910" +dependencies = [ + "hostname", + "libc", + "os_info", + "rustc_version", + "sentry-core", + "uname", +] + +[[package]] +name = "sentry-core" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "161283cfe8e99c8f6f236a402b9ccf726b201f365988b5bb637ebca0abbd4a30" +dependencies = [ + "once_cell", + "rand 0.8.5", + "sentry-types", + "serde", + "serde_json", +] + +[[package]] +name = "sentry-debug-images" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc6b25e945fcaa5e97c43faee0267eebda9f18d4b09a251775d8fef1086238a" +dependencies = [ + "findshlibs", + "once_cell", + "sentry-core", +] + +[[package]] +name = "sentry-panic" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74f229c7186dd971a9491ffcbe7883544aa064d1589bd30b83fb856cd22d63" +dependencies = [ + "sentry-backtrace", + "sentry-core", +] + +[[package]] +name = "sentry-tracing" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd3c5faf2103cd01eeda779ea439b68c4ee15adcdb16600836e97feafab362ec" +dependencies = [ + "sentry-backtrace", + "sentry-core", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "sentry-types" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d68cdf6bc41b8ff3ae2a9c4671e97426dcdd154cc1d4b6b72813f285d6b163f" +dependencies = [ + "debugid", + "hex", + "rand 0.8.5", + "serde", + "serde_json", + "thiserror 1.0.69", + "time", + "url", + "uuid", +] + [[package]] name = "serde" version = "1.0.226" @@ -6427,6 +6595,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "uname" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8" +dependencies = [ + "libc", +] + [[package]] name = "unicase" version = "2.8.1" @@ -6486,6 +6663,19 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "log", + "native-tls", + "once_cell", + "url", +] + [[package]] name = "url" version = "2.5.4" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 1c1640be3f..4ca4b7e6e1 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -6,6 +6,7 @@ members = [ "app-server-protocol", "apply-patch", "arg0", + "feedback", "codex-backend-openapi-models", "cloud-tasks", "cloud-tasks-client", @@ -56,6 +57,7 @@ codex-chatgpt = { path = "chatgpt" } codex-common = { path = "common" } codex-core = { path = "core" } codex-exec = { path = "exec" } +codex-feedback = { path = "feedback" } codex-file-search = { path = "file-search" } codex-git-tooling = { path = "git-tooling" } codex-linux-sandbox = { path = "linux-sandbox" } @@ -83,8 +85,8 @@ ansi-to-tui = "7.0.0" anyhow = "1" arboard = "3" askama = "0.12" -assert_matches = "1.5.0" assert_cmd = "2" +assert_matches = "1.5.0" async-channel = "2.3.1" async-stream = "0.3.6" async-trait = "0.1.89" @@ -147,6 +149,7 @@ reqwest = "0.12" rmcp = { version = "0.8.0", default-features = false } schemars = "0.8.22" seccompiler = "0.5.0" +sentry = "0.34.0" serde = "1" serde_json = "1" serde_with = "3.14" diff --git a/codex-rs/feedback/Cargo.toml b/codex-rs/feedback/Cargo.toml new file mode 100644 index 0000000000..b104f51225 --- /dev/null +++ b/codex-rs/feedback/Cargo.toml @@ -0,0 +1,13 @@ +[package] +edition.workspace = true +name = "codex-feedback" +version.workspace = true + +[dependencies] +anyhow = { workspace = true } +codex-protocol = { workspace = true } +sentry = { version = "0.34" } +tracing-subscriber = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/feedback/src/lib.rs b/codex-rs/feedback/src/lib.rs new file mode 100644 index 0000000000..285ab0661d --- /dev/null +++ b/codex-rs/feedback/src/lib.rs @@ -0,0 +1,231 @@ +use std::collections::VecDeque; +use std::fs; +use std::io::Write; +use std::io::{self}; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::Mutex; +use std::time::Duration; + +use anyhow::Result; +use anyhow::anyhow; +use codex_protocol::ConversationId; +use tracing_subscriber::fmt::writer::MakeWriter; + +const DEFAULT_MAX_BYTES: usize = 2 * 1024 * 1024; // 2 MiB +const SENTRY_DSN: &str = + "https://ae32ed50620d7a7792c1ce5df38b3e3e@o33249.ingest.us.sentry.io/4510195390611458"; +const UPLOAD_TIMEOUT_SECS: u64 = 10; + +#[derive(Clone)] +pub struct CodexFeedback { + inner: Arc, +} + +impl Default for CodexFeedback { + fn default() -> Self { + Self::new() + } +} + +impl CodexFeedback { + pub fn new() -> Self { + Self::with_capacity(DEFAULT_MAX_BYTES) + } + + pub(crate) fn with_capacity(max_bytes: usize) -> Self { + Self { + inner: Arc::new(FeedbackInner::new(max_bytes)), + } + } + + pub fn make_writer(&self) -> FeedbackMakeWriter { + FeedbackMakeWriter { + inner: self.inner.clone(), + } + } + + pub fn snapshot(&self, session_id: Option) -> CodexLogSnapshot { + let bytes = { + let guard = self.inner.ring.lock().expect("mutex poisoned"); + guard.snapshot_bytes() + }; + CodexLogSnapshot { + bytes, + thread_id: session_id + .map(|id| id.to_string()) + .unwrap_or("no-active-thread-".to_string() + &ConversationId::new().to_string()), + } + } +} + +struct FeedbackInner { + ring: Mutex, +} + +impl FeedbackInner { + fn new(max_bytes: usize) -> Self { + Self { + ring: Mutex::new(RingBuffer::new(max_bytes)), + } + } +} + +#[derive(Clone)] +pub struct FeedbackMakeWriter { + inner: Arc, +} + +impl<'a> MakeWriter<'a> for FeedbackMakeWriter { + type Writer = FeedbackWriter; + + fn make_writer(&'a self) -> Self::Writer { + FeedbackWriter { + inner: self.inner.clone(), + } + } +} + +pub struct FeedbackWriter { + inner: Arc, +} + +impl Write for FeedbackWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + let mut guard = self.inner.ring.lock().map_err(|_| io::ErrorKind::Other)?; + guard.push_bytes(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +struct RingBuffer { + max: usize, + buf: VecDeque, +} + +impl RingBuffer { + fn new(capacity: usize) -> Self { + Self { + max: capacity, + buf: VecDeque::with_capacity(capacity), + } + } + + fn len(&self) -> usize { + self.buf.len() + } + + fn push_bytes(&mut self, data: &[u8]) { + if data.is_empty() { + return; + } + + // If the incoming chunk is larger than capacity, keep only the trailing bytes. + if data.len() >= self.max { + self.buf.clear(); + let start = data.len() - self.max; + self.buf.extend(data[start..].iter().copied()); + return; + } + + // Evict from the front if we would exceed capacity. + let needed = self.len() + data.len(); + if needed > self.max { + let to_drop = needed - self.max; + for _ in 0..to_drop { + let _ = self.buf.pop_front(); + } + } + + self.buf.extend(data.iter().copied()); + } + + fn snapshot_bytes(&self) -> Vec { + self.buf.iter().copied().collect() + } +} + +pub struct CodexLogSnapshot { + bytes: Vec, + pub thread_id: String, +} + +impl CodexLogSnapshot { + pub(crate) fn as_bytes(&self) -> &[u8] { + &self.bytes + } + + pub fn save_to_temp_file(&self) -> io::Result { + let dir = std::env::temp_dir(); + let filename = format!("codex-feedback-{}.log", self.thread_id); + let path = dir.join(filename); + fs::write(&path, self.as_bytes())?; + Ok(path) + } + + pub fn upload_to_sentry(&self) -> Result<()> { + use std::collections::BTreeMap; + use std::str::FromStr; + use std::sync::Arc; + + use sentry::Client; + use sentry::ClientOptions; + use sentry::protocol::Attachment; + use sentry::protocol::Envelope; + use sentry::protocol::EnvelopeItem; + use sentry::protocol::Event; + use sentry::protocol::Level; + use sentry::transports::DefaultTransportFactory; + use sentry::types::Dsn; + + let client = Client::from_config(ClientOptions { + dsn: Some(Dsn::from_str(SENTRY_DSN).map_err(|e| anyhow!("invalid DSN: {}", e))?), + transport: Some(Arc::new(DefaultTransportFactory {})), + ..Default::default() + }); + + let tags = BTreeMap::from([(String::from("thread_id"), self.thread_id.to_string())]); + + let event = Event { + level: Level::Error, + message: Some("Codex Log Upload ".to_string() + &self.thread_id), + tags, + ..Default::default() + }; + let mut envelope = Envelope::new(); + envelope.add_item(EnvelopeItem::Event(event)); + envelope.add_item(EnvelopeItem::Attachment(Attachment { + buffer: self.bytes.clone(), + filename: String::from("codex-logs.log"), + content_type: Some("text/plain".to_string()), + ty: None, + })); + + client.send_envelope(envelope); + client.flush(Some(Duration::from_secs(UPLOAD_TIMEOUT_SECS))); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ring_buffer_drops_front_when_full() { + let fb = CodexFeedback::with_capacity(8); + { + let mut w = fb.make_writer().make_writer(); + w.write_all(b"abcdefgh").unwrap(); + w.write_all(b"ij").unwrap(); + } + let snap = fb.snapshot(None); + // Capacity 8: after writing 10 bytes, we should keep the last 8. + pretty_assertions::assert_eq!(std::str::from_utf8(snap.as_bytes()).unwrap(), "cdefghij"); + } +} diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 1ba3f0867b..41ffa31030 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -40,6 +40,7 @@ codex-login = { workspace = true } codex-ollama = { workspace = true } codex-protocol = { workspace = true } codex-app-server-protocol = { workspace = true } +codex-feedback = { workspace = true } color-eyre = { workspace = true } crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] } diffy = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 11a10d6d11..e1c2d062a4 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -74,12 +74,13 @@ pub(crate) struct App { // Esc-backtracking state grouped pub(crate) backtrack: crate::app_backtrack::BacktrackState, - + pub(crate) feedback: codex_feedback::CodexFeedback, /// Set when the user confirms an update; propagated on exit. pub(crate) pending_update_action: Option, } impl App { + #[allow(clippy::too_many_arguments)] pub async fn run( tui: &mut tui::Tui, auth_manager: Arc, @@ -88,6 +89,7 @@ impl App { initial_prompt: Option, initial_images: Vec, resume_selection: ResumeSelection, + feedback: codex_feedback::CodexFeedback, ) -> Result { use tokio_stream::StreamExt; let (app_event_tx, mut app_event_rx) = unbounded_channel(); @@ -110,6 +112,7 @@ impl App { initial_images: initial_images.clone(), enhanced_keys_supported, auth_manager: auth_manager.clone(), + feedback: feedback.clone(), }; ChatWidget::new(init, conversation_manager.clone()) } @@ -132,6 +135,7 @@ impl App { initial_images: initial_images.clone(), enhanced_keys_supported, auth_manager: auth_manager.clone(), + feedback: feedback.clone(), }; ChatWidget::new_from_existing( init, @@ -158,6 +162,7 @@ impl App { has_emitted_history_lines: false, commit_anim_running: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), + feedback: feedback.clone(), pending_update_action: None, }; @@ -236,6 +241,7 @@ impl App { initial_images: Vec::new(), enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), + feedback: self.feedback.clone(), }; self.chat_widget = ChatWidget::new(init, self.server.clone()); tui.frame_requester().schedule_frame(); @@ -549,6 +555,7 @@ mod tests { enhanced_keys_supported: false, commit_anim_running: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), + feedback: codex_feedback::CodexFeedback::new(), pending_update_action: None, } } diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 8f889623cf..b3e948ecb3 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -339,6 +339,7 @@ impl App { initial_images: Vec::new(), enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), + feedback: self.feedback.clone(), }; self.chat_widget = crate::chatwidget::ChatWidget::new_from_existing(init, conv, session_configured); diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs new file mode 100644 index 0000000000..9c75e32bdf --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -0,0 +1,224 @@ +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::history_cell; +use crate::history_cell::PlainHistoryCell; +use crate::render::renderable::Renderable; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use std::path::PathBuf; + +use super::BottomPane; +use super::SelectionAction; +use super::SelectionItem; +use super::SelectionViewParams; + +const BASE_ISSUE_URL: &str = "https://github.com/openai/codex/issues/new?template=2-bug-report.yml"; + +pub(crate) struct FeedbackView; + +impl FeedbackView { + pub fn show( + bottom_pane: &mut BottomPane, + file_path: PathBuf, + snapshot: codex_feedback::CodexLogSnapshot, + ) { + bottom_pane.show_selection_view(Self::selection_params(file_path, snapshot)); + } + + fn selection_params( + file_path: PathBuf, + snapshot: codex_feedback::CodexLogSnapshot, + ) -> SelectionViewParams { + let header = FeedbackHeader::new(file_path); + + let thread_id = snapshot.thread_id.clone(); + + let upload_action_tread_id = thread_id.clone(); + let upload_action: SelectionAction = Box::new(move |tx: &AppEventSender| { + match snapshot.upload_to_sentry() { + Ok(()) => { + let issue_url = format!( + "{BASE_ISSUE_URL}&steps=Uploaded%20thread:%20{upload_action_tread_id}", + ); + tx.send(AppEvent::InsertHistoryCell(Box::new(PlainHistoryCell::new(vec![ + Line::from( + "• Codex logs uploaded. Please open an issue using the following URL:", + ), + "".into(), + Line::from(vec![" ".into(), issue_url.cyan().underlined()]), + "".into(), + Line::from(vec![" Or mention your thread ID ".into(), upload_action_tread_id.clone().bold(), " in an existing issue.".into()]) + ])))); + } + Err(e) => { + tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event(format!("Failed to upload logs: {e}")), + ))); + } + } + }); + + let upload_item = SelectionItem { + name: "Yes".to_string(), + description: Some( + "Share the current Codex session logs with the team for troubleshooting." + .to_string(), + ), + actions: vec![upload_action], + dismiss_on_select: true, + ..Default::default() + }; + + let no_action: SelectionAction = Box::new(move |tx: &AppEventSender| { + let issue_url = format!("{BASE_ISSUE_URL}&steps=Thread%20ID:%20{thread_id}",); + + tx.send(AppEvent::InsertHistoryCell(Box::new( + PlainHistoryCell::new(vec![ + Line::from("• Please open an issue using the following URL:"), + "".into(), + Line::from(vec![" ".into(), issue_url.cyan().underlined()]), + "".into(), + Line::from(vec![ + " Or mention your thread ID ".into(), + thread_id.clone().bold(), + " in an existing issue.".into(), + ]), + ]), + ))); + }); + + let no_item = SelectionItem { + name: "No".to_string(), + actions: vec![no_action], + dismiss_on_select: true, + ..Default::default() + }; + + let cancel_item = SelectionItem { + name: "Cancel".to_string(), + dismiss_on_select: true, + ..Default::default() + }; + + SelectionViewParams { + header: Box::new(header), + items: vec![upload_item, no_item, cancel_item], + ..Default::default() + } + } +} + +struct FeedbackHeader { + file_path: PathBuf, +} + +impl FeedbackHeader { + fn new(file_path: PathBuf) -> Self { + Self { file_path } + } + + fn lines(&self) -> Vec> { + vec![ + Line::from("Do you want to upload logs before reporting issue?".bold()), + "".into(), + Line::from( + "Logs may include the full conversation history of this Codex process, including prompts, tool calls, and their results.", + ), + Line::from( + "These logs are retained for 90 days and are used solely for troubleshooting and diagnostic purposes.", + ), + "".into(), + Line::from(vec![ + "You can review the exact content of the logs before they’re uploaded at:".into(), + ]), + Line::from(self.file_path.display().to_string().dim()), + "".into(), + ] + } +} + +impl Renderable for FeedbackHeader { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.width == 0 || area.height == 0 { + return; + } + + for (i, line) in self.lines().into_iter().enumerate() { + let y = area.y.saturating_add(i as u16); + if y >= area.y.saturating_add(area.height) { + break; + } + let line_area = Rect::new(area.x, y, area.width, 1).intersection(area); + line.render(line_area, buf); + } + } + + fn desired_height(&self, width: u16) -> u16 { + self.lines() + .iter() + .map(|line| line.desired_height(width)) + .sum() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use crate::bottom_pane::list_selection_view::ListSelectionView; + use crate::style::user_message_style; + use codex_feedback::CodexFeedback; + use codex_protocol::ConversationId; + use insta::assert_snapshot; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use ratatui::style::Color; + use tokio::sync::mpsc::unbounded_channel; + + fn buffer_to_string(buffer: &Buffer) -> String { + (0..buffer.area.height) + .map(|row| { + let mut line = String::new(); + for col in 0..buffer.area.width { + let symbol = buffer[(buffer.area.x + col, buffer.area.y + row)].symbol(); + if symbol.is_empty() { + line.push(' '); + } else { + line.push_str(symbol); + } + } + line.trim_end().to_string() + }) + .collect::>() + .join("\n") + } + + #[test] + fn renders_feedback_view_header() { + let (tx_raw, _rx) = unbounded_channel::(); + let app_event_tx = AppEventSender::new(tx_raw); + let snapshot = CodexFeedback::new().snapshot(Some( + ConversationId::from_string("550e8400-e29b-41d4-a716-446655440000").unwrap(), + )); + let file_path = PathBuf::from("/tmp/codex-feedback.log"); + + let params = FeedbackView::selection_params(file_path.clone(), snapshot); + let view = ListSelectionView::new(params, app_event_tx); + + let width = 72; + let height = view.desired_height(width).max(1); + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + + let rendered = + buffer_to_string(&buf).replace(&file_path.display().to_string(), ""); + assert_snapshot!("feedback_view_render", rendered); + + let cell_style = buf[(area.x, area.y)].style(); + let expected_bg = user_message_style().bg.unwrap_or(Color::Reset); + assert_eq!(cell_style.bg.unwrap_or(Color::Reset), expected_bg); + } +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index d8c3265f95..8d9e84f2aa 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -27,11 +27,13 @@ mod footer; mod list_selection_view; mod prompt_args; pub(crate) use list_selection_view::SelectionViewParams; +mod feedback_view; mod paste_burst; pub mod popup_consts; mod scroll_state; mod selection_popup_common; mod textarea; +pub(crate) use feedback_view::FeedbackView; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum CancellationEvent { diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap new file mode 100644 index 0000000000..bafa94b09d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/bottom_pane/feedback_view.rs +expression: rendered +--- + Do you want to upload logs before reporting issue? + + Logs may include the full conversation history of this Codex process + These logs are retained for 90 days and are used solely for troubles + + You can review the exact content of the logs before they’re uploaded + + + +› 1. Yes Share the current Codex session logs with the team for + troubleshooting. + 2. No + 3. Cancel diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 775242a597..f592695346 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -224,6 +224,7 @@ pub(crate) struct ChatWidgetInit { pub(crate) initial_images: Vec, pub(crate) enhanced_keys_supported: bool, pub(crate) auth_manager: Arc, + pub(crate) feedback: codex_feedback::CodexFeedback, } pub(crate) struct ChatWidget { @@ -272,6 +273,8 @@ pub(crate) struct ChatWidget { needs_final_message_separator: bool, last_rendered_width: std::cell::Cell>, + // Feedback sink for /feedback + feedback: codex_feedback::CodexFeedback, } struct UserMessage { @@ -917,6 +920,7 @@ impl ChatWidget { initial_images, enhanced_keys_supported, auth_manager, + feedback, } = common; let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); @@ -963,6 +967,7 @@ impl ChatWidget { ghost_snapshots_disabled: true, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), + feedback, } } @@ -980,6 +985,7 @@ impl ChatWidget { initial_images, enhanced_keys_supported, auth_manager, + feedback, } = common; let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); @@ -1028,6 +1034,7 @@ impl ChatWidget { ghost_snapshots_disabled: true, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), + feedback, } } @@ -1131,6 +1138,25 @@ impl ChatWidget { return; } match cmd { + SlashCommand::Feedback => { + let snapshot = self.feedback.snapshot(self.conversation_id); + match snapshot.save_to_temp_file() { + Ok(path) => { + crate::bottom_pane::FeedbackView::show( + &mut self.bottom_pane, + path, + snapshot, + ); + self.request_redraw(); + } + Err(e) => { + self.add_to_history(history_cell::new_error_event(format!( + "Failed to save feedback logs: {e}" + ))); + self.request_redraw(); + } + } + } SlashCommand::New => { self.app_event_tx.send(AppEvent::NewSession); } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index adadcd0a0f..2055e20856 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -235,6 +235,7 @@ async fn helpers_are_available_and_do_not_panic() { initial_images: Vec::new(), enhanced_keys_supported: false, auth_manager, + feedback: codex_feedback::CodexFeedback::new(), }; let mut w = ChatWidget::new(init, conversation_manager); // Basic construction sanity. @@ -291,6 +292,7 @@ fn make_chatwidget_manual() -> ( ghost_snapshots_disabled: false, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), + feedback: codex_feedback::CodexFeedback::new(), }; (widget, rx, op_rx) } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 7447ef5feb..aa40b05c55 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -23,6 +23,7 @@ use std::path::PathBuf; use tracing::error; use tracing_appender::non_blocking; use tracing_subscriber::EnvFilter; +use tracing_subscriber::filter::Targets; use tracing_subscriber::prelude::*; mod app; @@ -219,13 +220,21 @@ pub async fn run_main( }) }; - // Build layered subscriber: let file_layer = tracing_subscriber::fmt::layer() .with_writer(non_blocking) .with_target(false) .with_span_events(tracing_subscriber::fmt::format::FmtSpan::CLOSE) .with_filter(env_filter()); + let feedback = codex_feedback::CodexFeedback::new(); + let targets = Targets::new().with_default(tracing::Level::TRACE); + + let feedback_layer = tracing_subscriber::fmt::layer() + .with_writer(feedback.make_writer()) + .with_ansi(false) + .with_target(false) + .with_filter(targets); + if cli.oss { codex_ollama::ensure_oss_ready(&config) .await @@ -250,15 +259,26 @@ pub async fn run_main( let _ = tracing_subscriber::registry() .with(file_layer) + .with(feedback_layer) .with(otel_layer) .try_init(); } else { - let _ = tracing_subscriber::registry().with(file_layer).try_init(); + let _ = tracing_subscriber::registry() + .with(file_layer) + .with(feedback_layer) + .try_init(); }; - run_ratatui_app(cli, config, overrides, cli_kv_overrides, active_profile) - .await - .map_err(|err| std::io::Error::other(err.to_string())) + run_ratatui_app( + cli, + config, + overrides, + cli_kv_overrides, + active_profile, + feedback, + ) + .await + .map_err(|err| std::io::Error::other(err.to_string())) } async fn run_ratatui_app( @@ -267,6 +287,7 @@ async fn run_ratatui_app( overrides: ConfigOverrides, cli_kv_overrides: Vec<(String, toml::Value)>, active_profile: Option, + feedback: codex_feedback::CodexFeedback, ) -> color_eyre::Result { color_eyre::install()?; @@ -462,6 +483,7 @@ async fn run_ratatui_app( prompt, images, resume_selection, + feedback, ) .await; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 14604a736d..22da09f358 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -25,6 +25,7 @@ pub enum SlashCommand { Mcp, Logout, Quit, + Feedback, #[cfg(debug_assertions)] TestApproval, } @@ -33,6 +34,7 @@ impl SlashCommand { /// User-visible description shown in the popup. pub fn description(self) -> &'static str { match self { + SlashCommand::Feedback => "send logs to maintainers", SlashCommand::New => "start a new chat during a conversation", SlashCommand::Init => "create an AGENTS.md file with instructions for Codex", SlashCommand::Compact => "summarize conversation to prevent hitting the context limit", @@ -72,6 +74,7 @@ impl SlashCommand { | SlashCommand::Mention | SlashCommand::Status | SlashCommand::Mcp + | SlashCommand::Feedback | SlashCommand::Quit => true, #[cfg(debug_assertions)] @@ -85,13 +88,7 @@ pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> { let show_beta_features = beta_features_enabled(); SlashCommand::iter() - .filter(|cmd| { - if *cmd == SlashCommand::Undo { - show_beta_features - } else { - true - } - }) + .filter(|cmd| *cmd != SlashCommand::Undo || show_beta_features) .map(|c| (c.command(), c)) .collect() } From bdda762deb38f6cc8ba18debc74716928d22a947 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Thu, 16 Oct 2025 21:24:43 -0700 Subject: [PATCH 007/287] [MCP] Allow specifying cwd and additional env vars (#5246) This makes stdio mcp servers more flexible by allowing users to specify the cwd to run the server command from and adding additional environment variables to be passed through to the server. Example config using the test server in this repo: ```toml [mcp_servers.test_stdio] cwd = "/Users//code/codex/codex-rs" command = "cargo" args = ["run", "--bin", "test_stdio_server"] env_vars = ["MCP_TEST_VALUE"] ``` @bolinfest I know you hate these env var tests but let's roll with this for now. I may take a stab at the env guard + serial macro at some point. --- codex-rs/Cargo.lock | 1 + codex-rs/cli/src/mcp_cmd.rs | 102 ++++++++------ codex-rs/cli/tests/mcp_add_remove.rs | 10 +- codex-rs/cli/tests/mcp_list.rs | 30 +++- codex-rs/common/src/format_env_display.rs | 66 +++++++++ codex-rs/common/src/lib.rs | 3 + codex-rs/core/src/config.rs | 128 ++++++++++++++++- codex-rs/core/src/config_types.rs | 79 ++++++++++- codex-rs/core/src/mcp_connection_manager.rs | 20 ++- codex-rs/core/tests/suite/rmcp_client.rs | 146 +++++++++++++++++++- codex-rs/mcp-client/src/main.rs | 2 +- codex-rs/mcp-client/src/mcp_client.rs | 50 +++++-- codex-rs/rmcp-client/Cargo.toml | 1 + codex-rs/rmcp-client/src/rmcp_client.rs | 8 +- codex-rs/rmcp-client/src/utils.rs | 51 ++++++- codex-rs/tui/src/history_cell.rs | 24 ++-- docs/config.md | 5 + 17 files changed, 650 insertions(+), 76 deletions(-) create mode 100644 codex-rs/common/src/format_env_display.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index da19466ce4..6289625ae0 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1374,6 +1374,7 @@ dependencies = [ "rmcp", "serde", "serde_json", + "serial_test", "sha2", "tempfile", "tiny_http", diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index e7fd7b8dc1..50274f3ee5 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -6,6 +6,7 @@ use anyhow::anyhow; use anyhow::bail; use clap::ArgGroup; use codex_common::CliConfigOverrides; +use codex_common::format_env_display::format_env_display; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::find_codex_home; @@ -227,6 +228,8 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re command: command_bin, args: command_args, env: env_map, + env_vars: Vec::new(), + cwd: None, } } AddMcpTransportArgs { @@ -400,11 +403,19 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> .copied() .unwrap_or(McpAuthStatus::Unsupported); let transport = match &cfg.transport { - McpServerTransportConfig::Stdio { command, args, env } => serde_json::json!({ + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => serde_json::json!({ "type": "stdio", "command": command, "args": args, "env": env, + "env_vars": env_vars, + "cwd": cwd, }), McpServerTransportConfig::StreamableHttp { url, @@ -446,30 +457,29 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> return Ok(()); } - let mut stdio_rows: Vec<[String; 6]> = Vec::new(); + let mut stdio_rows: Vec<[String; 7]> = Vec::new(); let mut http_rows: Vec<[String; 5]> = Vec::new(); for (name, cfg) in entries { match &cfg.transport { - McpServerTransportConfig::Stdio { command, args, env } => { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { let args_display = if args.is_empty() { "-".to_string() } else { args.join(" ") }; - let env_display = match env.as_ref() { - None => "-".to_string(), - Some(map) if map.is_empty() => "-".to_string(), - Some(map) => { - let mut pairs: Vec<_> = map.iter().collect(); - pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); - pairs - .into_iter() - .map(|(k, v)| format!("{k}={v}")) - .collect::>() - .join(", ") - } - }; + let env_display = format_env_display(env.as_ref(), env_vars); + let cwd_display = cwd + .as_ref() + .map(|path| path.display().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "-".to_string()); let status = if cfg.enabled { "enabled".to_string() } else { @@ -485,6 +495,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> command.clone(), args_display, env_display, + cwd_display, status, auth_status, ]); @@ -521,6 +532,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> "Command".len(), "Args".len(), "Env".len(), + "Cwd".len(), "Status".len(), "Auth".len(), ]; @@ -531,36 +543,40 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> } println!( - "{name: Re if get_args.json { let transport = match &server.transport { - McpServerTransportConfig::Stdio { command, args, env } => serde_json::json!({ + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => serde_json::json!({ "type": "stdio", "command": command, "args": args, "env": env, + "env_vars": env_vars, + "cwd": cwd, }), McpServerTransportConfig::StreamableHttp { url, @@ -666,7 +690,13 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re println!("{}", get_args.name); println!(" enabled: {}", server.enabled); match &server.transport { - McpServerTransportConfig::Stdio { command, args, env } => { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { println!(" transport: stdio"); println!(" command: {command}"); let args_display = if args.is_empty() { @@ -675,19 +705,13 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re args.join(" ") }; println!(" args: {args_display}"); - let env_display = match env.as_ref() { - None => "-".to_string(), - Some(map) if map.is_empty() => "-".to_string(), - Some(map) => { - let mut pairs: Vec<_> = map.iter().collect(); - pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); - pairs - .into_iter() - .map(|(k, v)| format!("{k}={v}")) - .collect::>() - .join(", ") - } - }; + let cwd_display = cwd + .as_ref() + .map(|path| path.display().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "-".to_string()); + println!(" cwd: {cwd_display}"); + let env_display = format_env_display(env.as_ref(), env_vars); println!(" env: {env_display}"); } McpServerTransportConfig::StreamableHttp { diff --git a/codex-rs/cli/tests/mcp_add_remove.rs b/codex-rs/cli/tests/mcp_add_remove.rs index 83abe72c29..7a6c2daaff 100644 --- a/codex-rs/cli/tests/mcp_add_remove.rs +++ b/codex-rs/cli/tests/mcp_add_remove.rs @@ -28,10 +28,18 @@ async fn add_and_remove_server_updates_global_config() -> Result<()> { assert_eq!(servers.len(), 1); let docs = servers.get("docs").expect("server should exist"); match &docs.transport { - McpServerTransportConfig::Stdio { command, args, env } => { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { assert_eq!(command, "echo"); assert_eq!(args, &vec!["hello".to_string()]); assert!(env.is_none()); + assert!(env_vars.is_empty()); + assert!(cwd.is_none()); } other => panic!("unexpected transport: {other:?}"), } diff --git a/codex-rs/cli/tests/mcp_list.rs b/codex-rs/cli/tests/mcp_list.rs index 8f33a8e470..ea0d6fc1d9 100644 --- a/codex-rs/cli/tests/mcp_list.rs +++ b/codex-rs/cli/tests/mcp_list.rs @@ -1,6 +1,9 @@ use std::path::Path; use anyhow::Result; +use codex_core::config::load_global_mcp_servers; +use codex_core::config::write_global_mcp_servers; +use codex_core::config_types::McpServerTransportConfig; use predicates::prelude::PredicateBooleanExt; use predicates::str::contains; use pretty_assertions::assert_eq; @@ -27,8 +30,8 @@ fn list_shows_empty_state() -> Result<()> { Ok(()) } -#[test] -fn list_and_get_render_expected_output() -> Result<()> { +#[tokio::test] +async fn list_and_get_render_expected_output() -> Result<()> { let codex_home = TempDir::new()?; let mut add = codex_command(codex_home.path())?; @@ -46,6 +49,18 @@ fn list_and_get_render_expected_output() -> Result<()> { .assert() .success(); + let mut servers = load_global_mcp_servers(codex_home.path()).await?; + let docs_entry = servers + .get_mut("docs") + .expect("docs server should exist after add"); + match &mut docs_entry.transport { + McpServerTransportConfig::Stdio { env_vars, .. } => { + *env_vars = vec!["APP_TOKEN".to_string(), "WORKSPACE_ID".to_string()]; + } + other => panic!("unexpected transport: {other:?}"), + } + write_global_mcp_servers(codex_home.path(), &servers)?; + let mut list_cmd = codex_command(codex_home.path())?; let list_output = list_cmd.args(["mcp", "list"]).output()?; assert!(list_output.status.success()); @@ -54,6 +69,8 @@ fn list_and_get_render_expected_output() -> Result<()> { assert!(stdout.contains("docs")); assert!(stdout.contains("docs-server")); assert!(stdout.contains("TOKEN=secret")); + assert!(stdout.contains("APP_TOKEN=$APP_TOKEN")); + assert!(stdout.contains("WORKSPACE_ID=$WORKSPACE_ID")); assert!(stdout.contains("Status")); assert!(stdout.contains("Auth")); assert!(stdout.contains("enabled")); @@ -79,7 +96,12 @@ fn list_and_get_render_expected_output() -> Result<()> { ], "env": { "TOKEN": "secret" - } + }, + "env_vars": [ + "APP_TOKEN", + "WORKSPACE_ID" + ], + "cwd": null }, "startup_timeout_sec": null, "tool_timeout_sec": null, @@ -98,6 +120,8 @@ fn list_and_get_render_expected_output() -> Result<()> { assert!(stdout.contains("command: docs-server")); assert!(stdout.contains("args: --port 4000")); assert!(stdout.contains("env: TOKEN=secret")); + assert!(stdout.contains("APP_TOKEN=$APP_TOKEN")); + assert!(stdout.contains("WORKSPACE_ID=$WORKSPACE_ID")); assert!(stdout.contains("enabled: true")); assert!(stdout.contains("remove: codex mcp remove docs")); diff --git a/codex-rs/common/src/format_env_display.rs b/codex-rs/common/src/format_env_display.rs new file mode 100644 index 0000000000..640be30783 --- /dev/null +++ b/codex-rs/common/src/format_env_display.rs @@ -0,0 +1,66 @@ +use std::collections::HashMap; + +pub fn format_env_display(env: Option<&HashMap>, env_vars: &[String]) -> String { + let mut parts: Vec = Vec::new(); + + if let Some(map) = env { + let mut pairs: Vec<_> = map.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + parts.extend( + pairs + .into_iter() + .map(|(key, value)| format!("{key}={value}")), + ); + } + + if !env_vars.is_empty() { + parts.extend(env_vars.iter().map(|var| format!("{var}=${var}"))); + } + + if parts.is_empty() { + "-".to_string() + } else { + parts.join(", ") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn returns_dash_when_empty() { + assert_eq!(format_env_display(None, &[]), "-"); + + let empty_map = HashMap::new(); + assert_eq!(format_env_display(Some(&empty_map), &[]), "-"); + } + + #[test] + fn formats_sorted_env_pairs() { + let mut env = HashMap::new(); + env.insert("B".to_string(), "two".to_string()); + env.insert("A".to_string(), "one".to_string()); + + assert_eq!(format_env_display(Some(&env), &[]), "A=one, B=two"); + } + + #[test] + fn formats_env_vars_with_dollar_prefix() { + let vars = vec!["TOKEN".to_string(), "PATH".to_string()]; + + assert_eq!(format_env_display(None, &vars), "TOKEN=$TOKEN, PATH=$PATH"); + } + + #[test] + fn combines_env_pairs_and_vars() { + let mut env = HashMap::new(); + env.insert("HOME".to_string(), "/tmp".to_string()); + let vars = vec!["TOKEN".to_string()]; + + assert_eq!( + format_env_display(Some(&env), &vars), + "HOME=/tmp, TOKEN=$TOKEN" + ); + } +} diff --git a/codex-rs/common/src/lib.rs b/codex-rs/common/src/lib.rs index 292503f77e..276bfca069 100644 --- a/codex-rs/common/src/lib.rs +++ b/codex-rs/common/src/lib.rs @@ -13,6 +13,9 @@ mod sandbox_mode_cli_arg; #[cfg(feature = "cli")] pub use sandbox_mode_cli_arg::SandboxModeCliArg; +#[cfg(feature = "cli")] +pub mod format_env_display; + #[cfg(any(feature = "cli", test))] mod config_override; diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 570cd5151c..6f5defcedc 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -388,7 +388,13 @@ pub fn write_global_mcp_servers( let mut entry = TomlTable::new(); entry.set_implicit(false); match &config.transport { - McpServerTransportConfig::Stdio { command, args, env } => { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { entry["command"] = toml_edit::value(command.clone()); if !args.is_empty() { @@ -411,6 +417,15 @@ pub fn write_global_mcp_servers( } entry["env"] = TomlItem::Table(env_table); } + + if !env_vars.is_empty() { + entry["env_vars"] = + TomlItem::Value(env_vars.iter().collect::().into()); + } + + if let Some(cwd) = cwd { + entry["cwd"] = toml_edit::value(cwd.to_string_lossy().to_string()); + } } McpServerTransportConfig::StreamableHttp { url, @@ -1806,6 +1821,8 @@ approve_all = true command: "echo".to_string(), args: vec!["hello".to_string()], env: None, + env_vars: Vec::new(), + cwd: None, }, enabled: true, startup_timeout_sec: Some(Duration::from_secs(3)), @@ -1819,10 +1836,18 @@ approve_all = true assert_eq!(loaded.len(), 1); let docs = loaded.get("docs").expect("docs entry"); match &docs.transport { - McpServerTransportConfig::Stdio { command, args, env } => { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { assert_eq!(command, "echo"); assert_eq!(args, &vec!["hello".to_string()]); assert!(env.is_none()); + assert!(env_vars.is_empty()); + assert!(cwd.is_none()); } other => panic!("unexpected transport {other:?}"), } @@ -1932,6 +1957,8 @@ bearer_token = "secret" ("ZIG_VAR".to_string(), "3".to_string()), ("ALPHA_VAR".to_string(), "1".to_string()), ])), + env_vars: Vec::new(), + cwd: None, }, enabled: true, startup_timeout_sec: None, @@ -1958,7 +1985,13 @@ ZIG_VAR = "3" let loaded = load_global_mcp_servers(codex_home.path()).await?; let docs = loaded.get("docs").expect("docs entry"); match &docs.transport { - McpServerTransportConfig::Stdio { command, args, env } => { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { assert_eq!(command, "docs-server"); assert_eq!(args, &vec!["--verbose".to_string()]); let env = env @@ -1966,6 +1999,91 @@ ZIG_VAR = "3" .expect("env should be preserved for stdio transport"); assert_eq!(env.get("ALPHA_VAR"), Some(&"1".to_string())); assert_eq!(env.get("ZIG_VAR"), Some(&"3".to_string())); + assert!(env_vars.is_empty()); + assert!(cwd.is_none()); + } + other => panic!("unexpected transport {other:?}"), + } + + Ok(()) + } + + #[tokio::test] + async fn write_global_mcp_servers_serializes_env_vars() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let servers = BTreeMap::from([( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "docs-server".to_string(), + args: Vec::new(), + env: None, + env_vars: vec!["ALPHA".to_string(), "BETA".to_string()], + cwd: None, + }, + enabled: true, + startup_timeout_sec: None, + tool_timeout_sec: None, + }, + )]); + + write_global_mcp_servers(codex_home.path(), &servers)?; + + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + let serialized = std::fs::read_to_string(&config_path)?; + assert!( + serialized.contains(r#"env_vars = ["ALPHA", "BETA"]"#), + "serialized config missing env_vars field:\n{serialized}" + ); + + let loaded = load_global_mcp_servers(codex_home.path()).await?; + let docs = loaded.get("docs").expect("docs entry"); + match &docs.transport { + McpServerTransportConfig::Stdio { env_vars, .. } => { + assert_eq!(env_vars, &vec!["ALPHA".to_string(), "BETA".to_string()]); + } + other => panic!("unexpected transport {other:?}"), + } + + Ok(()) + } + + #[tokio::test] + async fn write_global_mcp_servers_serializes_cwd() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let cwd_path = PathBuf::from("/tmp/codex-mcp"); + let servers = BTreeMap::from([( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "docs-server".to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: Some(cwd_path.clone()), + }, + enabled: true, + startup_timeout_sec: None, + tool_timeout_sec: None, + }, + )]); + + write_global_mcp_servers(codex_home.path(), &servers)?; + + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + let serialized = std::fs::read_to_string(&config_path)?; + assert!( + serialized.contains(r#"cwd = "/tmp/codex-mcp""#), + "serialized config missing cwd field:\n{serialized}" + ); + + let loaded = load_global_mcp_servers(codex_home.path()).await?; + let docs = loaded.get("docs").expect("docs entry"); + match &docs.transport { + McpServerTransportConfig::Stdio { cwd, .. } => { + assert_eq!(cwd.as_deref(), Some(Path::new("/tmp/codex-mcp"))); } other => panic!("unexpected transport {other:?}"), } @@ -2205,6 +2323,8 @@ url = "https://example.com/mcp" command: "logs-server".to_string(), args: vec!["--follow".to_string()], env: None, + env_vars: Vec::new(), + cwd: None, }, enabled: true, startup_timeout_sec: None, @@ -2277,6 +2397,8 @@ url = "https://example.com/mcp" command: "docs-server".to_string(), args: Vec::new(), env: None, + env_vars: Vec::new(), + cwd: None, }, enabled: false, startup_timeout_sec: None, diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index 3a14d77a66..3da6108631 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -44,20 +44,26 @@ impl<'de> Deserialize<'de> for McpServerConfig { { #[derive(Deserialize)] struct RawMcpServerConfig { + // stdio command: Option, #[serde(default)] args: Option>, #[serde(default)] env: Option>, #[serde(default)] + env_vars: Option>, + #[serde(default)] + cwd: Option, http_headers: Option>, #[serde(default)] env_http_headers: Option>, + // streamable_http url: Option, bearer_token: Option, bearer_token_env_var: Option, + // shared #[serde(default)] startup_timeout_sec: Option, #[serde(default)] @@ -96,6 +102,8 @@ impl<'de> Deserialize<'de> for McpServerConfig { command: Some(command), args, env, + env_vars, + cwd, url, bearer_token_env_var, http_headers, @@ -114,6 +122,8 @@ impl<'de> Deserialize<'de> for McpServerConfig { command, args: args.unwrap_or_default(), env, + env_vars: env_vars.unwrap_or_default(), + cwd, } } RawMcpServerConfig { @@ -123,13 +133,20 @@ impl<'de> Deserialize<'de> for McpServerConfig { command, args, env, + env_vars, + cwd, http_headers, env_http_headers, - .. + startup_timeout_sec: _, + tool_timeout_sec: _, + startup_timeout_ms: _, + enabled: _, } => { throw_if_set("streamable_http", "command", command.as_ref())?; throw_if_set("streamable_http", "args", args.as_ref())?; throw_if_set("streamable_http", "env", env.as_ref())?; + throw_if_set("streamable_http", "env_vars", env_vars.as_ref())?; + throw_if_set("streamable_http", "cwd", cwd.as_ref())?; throw_if_set("streamable_http", "bearer_token", bearer_token.as_ref())?; McpServerTransportConfig::StreamableHttp { url, @@ -164,6 +181,10 @@ pub enum McpServerTransportConfig { args: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] env: Option>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + env_vars: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + cwd: Option, }, /// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http StreamableHttp { @@ -500,7 +521,9 @@ mod tests { McpServerTransportConfig::Stdio { command: "echo".to_string(), args: vec![], - env: None + env: None, + env_vars: Vec::new(), + cwd: None, } ); assert!(cfg.enabled); @@ -521,7 +544,9 @@ mod tests { McpServerTransportConfig::Stdio { command: "echo".to_string(), args: vec!["hello".to_string(), "world".to_string()], - env: None + env: None, + env_vars: Vec::new(), + cwd: None, } ); assert!(cfg.enabled); @@ -543,12 +568,58 @@ mod tests { McpServerTransportConfig::Stdio { command: "echo".to_string(), args: vec!["hello".to_string(), "world".to_string()], - env: Some(HashMap::from([("FOO".to_string(), "BAR".to_string())])) + env: Some(HashMap::from([("FOO".to_string(), "BAR".to_string())])), + env_vars: Vec::new(), + cwd: None, } ); assert!(cfg.enabled); } + #[test] + fn deserialize_stdio_command_server_config_with_env_vars() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + env_vars = ["FOO", "BAR"] + "#, + ) + .expect("should deserialize command config with env_vars"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec![], + env: None, + env_vars: vec!["FOO".to_string(), "BAR".to_string()], + cwd: None, + } + ); + } + + #[test] + fn deserialize_stdio_command_server_config_with_cwd() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + cwd = "/tmp" + "#, + ) + .expect("should deserialize command config with cwd"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec![], + env: None, + env_vars: Vec::new(), + cwd: Some(PathBuf::from("/tmp")), + } + ); + } + #[test] fn deserialize_disabled_server_config() { let cfg: McpServerConfig = toml::from_str( diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 8ddb0366fb..e7ff998340 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -10,6 +10,7 @@ use std::collections::HashMap; use std::collections::HashSet; use std::env; use std::ffi::OsString; +use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -102,20 +103,25 @@ enum McpClientAdapter { } impl McpClientAdapter { + #[allow(clippy::too_many_arguments)] async fn new_stdio_client( use_rmcp_client: bool, program: OsString, args: Vec, env: Option>, + env_vars: Vec, + cwd: Option, params: mcp_types::InitializeRequestParams, startup_timeout: Duration, ) -> Result { if use_rmcp_client { - let client = Arc::new(RmcpClient::new_stdio_client(program, args, env).await?); + let client = + Arc::new(RmcpClient::new_stdio_client(program, args, env, &env_vars, cwd).await?); client.initialize(params, Some(startup_timeout)).await?; Ok(McpClientAdapter::Rmcp(client)) } else { - let client = Arc::new(McpClient::new_stdio_client(program, args, env).await?); + let client = + Arc::new(McpClient::new_stdio_client(program, args, env, &env_vars, cwd).await?); client.initialize(params, Some(startup_timeout)).await?; Ok(McpClientAdapter::Legacy(client)) } @@ -256,7 +262,13 @@ impl McpConnectionManager { }; let client = match transport { - McpServerTransportConfig::Stdio { command, args, env } => { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { let command_os: OsString = command.into(); let args_os: Vec = args.into_iter().map(Into::into).collect(); McpClientAdapter::new_stdio_client( @@ -264,6 +276,8 @@ impl McpConnectionManager { command_os, args_os, env, + env_vars, + cwd, params, startup_timeout, ) diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index b2559cd2ee..1a2815b0ee 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::ffi::OsStr; use std::ffi::OsString; use std::fs; use std::net::TcpListener; @@ -35,6 +36,7 @@ use tokio::time::sleep; use wiremock::matchers::any; #[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[serial(mcp_test_value)] async fn stdio_server_round_trip() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); @@ -86,6 +88,8 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { "MCP_TEST_VALUE".to_string(), expected_env_value.to_string(), )])), + env_vars: Vec::new(), + cwd: None, }, enabled: true, startup_timeout_sec: Some(Duration::from_secs(10)), @@ -106,7 +110,143 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::DangerFullAccess, + sandbox_policy: SandboxPolicy::ReadOnly, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + let begin_event = wait_for_event_with_timeout( + &fixture.codex, + |ev| matches!(ev, EventMsg::McpToolCallBegin(_)), + Duration::from_secs(10), + ) + .await; + + let EventMsg::McpToolCallBegin(begin) = begin_event else { + unreachable!("event guard guarantees McpToolCallBegin"); + }; + assert_eq!(begin.invocation.server, server_name); + assert_eq!(begin.invocation.tool, "echo"); + + let end_event = wait_for_event(&fixture.codex, |ev| { + matches!(ev, EventMsg::McpToolCallEnd(_)) + }) + .await; + let EventMsg::McpToolCallEnd(end) = end_event else { + unreachable!("event guard guarantees McpToolCallEnd"); + }; + + let result = end + .result + .as_ref() + .expect("rmcp echo tool should return success"); + assert_eq!(result.is_error, Some(false)); + assert!( + result.content.is_empty(), + "content should default to an empty array" + ); + + let structured = result + .structured_content + .as_ref() + .expect("structured content"); + let Value::Object(map) = structured else { + panic!("structured content should be an object: {structured:?}"); + }; + let echo_value = map + .get("echo") + .and_then(Value::as_str) + .expect("echo payload present"); + assert_eq!(echo_value, "ECHOING: ping"); + let env_value = map + .get("env") + .and_then(Value::as_str) + .expect("env snapshot inserted"); + assert_eq!(env_value, expected_env_value); + + wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + server.verify().await; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[serial(mcp_test_value)] +async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + + let call_id = "call-1234"; + let server_name = "rmcp_whitelist"; + let tool_name = format!("{server_name}__echo"); + + mount_sse_once_match( + &server, + any(), + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call(call_id, &tool_name, "{\"message\":\"ping\"}"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + mount_sse_once_match( + &server, + any(), + responses::sse(vec![ + responses::ev_assistant_message("msg-1", "rmcp echo tool completed successfully."), + responses::ev_completed("resp-2"), + ]), + ) + .await; + + let expected_env_value = "propagated-env-from-whitelist"; + let _guard = EnvVarGuard::set("MCP_TEST_VALUE", OsStr::new(expected_env_value)); + let rmcp_test_server_bin = CargoBuild::new() + .package("codex-rmcp-client") + .bin("test_stdio_server") + .run()? + .path() + .to_string_lossy() + .into_owned(); + + let fixture = test_codex() + .with_config(move |config| { + config.features.enable(Feature::RmcpClient); + config.mcp_servers.insert( + server_name.to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: rmcp_test_server_bin, + args: Vec::new(), + env: None, + env_vars: vec!["MCP_TEST_VALUE".to_string()], + cwd: None, + }, + enabled: true, + startup_timeout_sec: Some(Duration::from_secs(10)), + tool_timeout_sec: None, + }, + ); + }) + .build(&server) + .await?; + let session_model = fixture.session_configured.model.clone(); + + fixture + .codex + .submit(Op::UserTurn { + items: vec![InputItem::Text { + text: "call the rmcp echo tool".into(), + }], + final_output_json_schema: None, + cwd: fixture.cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, model: session_model, effort: None, summary: ReasoningSummary::Auto, @@ -257,7 +397,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::DangerFullAccess, + sandbox_policy: SandboxPolicy::ReadOnly, model: session_model, effort: None, summary: ReasoningSummary::Auto, @@ -440,7 +580,7 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::DangerFullAccess, + sandbox_policy: SandboxPolicy::ReadOnly, model: session_model, effort: None, summary: ReasoningSummary::Auto, diff --git a/codex-rs/mcp-client/src/main.rs b/codex-rs/mcp-client/src/main.rs index f46058b99e..8e1f322dc7 100644 --- a/codex-rs/mcp-client/src/main.rs +++ b/codex-rs/mcp-client/src/main.rs @@ -49,7 +49,7 @@ async fn main() -> Result<()> { // Spawn the subprocess and connect the client. let program = args.remove(0); let env = None; - let client = McpClient::new_stdio_client(program, args, env) + let client = McpClient::new_stdio_client(program, args, env, &[], None) .await .with_context(|| format!("failed to spawn subprocess: {original_args:?}"))?; diff --git a/codex-rs/mcp-client/src/mcp_client.rs b/codex-rs/mcp-client/src/mcp_client.rs index 27f96494ee..3be93f35f6 100644 --- a/codex-rs/mcp-client/src/mcp_client.rs +++ b/codex-rs/mcp-client/src/mcp_client.rs @@ -13,6 +13,7 @@ use std::collections::HashMap; use std::ffi::OsString; +use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; @@ -86,19 +87,26 @@ impl McpClient { program: OsString, args: Vec, env: Option>, + env_vars: &[String], + cwd: Option, ) -> std::io::Result { - let mut child = Command::new(program) + let mut command = Command::new(program); + command .args(args) .env_clear() - .envs(create_env_for_mcp_server(env)) + .envs(create_env_for_mcp_server(env, env_vars)) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) // As noted in the `kill_on_drop` documentation, the Tokio runtime makes // a "best effort" to reap-after-exit to avoid zombie processes, but it // is not a guarantee. - .kill_on_drop(true) - .spawn()?; + .kill_on_drop(true); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } + + let mut child = command.spawn()?; let stdin = child .stdin @@ -447,12 +455,16 @@ const DEFAULT_ENV_VARS: &[&str] = &[ /// `config.toml`. fn create_env_for_mcp_server( extra_env: Option>, + env_vars: &[String], ) -> HashMap { DEFAULT_ENV_VARS .iter() - .filter_map(|var| match std::env::var(var) { - Ok(value) => Some((var.to_string(), value)), - Err(_) => None, + .copied() + .chain(env_vars.iter().map(String::as_str)) + .filter_map(|var| { + std::env::var(var) + .ok() + .map(|value| (var.to_string(), value)) }) .chain(extra_env.unwrap_or_default()) .collect::>() @@ -462,14 +474,36 @@ fn create_env_for_mcp_server( mod tests { use super::*; + fn set_env_var(key: &str, value: &str) { + unsafe { + std::env::set_var(key, value); + } + } + + fn remove_env_var(key: &str) { + unsafe { + std::env::remove_var(key); + } + } + #[test] fn test_create_env_for_mcp_server() { let env_var = "USER"; let env_var_existing_value = std::env::var(env_var).unwrap_or_default(); let env_var_new_value = format!("{env_var_existing_value}-extra"); let extra_env = HashMap::from([(env_var.to_owned(), env_var_new_value.clone())]); - let mcp_server_env = create_env_for_mcp_server(Some(extra_env)); + let mcp_server_env = create_env_for_mcp_server(Some(extra_env), &[]); assert!(mcp_server_env.contains_key("PATH")); assert_eq!(Some(&env_var_new_value), mcp_server_env.get(env_var)); } + + #[test] + fn test_create_env_for_mcp_server_includes_extra_whitelisted_vars() { + let custom_var = "CUSTOM_TEST_VAR"; + let value = "value".to_string(); + set_env_var(custom_var, &value); + let mcp_server_env = create_env_for_mcp_server(None, &[custom_var.to_string()]); + assert_eq!(Some(&value), mcp_server_env.get(custom_var)); + remove_env_var(custom_var); + } } diff --git a/codex-rs/rmcp-client/Cargo.toml b/codex-rs/rmcp-client/Cargo.toml index 99a609b339..0016f1142a 100644 --- a/codex-rs/rmcp-client/Cargo.toml +++ b/codex-rs/rmcp-client/Cargo.toml @@ -58,4 +58,5 @@ webbrowser = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } +serial_test = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index 038f32ef9c..245b4eb355 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::ffi::OsString; use std::io; +use std::path::PathBuf; use std::process::Stdio; use std::sync::Arc; use std::time::Duration; @@ -79,6 +80,8 @@ impl RmcpClient { program: OsString, args: Vec, env: Option>, + env_vars: &[String], + cwd: Option, ) -> io::Result { let program_name = program.to_string_lossy().into_owned(); let mut command = Command::new(&program); @@ -87,8 +90,11 @@ impl RmcpClient { .stdin(Stdio::piped()) .stdout(Stdio::piped()) .env_clear() - .envs(create_env_for_mcp_server(env)) + .envs(create_env_for_mcp_server(env, env_vars)) .args(&args); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } let (transport, stderr) = TokioChildProcess::builder(command) .stderr(Stdio::piped()) diff --git a/codex-rs/rmcp-client/src/utils.rs b/codex-rs/rmcp-client/src/utils.rs index cccb12c05a..17e050cb6f 100644 --- a/codex-rs/rmcp-client/src/utils.rs +++ b/codex-rs/rmcp-client/src/utils.rs @@ -74,9 +74,12 @@ where pub(crate) fn create_env_for_mcp_server( extra_env: Option>, + env_vars: &[String], ) -> HashMap { DEFAULT_ENV_VARS .iter() + .copied() + .chain(env_vars.iter().map(String::as_str)) .filter_map(|var| env::var(var).ok().map(|value| (var.to_string(), value))) .chain(extra_env.unwrap_or_default()) .collect() @@ -185,13 +188,59 @@ mod tests { use rmcp::model::CallToolResult as RmcpCallToolResult; use serde_json::json; + use serial_test::serial; + use std::ffi::OsString; + + struct EnvVarGuard { + key: String, + original: Option, + } + + impl EnvVarGuard { + fn set(key: &str, value: &str) -> Self { + let original = std::env::var_os(key); + unsafe { + std::env::set_var(key, value); + } + Self { + key: key.to_string(), + original, + } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(value) = &self.original { + unsafe { + std::env::set_var(&self.key, value); + } + } else { + unsafe { + std::env::remove_var(&self.key); + } + } + } + } + #[tokio::test] async fn create_env_honors_overrides() { let value = "custom".to_string(); - let env = create_env_for_mcp_server(Some(HashMap::from([("TZ".into(), value.clone())]))); + let env = + create_env_for_mcp_server(Some(HashMap::from([("TZ".into(), value.clone())])), &[]); assert_eq!(env.get("TZ"), Some(&value)); } + #[test] + #[serial(extra_rmcp_env)] + fn create_env_includes_additional_whitelisted_variables() { + let custom_var = "EXTRA_RMCP_ENV"; + let value = "from-env"; + let _guard = EnvVarGuard::set(custom_var, value); + let env = create_env_for_mcp_server(None, &[custom_var.to_string()]); + assert_eq!(env.get(custom_var), Some(&value.to_string())); + } + #[test] fn convert_call_tool_result_defaults_missing_content() -> Result<()> { let structured_content = json!({ "key": "value" }); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 2dd2b57857..dae48d487c 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -20,6 +20,7 @@ use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; use crate::wrapping::word_wrap_lines; use base64::Engine; +use codex_common::format_env_display::format_env_display; use codex_core::config::Config; use codex_core::config_types::McpServerTransportConfig; use codex_core::config_types::ReasoningSummaryFormat; @@ -1013,7 +1014,13 @@ pub(crate) fn new_mcp_tools_output( lines.push(vec![" • Auth: ".into(), status.to_string().into()].into()); match &cfg.transport { - McpServerTransportConfig::Stdio { command, args, env } => { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { let args_suffix = if args.is_empty() { String::new() } else { @@ -1022,13 +1029,13 @@ pub(crate) fn new_mcp_tools_output( let cmd_display = format!("{command}{args_suffix}"); lines.push(vec![" • Command: ".into(), cmd_display.into()].into()); - if let Some(env) = env.as_ref() - && !env.is_empty() - { - let mut env_pairs: Vec = - env.iter().map(|(k, v)| format!("{k}={v}")).collect(); - env_pairs.sort(); - lines.push(vec![" • Env: ".into(), env_pairs.join(" ").into()].into()); + if let Some(cwd) = cwd.as_ref() { + lines.push(vec![" • Cwd: ".into(), cwd.display().to_string().into()].into()); + } + + let env_display = format_env_display(env.as_ref(), env_vars); + if env_display != "-" { + lines.push(vec![" • Env: ".into(), env_display.into()].into()); } } McpServerTransportConfig::StreamableHttp { @@ -1077,7 +1084,6 @@ pub(crate) fn new_mcp_tools_output( PlainHistoryCell { lines } } - pub(crate) fn new_info_event(message: String, hint: Option) -> PlainHistoryCell { let mut line = vec!["• ".dim(), message.into()]; if let Some(hint) = hint { diff --git a/docs/config.md b/docs/config.md index 8b5a45ab06..dafd9dbae3 100644 --- a/docs/config.md +++ b/docs/config.md @@ -359,6 +359,11 @@ env = { "API_KEY" = "value" } # or [mcp_servers.server_name.env] API_KEY = "value" +# Optional: Additional list of environment variables that will be whitelisted in the MCP server's environment. +env_vars = ["API_KEY2"] + +# Optional: cwd that the command will be run from +cwd = "/Users//code/my-server" ``` #### Streamable HTTP From 40fba1bb4c70a98138e54930e40d347055bc7b86 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Thu, 16 Oct 2025 22:05:15 -0700 Subject: [PATCH 008/287] [MCP] Add support for resources (#5239) This PR adds support for [MCP resources](https://modelcontextprotocol.io/specification/2025-06-18/server/resources) by adding three new tools for the model: 1. `list_resources` 2. `list_resource_templates` 3. `read_resource` These 3 tools correspond to the [three primary MCP resource protocol messages](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#protocol-messages). Example of listing and reading a GitHub resource tempalte CleanShot 2025-10-15 at 17 31 10 `/mcp` with Figma configured CleanShot 2025-10-15 at 18 29 35 Fixes #4956 --- AGENTS.md | 1 + codex-rs/Cargo.lock | 1 + codex-rs/core/src/codex.rs | 56 +- codex-rs/core/src/mcp_connection_manager.rs | 240 +++++- .../core/src/tools/handlers/mcp_resource.rs | 773 ++++++++++++++++++ codex-rs/core/src/tools/handlers/mod.rs | 2 + codex-rs/core/src/tools/spec.rs | 166 +++- codex-rs/core/tests/suite/model_tools.rs | 22 +- codex-rs/core/tests/suite/prompt_caching.rs | 22 +- codex-rs/protocol/src/protocol.rs | 6 + codex-rs/rmcp-client/Cargo.toml | 1 + .../rmcp-client/src/bin/test_stdio_server.rs | 96 +++ .../src/bin/test_streamable_http_server.rs | 96 +++ codex-rs/rmcp-client/src/rmcp_client.rs | 57 ++ codex-rs/rmcp-client/tests/resources.rs | 124 +++ codex-rs/tui/src/chatwidget.rs | 2 + codex-rs/tui/src/history_cell.rs | 60 +- docs/config.md | 4 + 18 files changed, 1705 insertions(+), 24 deletions(-) create mode 100644 codex-rs/core/src/tools/handlers/mcp_resource.rs create mode 100644 codex-rs/rmcp-client/tests/resources.rs diff --git a/AGENTS.md b/AGENTS.md index 832f1d6543..81b8d10ac7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ In the codex-rs folder where the rust code lives: - Always inline format! args when possible per https://rust-lang.github.io/rust-clippy/master/index.html#uninlined_format_args - Use method references over closures when possible per https://rust-lang.github.io/rust-clippy/master/index.html#redundant_closure_for_method_calls - When writing tests, prefer comparing the equality of entire objects over fields one by one. +- When making a change that adds or changes an API, ensure that the documentation in the `docs/` folder is up to date if applicable. Run `just fmt` (in `codex-rs` directory) automatically after making Rust code changes; do not ask for approval to run it. Before finalizing a change to `codex-rs`, run `just fix -p ` (in `codex-rs` directory) to fix any linter issues in the code. Prefer scoping with `-p` to avoid slow workspace‑wide Clippy builds; only run `just fix` without `-p` if you changed shared crates. Additionally, run the tests: diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 6289625ae0..45a41b819a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1365,6 +1365,7 @@ dependencies = [ "axum", "codex-protocol", "dirs", + "escargot", "futures", "keyring", "mcp-types", diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index ce86b808c0..8acf8b20a8 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -28,6 +28,12 @@ use futures::future::BoxFuture; use futures::prelude::*; use futures::stream::FuturesOrdered; use mcp_types::CallToolResult; +use mcp_types::ListResourceTemplatesRequestParams; +use mcp_types::ListResourceTemplatesResult; +use mcp_types::ListResourcesRequestParams; +use mcp_types::ListResourcesResult; +use mcp_types::ReadResourceRequestParams; +use mcp_types::ReadResourceResult; use serde_json; use serde_json::Value; use tokio::sync::Mutex; @@ -1057,6 +1063,39 @@ impl Session { } } + pub async fn list_resources( + &self, + server: &str, + params: Option, + ) -> anyhow::Result { + self.services + .mcp_connection_manager + .list_resources(server, params) + .await + } + + pub async fn list_resource_templates( + &self, + server: &str, + params: Option, + ) -> anyhow::Result { + self.services + .mcp_connection_manager + .list_resource_templates(server, params) + .await + } + + pub async fn read_resource( + &self, + server: &str, + params: ReadResourceRequestParams, + ) -> anyhow::Result { + self.services + .mcp_connection_manager + .read_resource(server, params) + .await + } + pub async fn call_tool( &self, server: &str, @@ -1419,16 +1458,23 @@ async fn submission_loop( // This is a cheap lookup from the connection manager's cache. let tools = sess.services.mcp_connection_manager.list_all_tools(); - let auth_statuses = compute_auth_statuses( - config.mcp_servers.iter(), - config.mcp_oauth_credentials_store_mode, - ) - .await; + let (auth_statuses, resources, resource_templates) = tokio::join!( + compute_auth_statuses( + config.mcp_servers.iter(), + config.mcp_oauth_credentials_store_mode, + ), + sess.services.mcp_connection_manager.list_all_resources(), + sess.services + .mcp_connection_manager + .list_all_resource_templates() + ); let event = Event { id: sub_id, msg: EventMsg::McpListToolsResponse( crate::protocol::McpListToolsResponseEvent { tools, + resources, + resource_templates, auth_statuses, }, ), diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index e7ff998340..99c2e626e5 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -22,6 +22,14 @@ use codex_rmcp_client::OAuthCredentialsStoreMode; use codex_rmcp_client::RmcpClient; use mcp_types::ClientCapabilities; use mcp_types::Implementation; +use mcp_types::ListResourceTemplatesRequestParams; +use mcp_types::ListResourceTemplatesResult; +use mcp_types::ListResourcesRequestParams; +use mcp_types::ListResourcesResult; +use mcp_types::ReadResourceRequestParams; +use mcp_types::ReadResourceResult; +use mcp_types::Resource; +use mcp_types::ResourceTemplate; use mcp_types::Tool; use serde_json::json; @@ -164,6 +172,47 @@ impl McpClientAdapter { } } + async fn list_resources( + &self, + params: Option, + timeout: Option, + ) -> Result { + match self { + McpClientAdapter::Legacy(_) => Ok(ListResourcesResult { + next_cursor: None, + resources: Vec::new(), + }), + McpClientAdapter::Rmcp(client) => client.list_resources(params, timeout).await, + } + } + + async fn read_resource( + &self, + params: mcp_types::ReadResourceRequestParams, + timeout: Option, + ) -> Result { + match self { + McpClientAdapter::Legacy(_) => Err(anyhow!( + "resources/read is not supported by legacy MCP clients" + )), + McpClientAdapter::Rmcp(client) => client.read_resource(params, timeout).await, + } + } + + async fn list_resource_templates( + &self, + params: Option, + timeout: Option, + ) -> Result { + match self { + McpClientAdapter::Legacy(_) => Ok(ListResourceTemplatesResult { + next_cursor: None, + resource_templates: Vec::new(), + }), + McpClientAdapter::Rmcp(client) => client.list_resource_templates(params, timeout).await, + } + } + async fn call_tool( &self, name: String, @@ -349,7 +398,7 @@ impl McpConnectionManager { Ok((Self { clients, tools }, errors)) } - /// Returns a single map that contains **all** tools. Each key is the + /// Returns a single map that contains all tools. Each key is the /// fully-qualified name for the tool. pub fn list_all_tools(&self) -> HashMap { self.tools @@ -358,6 +407,133 @@ impl McpConnectionManager { .collect() } + /// 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> { + let mut join_set = JoinSet::new(); + + for (server_name, managed_client) in &self.clients { + let server_name_cloned = server_name.clone(); + let client_clone = managed_client.client.clone(); + let timeout = managed_client.tool_timeout; + + join_set.spawn(async move { + let mut collected: Vec = Vec::new(); + let mut cursor: Option = None; + + loop { + let params = cursor.as_ref().map(|next| ListResourcesRequestParams { + cursor: Some(next.clone()), + }); + let response = match client_clone.list_resources(params, timeout).await { + Ok(result) => result, + Err(err) => return (server_name_cloned, Err(err)), + }; + + collected.extend(response.resources); + + match response.next_cursor { + Some(next) => { + if cursor.as_ref() == Some(&next) { + return ( + server_name_cloned, + Err(anyhow!("resources/list returned duplicate cursor")), + ); + } + cursor = Some(next); + } + None => return (server_name_cloned, Ok(collected)), + } + } + }); + } + + let mut aggregated: HashMap> = HashMap::new(); + + while let Some(join_res) = join_set.join_next().await { + match join_res { + Ok((server_name, Ok(resources))) => { + aggregated.insert(server_name, resources); + } + Ok((server_name, Err(err))) => { + warn!("Failed to list resources for MCP server '{server_name}': {err:#}"); + } + Err(err) => { + warn!("Task panic when listing resources for MCP server: {err:#}"); + } + } + } + + 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> { + let mut join_set = JoinSet::new(); + + for (server_name, managed_client) in &self.clients { + let server_name_cloned = server_name.clone(); + let client_clone = managed_client.client.clone(); + let timeout = managed_client.tool_timeout; + + join_set.spawn(async move { + let mut collected: Vec = Vec::new(); + let mut cursor: Option = None; + + loop { + let params = cursor + .as_ref() + .map(|next| ListResourceTemplatesRequestParams { + cursor: Some(next.clone()), + }); + let response = match client_clone.list_resource_templates(params, timeout).await + { + Ok(result) => result, + Err(err) => return (server_name_cloned, Err(err)), + }; + + collected.extend(response.resource_templates); + + match response.next_cursor { + Some(next) => { + if cursor.as_ref() == Some(&next) { + return ( + server_name_cloned, + Err(anyhow!( + "resources/templates/list returned duplicate cursor" + )), + ); + } + cursor = Some(next); + } + None => return (server_name_cloned, Ok(collected)), + } + } + }); + } + + let mut aggregated: HashMap> = HashMap::new(); + + while let Some(join_res) = join_set.join_next().await { + match join_res { + Ok((server_name, Ok(templates))) => { + aggregated.insert(server_name, templates); + } + Ok((server_name, Err(err))) => { + warn!( + "Failed to list resource templates for MCP server '{server_name}': {err:#}" + ); + } + Err(err) => { + warn!("Task panic when listing resource templates for MCP server: {err:#}"); + } + } + } + + aggregated + } + /// Invoke the tool indicated by the (server, tool) pair. pub async fn call_tool( &self, @@ -369,7 +545,7 @@ impl McpConnectionManager { .clients .get(server) .ok_or_else(|| anyhow!("unknown MCP server '{server}'"))?; - let client = managed.client.clone(); + let client = &managed.client; let timeout = managed.tool_timeout; client @@ -378,6 +554,64 @@ impl McpConnectionManager { .with_context(|| format!("tool call failed for `{server}/{tool}`")) } + /// List resources from the specified server. + pub async fn list_resources( + &self, + server: &str, + params: Option, + ) -> Result { + let managed = self + .clients + .get(server) + .ok_or_else(|| anyhow!("unknown MCP server '{server}'"))?; + let client = managed.client.clone(); + let timeout = managed.tool_timeout; + + client + .list_resources(params, timeout) + .await + .with_context(|| format!("resources/list failed for `{server}`")) + } + + /// List resource templates from the specified server. + pub async fn list_resource_templates( + &self, + server: &str, + params: Option, + ) -> Result { + let managed = self + .clients + .get(server) + .ok_or_else(|| anyhow!("unknown MCP server '{server}'"))?; + let client = managed.client.clone(); + let timeout = managed.tool_timeout; + + client + .list_resource_templates(params, timeout) + .await + .with_context(|| format!("resources/templates/list failed for `{server}`")) + } + + /// Read a resource from the specified server. + pub async fn read_resource( + &self, + server: &str, + params: ReadResourceRequestParams, + ) -> Result { + let managed = self + .clients + .get(server) + .ok_or_else(|| anyhow!("unknown MCP server '{server}'"))?; + let client = managed.client.clone(); + let timeout = managed.tool_timeout; + let uri = params.uri.clone(); + + client + .read_resource(params, timeout) + .await + .with_context(|| format!("resources/read failed for `{server}` ({uri})")) + } + pub fn parse_tool_name(&self, tool_name: &str) -> Option<(String, String)> { self.tools .get(tool_name) @@ -413,7 +647,7 @@ fn resolve_bearer_token( } /// Query every server for its available tools and return a single map that -/// contains **all** tools. Each key is the fully-qualified name for the tool. +/// contains all tools. Each key is the fully-qualified name for the tool. async fn list_all_tools(clients: &HashMap) -> Result> { let mut join_set = JoinSet::new(); diff --git a/codex-rs/core/src/tools/handlers/mcp_resource.rs b/codex-rs/core/src/tools/handlers/mcp_resource.rs new file mode 100644 index 0000000000..7e425aad9b --- /dev/null +++ b/codex-rs/core/src/tools/handlers/mcp_resource.rs @@ -0,0 +1,773 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; + +use async_trait::async_trait; +use mcp_types::CallToolResult; +use mcp_types::ContentBlock; +use mcp_types::ListResourceTemplatesRequestParams; +use mcp_types::ListResourceTemplatesResult; +use mcp_types::ListResourcesRequestParams; +use mcp_types::ListResourcesResult; +use mcp_types::ReadResourceRequestParams; +use mcp_types::ReadResourceResult; +use mcp_types::Resource; +use mcp_types::ResourceTemplate; +use mcp_types::TextContent; +use serde::Deserialize; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::Value; + +use crate::codex::Session; +use crate::function_tool::FunctionCallError; +use crate::protocol::Event; +use crate::protocol::EventMsg; +use crate::protocol::McpInvocation; +use crate::protocol::McpToolCallBeginEvent; +use crate::protocol::McpToolCallEndEvent; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolOutput; +use crate::tools::context::ToolPayload; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; + +pub struct McpResourceHandler; + +#[derive(Debug, Deserialize, Default)] +struct ListResourcesArgs { + /// Lists all resources from all servers if not specified. + #[serde(default)] + server: Option, + #[serde(default)] + cursor: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct ListResourceTemplatesArgs { + /// Lists all resource templates from all servers if not specified. + #[serde(default)] + server: Option, + #[serde(default)] + cursor: Option, +} + +#[derive(Debug, Deserialize)] +struct ReadResourceArgs { + server: String, + uri: String, +} + +#[derive(Debug, Serialize)] +struct ResourceWithServer { + server: String, + #[serde(flatten)] + resource: Resource, +} + +impl ResourceWithServer { + fn new(server: String, resource: Resource) -> Self { + Self { server, resource } + } +} + +#[derive(Debug, Serialize)] +struct ResourceTemplateWithServer { + server: String, + #[serde(flatten)] + template: ResourceTemplate, +} + +impl ResourceTemplateWithServer { + fn new(server: String, template: ResourceTemplate) -> Self { + Self { server, template } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ListResourcesPayload { + #[serde(skip_serializing_if = "Option::is_none")] + server: Option, + resources: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + next_cursor: Option, +} + +impl ListResourcesPayload { + fn from_single_server(server: String, result: ListResourcesResult) -> Self { + let resources = result + .resources + .into_iter() + .map(|resource| ResourceWithServer::new(server.clone(), resource)) + .collect(); + Self { + server: Some(server), + resources, + next_cursor: result.next_cursor, + } + } + + fn from_all_servers(resources_by_server: HashMap>) -> Self { + let mut entries: Vec<(String, Vec)> = resources_by_server.into_iter().collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut resources = Vec::new(); + for (server, server_resources) in entries { + for resource in server_resources { + resources.push(ResourceWithServer::new(server.clone(), resource)); + } + } + + Self { + server: None, + resources, + next_cursor: None, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ListResourceTemplatesPayload { + #[serde(skip_serializing_if = "Option::is_none")] + server: Option, + resource_templates: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + next_cursor: Option, +} + +impl ListResourceTemplatesPayload { + fn from_single_server(server: String, result: ListResourceTemplatesResult) -> Self { + let resource_templates = result + .resource_templates + .into_iter() + .map(|template| ResourceTemplateWithServer::new(server.clone(), template)) + .collect(); + Self { + server: Some(server), + resource_templates, + next_cursor: result.next_cursor, + } + } + + fn from_all_servers(templates_by_server: HashMap>) -> Self { + let mut entries: Vec<(String, Vec)> = + templates_by_server.into_iter().collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut resource_templates = Vec::new(); + for (server, server_templates) in entries { + for template in server_templates { + resource_templates.push(ResourceTemplateWithServer::new(server.clone(), template)); + } + } + + Self { + server: None, + resource_templates, + next_cursor: None, + } + } +} + +#[derive(Debug, Serialize)] +struct ReadResourcePayload { + server: String, + uri: String, + #[serde(flatten)] + result: ReadResourceResult, +} + +#[async_trait] +impl ToolHandler for McpResourceHandler { + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + sub_id, + call_id, + tool_name, + payload, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "mcp_resource handler received unsupported payload".to_string(), + )); + } + }; + + let arguments_value = parse_arguments(arguments.as_str())?; + + match tool_name.as_str() { + "list_mcp_resources" => { + handle_list_resources( + Arc::clone(&session), + sub_id.clone(), + call_id.clone(), + arguments_value.clone(), + ) + .await + } + "list_mcp_resource_templates" => { + handle_list_resource_templates( + Arc::clone(&session), + sub_id.clone(), + call_id.clone(), + arguments_value.clone(), + ) + .await + } + "read_mcp_resource" => { + handle_read_resource(Arc::clone(&session), sub_id, call_id, arguments_value).await + } + other => Err(FunctionCallError::RespondToModel(format!( + "unsupported MCP resource tool: {other}" + ))), + } + } +} + +async fn handle_list_resources( + session: Arc, + sub_id: String, + call_id: String, + arguments: Option, +) -> Result { + let args: ListResourcesArgs = parse_args_with_default(arguments.clone())?; + let ListResourcesArgs { server, cursor } = args; + let server = normalize_optional_string(server); + let cursor = normalize_optional_string(cursor); + + let invocation = McpInvocation { + server: server.clone().unwrap_or_else(|| "codex".to_string()), + tool: "list_mcp_resources".to_string(), + arguments: arguments.clone(), + }; + + emit_tool_call_begin(&session, &sub_id, &call_id, invocation.clone()).await; + let start = Instant::now(); + + let payload_result: Result = async { + if let Some(server_name) = server.clone() { + let params = cursor.clone().map(|value| ListResourcesRequestParams { + cursor: Some(value), + }); + let result = session + .list_resources(&server_name, params) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!("resources/list failed: {err:#}")) + })?; + Ok(ListResourcesPayload::from_single_server( + server_name, + result, + )) + } else { + if cursor.is_some() { + return Err(FunctionCallError::RespondToModel( + "cursor can only be used when a server is specified".to_string(), + )); + } + + let resources = session + .services + .mcp_connection_manager + .list_all_resources() + .await; + Ok(ListResourcesPayload::from_all_servers(resources)) + } + } + .await; + + match payload_result { + Ok(payload) => match serialize_function_output(payload) { + Ok(output) => { + let ToolOutput::Function { content, success } = &output else { + unreachable!("MCP resource handler should return function output"); + }; + let duration = start.elapsed(); + emit_tool_call_end( + &session, + &sub_id, + &call_id, + invocation, + duration, + Ok(call_tool_result_from_content(content, *success)), + ) + .await; + Ok(output) + } + Err(err) => { + let duration = start.elapsed(); + let message = err.to_string(); + emit_tool_call_end( + &session, + &sub_id, + &call_id, + invocation, + duration, + Err(message.clone()), + ) + .await; + Err(err) + } + }, + Err(err) => { + let duration = start.elapsed(); + let message = err.to_string(); + emit_tool_call_end( + &session, + &sub_id, + &call_id, + invocation, + duration, + Err(message.clone()), + ) + .await; + Err(err) + } + } +} + +async fn handle_list_resource_templates( + session: Arc, + sub_id: String, + call_id: String, + arguments: Option, +) -> Result { + let args: ListResourceTemplatesArgs = parse_args_with_default(arguments.clone())?; + let ListResourceTemplatesArgs { server, cursor } = args; + let server = normalize_optional_string(server); + let cursor = normalize_optional_string(cursor); + + let invocation = McpInvocation { + server: server.clone().unwrap_or_else(|| "codex".to_string()), + tool: "list_mcp_resource_templates".to_string(), + arguments: arguments.clone(), + }; + + emit_tool_call_begin(&session, &sub_id, &call_id, invocation.clone()).await; + let start = Instant::now(); + + let payload_result: Result = async { + if let Some(server_name) = server.clone() { + let params = cursor + .clone() + .map(|value| ListResourceTemplatesRequestParams { + cursor: Some(value), + }); + let result = session + .list_resource_templates(&server_name, params) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!( + "resources/templates/list failed: {err:#}" + )) + })?; + Ok(ListResourceTemplatesPayload::from_single_server( + server_name, + result, + )) + } else { + if cursor.is_some() { + return Err(FunctionCallError::RespondToModel( + "cursor can only be used when a server is specified".to_string(), + )); + } + + let templates = session + .services + .mcp_connection_manager + .list_all_resource_templates() + .await; + Ok(ListResourceTemplatesPayload::from_all_servers(templates)) + } + } + .await; + + match payload_result { + Ok(payload) => match serialize_function_output(payload) { + Ok(output) => { + let ToolOutput::Function { content, success } = &output else { + unreachable!("MCP resource handler should return function output"); + }; + let duration = start.elapsed(); + emit_tool_call_end( + &session, + &sub_id, + &call_id, + invocation, + duration, + Ok(call_tool_result_from_content(content, *success)), + ) + .await; + Ok(output) + } + Err(err) => { + let duration = start.elapsed(); + let message = err.to_string(); + emit_tool_call_end( + &session, + &sub_id, + &call_id, + invocation, + duration, + Err(message.clone()), + ) + .await; + Err(err) + } + }, + Err(err) => { + let duration = start.elapsed(); + let message = err.to_string(); + emit_tool_call_end( + &session, + &sub_id, + &call_id, + invocation, + duration, + Err(message.clone()), + ) + .await; + Err(err) + } + } +} + +async fn handle_read_resource( + session: Arc, + sub_id: String, + call_id: String, + arguments: Option, +) -> Result { + let args: ReadResourceArgs = parse_args(arguments.clone())?; + let ReadResourceArgs { server, uri } = args; + let server = normalize_required_string("server", server)?; + let uri = normalize_required_string("uri", uri)?; + + let invocation = McpInvocation { + server: server.clone(), + tool: "read_mcp_resource".to_string(), + arguments: arguments.clone(), + }; + + emit_tool_call_begin(&session, &sub_id, &call_id, invocation.clone()).await; + let start = Instant::now(); + + let payload_result: Result = async { + let result = session + .read_resource(&server, ReadResourceRequestParams { uri: uri.clone() }) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!("resources/read failed: {err:#}")) + })?; + + Ok(ReadResourcePayload { + server, + uri, + result, + }) + } + .await; + + match payload_result { + Ok(payload) => match serialize_function_output(payload) { + Ok(output) => { + let ToolOutput::Function { content, success } = &output else { + unreachable!("MCP resource handler should return function output"); + }; + let duration = start.elapsed(); + emit_tool_call_end( + &session, + &sub_id, + &call_id, + invocation, + duration, + Ok(call_tool_result_from_content(content, *success)), + ) + .await; + Ok(output) + } + Err(err) => { + let duration = start.elapsed(); + let message = err.to_string(); + emit_tool_call_end( + &session, + &sub_id, + &call_id, + invocation, + duration, + Err(message.clone()), + ) + .await; + Err(err) + } + }, + Err(err) => { + let duration = start.elapsed(); + let message = err.to_string(); + emit_tool_call_end( + &session, + &sub_id, + &call_id, + invocation, + duration, + Err(message.clone()), + ) + .await; + Err(err) + } + } +} + +fn call_tool_result_from_content(content: &str, success: Option) -> CallToolResult { + CallToolResult { + content: vec![ContentBlock::TextContent(TextContent { + annotations: None, + text: content.to_string(), + r#type: "text".to_string(), + })], + is_error: success.map(|value| !value), + structured_content: None, + } +} + +async fn emit_tool_call_begin( + session: &Arc, + sub_id: &str, + call_id: &str, + invocation: McpInvocation, +) { + session + .send_event(Event { + id: sub_id.to_string(), + msg: EventMsg::McpToolCallBegin(McpToolCallBeginEvent { + call_id: call_id.to_string(), + invocation, + }), + }) + .await; +} + +async fn emit_tool_call_end( + session: &Arc, + sub_id: &str, + call_id: &str, + invocation: McpInvocation, + duration: Duration, + result: Result, +) { + session + .send_event(Event { + id: sub_id.to_string(), + msg: EventMsg::McpToolCallEnd(McpToolCallEndEvent { + call_id: call_id.to_string(), + invocation, + duration, + result, + }), + }) + .await; +} + +fn normalize_optional_string(input: Option) -> Option { + input.and_then(|value| { + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +fn normalize_required_string(field: &str, value: String) -> Result { + match normalize_optional_string(Some(value)) { + Some(normalized) => Ok(normalized), + None => Err(FunctionCallError::RespondToModel(format!( + "{field} must be provided" + ))), + } +} + +fn serialize_function_output(payload: T) -> Result +where + T: Serialize, +{ + let content = serde_json::to_string(&payload).map_err(|err| { + FunctionCallError::RespondToModel(format!( + "failed to serialize MCP resource response: {err}" + )) + })?; + + Ok(ToolOutput::Function { + content, + success: Some(true), + }) +} + +fn parse_arguments(raw_args: &str) -> Result, FunctionCallError> { + if raw_args.trim().is_empty() { + Ok(None) + } else { + serde_json::from_str(raw_args).map(Some).map_err(|err| { + FunctionCallError::RespondToModel(format!("failed to parse function arguments: {err}")) + }) + } +} + +fn parse_args(arguments: Option) -> Result +where + T: DeserializeOwned, +{ + match arguments { + Some(value) => serde_json::from_value(value).map_err(|err| { + FunctionCallError::RespondToModel(format!("failed to parse function arguments: {err}")) + }), + None => Err(FunctionCallError::RespondToModel( + "failed to parse function arguments: expected value".to_string(), + )), + } +} + +fn parse_args_with_default(arguments: Option) -> Result +where + T: DeserializeOwned + Default, +{ + match arguments { + Some(value) => parse_args(Some(value)), + None => Ok(T::default()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use mcp_types::ListResourcesResult; + use mcp_types::ResourceTemplate; + use pretty_assertions::assert_eq; + use serde_json::json; + + fn resource(uri: &str, name: &str) -> Resource { + Resource { + annotations: None, + description: None, + mime_type: None, + name: name.to_string(), + size: None, + title: None, + uri: uri.to_string(), + } + } + + fn template(uri_template: &str, name: &str) -> ResourceTemplate { + ResourceTemplate { + annotations: None, + description: None, + mime_type: None, + name: name.to_string(), + title: None, + uri_template: uri_template.to_string(), + } + } + + #[test] + fn resource_with_server_serializes_server_field() { + let entry = ResourceWithServer::new("test".to_string(), resource("memo://id", "memo")); + let value = serde_json::to_value(&entry).expect("serialize resource"); + + assert_eq!(value["server"], json!("test")); + assert_eq!(value["uri"], json!("memo://id")); + assert_eq!(value["name"], json!("memo")); + } + + #[test] + fn list_resources_payload_from_single_server_copies_next_cursor() { + let result = ListResourcesResult { + next_cursor: Some("cursor-1".to_string()), + resources: vec![resource("memo://id", "memo")], + }; + let payload = ListResourcesPayload::from_single_server("srv".to_string(), result); + let value = serde_json::to_value(&payload).expect("serialize payload"); + + assert_eq!(value["server"], json!("srv")); + assert_eq!(value["nextCursor"], json!("cursor-1")); + let resources = value["resources"].as_array().expect("resources array"); + assert_eq!(resources.len(), 1); + assert_eq!(resources[0]["server"], json!("srv")); + } + + #[test] + fn list_resources_payload_from_all_servers_is_sorted() { + let mut map = HashMap::new(); + map.insert("beta".to_string(), vec![resource("memo://b-1", "b-1")]); + map.insert( + "alpha".to_string(), + vec![resource("memo://a-1", "a-1"), resource("memo://a-2", "a-2")], + ); + + let payload = ListResourcesPayload::from_all_servers(map); + let value = serde_json::to_value(&payload).expect("serialize payload"); + let uris: Vec = value["resources"] + .as_array() + .expect("resources array") + .iter() + .map(|entry| entry["uri"].as_str().unwrap().to_string()) + .collect(); + + assert_eq!( + uris, + vec![ + "memo://a-1".to_string(), + "memo://a-2".to_string(), + "memo://b-1".to_string() + ] + ); + } + + #[test] + fn call_tool_result_from_content_marks_success() { + let result = call_tool_result_from_content("{}", Some(true)); + assert_eq!(result.is_error, Some(false)); + assert_eq!(result.content.len(), 1); + } + + #[test] + fn parse_arguments_handles_empty_and_json() { + assert!( + parse_arguments(" \n\t").unwrap().is_none(), + "expected None for empty arguments" + ); + + let value = parse_arguments(r#"{"server":"figma"}"#) + .expect("parse json") + .expect("value present"); + assert_eq!(value["server"], json!("figma")); + } + + #[test] + fn template_with_server_serializes_server_field() { + let entry = + ResourceTemplateWithServer::new("srv".to_string(), template("memo://{id}", "memo")); + let value = serde_json::to_value(&entry).expect("serialize template"); + + assert_eq!( + value, + json!({ + "server": "srv", + "uriTemplate": "memo://{id}", + "name": "memo" + }) + ); + } +} diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 9bff9fd5e7..355e5d607a 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -3,6 +3,7 @@ mod exec_stream; mod grep_files; mod list_dir; mod mcp; +mod mcp_resource; mod plan; mod read_file; mod shell; @@ -17,6 +18,7 @@ pub use exec_stream::ExecStreamHandler; pub use grep_files::GrepFilesHandler; pub use list_dir::ListDirHandler; pub use mcp::McpHandler; +pub use mcp_resource::McpResourceHandler; pub use plan::PlanHandler; pub use read_file::ReadFileHandler; pub use shell::ShellHandler; diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index bb1df187c2..5352051917 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -511,6 +511,107 @@ fn create_list_dir_tool() -> ToolSpec { }, }) } + +fn create_list_mcp_resources_tool() -> ToolSpec { + let mut properties = BTreeMap::new(); + properties.insert( + "server".to_string(), + JsonSchema::String { + description: Some( + "Optional MCP server name. When omitted, lists resources from every configured server." + .to_string(), + ), + }, + ); + properties.insert( + "cursor".to_string(), + JsonSchema::String { + description: Some( + "Opaque cursor returned by a previous list_mcp_resources call for the same server." + .to_string(), + ), + }, + ); + + ToolSpec::Function(ResponsesApiTool { + name: "list_mcp_resources".to_string(), + description: "Lists resources provided by MCP servers. Resources allow servers to share data that provides context to language models, such as files, database schemas, or application-specific information. Prefer resources over web search when possible.".to_string(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: None, + additional_properties: Some(false.into()), + }, + }) +} + +fn create_list_mcp_resource_templates_tool() -> ToolSpec { + let mut properties = BTreeMap::new(); + properties.insert( + "server".to_string(), + JsonSchema::String { + description: Some( + "Optional MCP server name. When omitted, lists resource templates from all configured servers." + .to_string(), + ), + }, + ); + properties.insert( + "cursor".to_string(), + JsonSchema::String { + description: Some( + "Opaque cursor returned by a previous list_mcp_resource_templates call for the same server." + .to_string(), + ), + }, + ); + + ToolSpec::Function(ResponsesApiTool { + name: "list_mcp_resource_templates".to_string(), + description: "Lists resource templates provided by MCP servers. Parameterized resource templates allow servers to share data that takes parameters and provides context to language models, such as files, database schemas, or application-specific information. Prefer resource templates over web search when possible.".to_string(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: None, + additional_properties: Some(false.into()), + }, + }) +} + +fn create_read_mcp_resource_tool() -> ToolSpec { + let mut properties = BTreeMap::new(); + properties.insert( + "server".to_string(), + JsonSchema::String { + description: Some( + "MCP server name exactly as configured. Must match the 'server' field returned by list_mcp_resources." + .to_string(), + ), + }, + ); + properties.insert( + "uri".to_string(), + JsonSchema::String { + description: Some( + "Resource URI to read. Must be one of the URIs returned by list_mcp_resources." + .to_string(), + ), + }, + ); + + ToolSpec::Function(ResponsesApiTool { + name: "read_mcp_resource".to_string(), + description: + "Read a specific resource from an MCP server given the server name and resource URI." + .to_string(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: Some(vec!["server".to_string(), "uri".to_string()]), + additional_properties: Some(false.into()), + }, + }) +} /// TODO(dylan): deprecate once we get rid of json tool #[derive(Serialize, Deserialize)] pub(crate) struct ApplyPatchToolArgs { @@ -723,6 +824,7 @@ pub(crate) fn build_specs( use crate::tools::handlers::GrepFilesHandler; use crate::tools::handlers::ListDirHandler; use crate::tools::handlers::McpHandler; + use crate::tools::handlers::McpResourceHandler; use crate::tools::handlers::PlanHandler; use crate::tools::handlers::ReadFileHandler; use crate::tools::handlers::ShellHandler; @@ -740,6 +842,7 @@ pub(crate) fn build_specs( let apply_patch_handler = Arc::new(ApplyPatchHandler); let view_image_handler = Arc::new(ViewImageHandler); let mcp_handler = Arc::new(McpHandler); + let mcp_resource_handler = Arc::new(McpResourceHandler); if config.experimental_unified_exec_tool { builder.push_spec(create_unified_exec_tool()); @@ -770,6 +873,13 @@ pub(crate) fn build_specs( builder.register_handler("container.exec", shell_handler.clone()); builder.register_handler("local_shell", shell_handler); + builder.push_spec_with_parallel_support(create_list_mcp_resources_tool(), true); + builder.push_spec_with_parallel_support(create_list_mcp_resource_templates_tool(), true); + builder.push_spec_with_parallel_support(create_read_mcp_resource_tool(), true); + builder.register_handler("list_mcp_resources", mcp_resource_handler.clone()); + builder.register_handler("list_mcp_resource_templates", mcp_resource_handler.clone()); + builder.register_handler("read_mcp_resource", mcp_resource_handler); + if config.plan_tool { builder.push_spec(PLAN_TOOL.clone()); builder.register_handler("update_plan", plan_handler); @@ -917,7 +1027,15 @@ mod tests { assert_eq_tool_names( &tools, - &["unified_exec", "update_plan", "web_search", "view_image"], + &[ + "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", + "update_plan", + "web_search", + "view_image", + ], ); } @@ -936,7 +1054,15 @@ mod tests { assert_eq_tool_names( &tools, - &["unified_exec", "update_plan", "web_search", "view_image"], + &[ + "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", + "update_plan", + "web_search", + "view_image", + ], ); } @@ -1043,15 +1169,19 @@ mod tests { &tools, &[ "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", "web_search", "view_image", "test_server/do_something_cool", ], ); + let tool = find_tool(&tools, "test_server/do_something_cool"); assert_eq!( - tools[3].spec, - ToolSpec::Function(ResponsesApiTool { + &tool.spec, + &ToolSpec::Function(ResponsesApiTool { name: "test_server/do_something_cool".to_string(), parameters: JsonSchema::Object { properties: BTreeMap::from([ @@ -1158,6 +1288,9 @@ mod tests { &tools, &[ "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", "view_image", "test_server/cool", "test_server/do", @@ -1206,6 +1339,9 @@ mod tests { &tools, &[ "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", "apply_patch", "web_search", "view_image", @@ -1214,7 +1350,7 @@ mod tests { ); assert_eq!( - tools[4].spec, + tools[7].spec, ToolSpec::Function(ResponsesApiTool { name: "dash/search".to_string(), parameters: JsonSchema::Object { @@ -1271,6 +1407,9 @@ mod tests { &tools, &[ "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", "apply_patch", "web_search", "view_image", @@ -1278,7 +1417,7 @@ mod tests { ], ); assert_eq!( - tools[4].spec, + tools[7].spec, ToolSpec::Function(ResponsesApiTool { name: "dash/paginate".to_string(), parameters: JsonSchema::Object { @@ -1334,6 +1473,9 @@ mod tests { &tools, &[ "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", "apply_patch", "web_search", "view_image", @@ -1341,7 +1483,7 @@ mod tests { ], ); assert_eq!( - tools[4].spec, + tools[7].spec, ToolSpec::Function(ResponsesApiTool { name: "dash/tags".to_string(), parameters: JsonSchema::Object { @@ -1399,6 +1541,9 @@ mod tests { &tools, &[ "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", "apply_patch", "web_search", "view_image", @@ -1406,7 +1551,7 @@ mod tests { ], ); assert_eq!( - tools[4].spec, + tools[7].spec, ToolSpec::Function(ResponsesApiTool { name: "dash/value".to_string(), parameters: JsonSchema::Object { @@ -1501,6 +1646,9 @@ mod tests { &tools, &[ "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", "apply_patch", "web_search", "view_image", @@ -1509,7 +1657,7 @@ mod tests { ); assert_eq!( - tools[4].spec, + tools[7].spec, ToolSpec::Function(ResponsesApiTool { name: "test_server/do_something_cool".to_string(), parameters: JsonSchema::Object { diff --git a/codex-rs/core/tests/suite/model_tools.rs b/codex-rs/core/tests/suite/model_tools.rs index f26cfcf67c..71e64f9e3a 100644 --- a/codex-rs/core/tests/suite/model_tools.rs +++ b/codex-rs/core/tests/suite/model_tools.rs @@ -94,21 +94,37 @@ async fn model_selects_expected_tools() { let codex_tools = collect_tool_identifiers_for_model("codex-mini-latest").await; assert_eq!( codex_tools, - vec!["local_shell".to_string()], + vec![ + "local_shell".to_string(), + "list_mcp_resources".to_string(), + "list_mcp_resource_templates".to_string(), + "read_mcp_resource".to_string() + ], "codex-mini-latest should expose the local shell tool", ); let o3_tools = collect_tool_identifiers_for_model("o3").await; assert_eq!( o3_tools, - vec!["shell".to_string()], + vec![ + "shell".to_string(), + "list_mcp_resources".to_string(), + "list_mcp_resource_templates".to_string(), + "read_mcp_resource".to_string() + ], "o3 should expose the generic shell tool", ); let gpt5_codex_tools = collect_tool_identifiers_for_model("gpt-5-codex").await; assert_eq!( gpt5_codex_tools, - vec!["shell".to_string(), "apply_patch".to_string(),], + vec![ + "shell".to_string(), + "list_mcp_resources".to_string(), + "list_mcp_resource_templates".to_string(), + "read_mcp_resource".to_string(), + "apply_patch".to_string() + ], "gpt-5-codex should expose the apply_patch tool", ); } diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index dc000af449..fcfc960224 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -223,10 +223,28 @@ async fn prompt_tools_are_consistent_across_requests() { // our internal implementation is responsible for keeping tools in sync // with the OpenAI schema, so we just verify the tool presence here let tools_by_model: HashMap<&'static str, Vec<&'static str>> = HashMap::from([ - ("gpt-5", vec!["shell", "update_plan", "view_image"]), + ( + "gpt-5", + vec![ + "shell", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", + "update_plan", + "view_image", + ], + ), ( "gpt-5-codex", - vec!["shell", "update_plan", "apply_patch", "view_image"], + vec![ + "shell", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", + "update_plan", + "apply_patch", + "view_image", + ], ), ]); let expected_tools_names = tools_by_model diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index d18c5338c8..d888194776 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -21,6 +21,8 @@ use crate::num_format::format_with_separators; use crate::parse_command::ParsedCommand; use crate::plan_tool::UpdatePlanArgs; use mcp_types::CallToolResult; +use mcp_types::Resource as McpResource; +use mcp_types::ResourceTemplate as McpResourceTemplate; use mcp_types::Tool as McpTool; use serde::Deserialize; use serde::Serialize; @@ -1249,6 +1251,10 @@ pub struct GetHistoryEntryResponseEvent { pub struct McpListToolsResponseEvent { /// Fully qualified tool name -> tool definition. pub tools: std::collections::HashMap, + /// Known resources grouped by server name. + pub resources: std::collections::HashMap>, + /// Known resource templates grouped by server name. + pub resource_templates: std::collections::HashMap>, /// Authentication status for each configured MCP server. pub auth_statuses: std::collections::HashMap, } diff --git a/codex-rs/rmcp-client/Cargo.toml b/codex-rs/rmcp-client/Cargo.toml index 0016f1142a..c515cdbfa3 100644 --- a/codex-rs/rmcp-client/Cargo.toml +++ b/codex-rs/rmcp-client/Cargo.toml @@ -57,6 +57,7 @@ urlencoding = { workspace = true } webbrowser = { workspace = true } [dev-dependencies] +escargot = { workspace = true } pretty_assertions = { workspace = true } serial_test = { workspace = true } tempfile = { workspace = true } 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 2d380fa54e..44ae50f02f 100644 --- a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs @@ -8,8 +8,17 @@ use rmcp::handler::server::ServerHandler; use rmcp::model::CallToolRequestParam; use rmcp::model::CallToolResult; use rmcp::model::JsonObject; +use rmcp::model::ListResourceTemplatesResult; +use rmcp::model::ListResourcesResult; use rmcp::model::ListToolsResult; use rmcp::model::PaginatedRequestParam; +use rmcp::model::RawResource; +use rmcp::model::RawResourceTemplate; +use rmcp::model::ReadResourceRequestParam; +use rmcp::model::ReadResourceResult; +use rmcp::model::Resource; +use rmcp::model::ResourceContents; +use rmcp::model::ResourceTemplate; use rmcp::model::ServerCapabilities; use rmcp::model::ServerInfo; use rmcp::model::Tool; @@ -20,15 +29,24 @@ use tokio::task; #[derive(Clone)] struct TestToolServer { tools: Arc>, + resources: Arc>, + resource_templates: Arc>, } + +const MEMO_URI: &str = "memo://codex/example-note"; +const MEMO_CONTENT: &str = "This is a sample MCP resource served by the rmcp test server."; pub fn stdio() -> (tokio::io::Stdin, tokio::io::Stdout) { (tokio::io::stdin(), tokio::io::stdout()) } impl TestToolServer { fn new() -> Self { let tools = vec![Self::echo_tool()]; + let resources = vec![Self::memo_resource()]; + let resource_templates = vec![Self::memo_template()]; Self { tools: Arc::new(tools), + resources: Arc::new(resources), + resource_templates: Arc::new(resource_templates), } } @@ -51,6 +69,36 @@ impl TestToolServer { Arc::new(schema), ) } + + fn memo_resource() -> Resource { + let raw = RawResource { + uri: MEMO_URI.to_string(), + name: "example-note".to_string(), + title: Some("Example Note".to_string()), + description: Some("A sample MCP resource exposed for integration tests.".to_string()), + mime_type: Some("text/plain".to_string()), + size: None, + icons: None, + }; + Resource::new(raw, None) + } + + fn memo_template() -> ResourceTemplate { + let raw = RawResourceTemplate { + uri_template: "memo://codex/{slug}".to_string(), + name: "codex-memo".to_string(), + title: Some("Codex Memo".to_string()), + description: Some( + "Template for memo://codex/{slug} resources used in tests.".to_string(), + ), + mime_type: Some("text/plain".to_string()), + }; + ResourceTemplate::new(raw, None) + } + + fn memo_text() -> &'static str { + MEMO_CONTENT + } } #[derive(Deserialize)] @@ -66,6 +114,7 @@ impl ServerHandler for TestToolServer { capabilities: ServerCapabilities::builder() .enable_tools() .enable_tool_list_changed() + .enable_resources() .build(), ..ServerInfo::default() } @@ -85,6 +134,53 @@ impl ServerHandler for TestToolServer { } } + fn list_resources( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> impl std::future::Future> + Send + '_ { + let resources = self.resources.clone(); + async move { + Ok(ListResourcesResult { + resources: (*resources).clone(), + next_cursor: None, + }) + } + } + + async fn list_resource_templates( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> Result { + Ok(ListResourceTemplatesResult { + resource_templates: (*self.resource_templates).clone(), + next_cursor: None, + }) + } + + async fn read_resource( + &self, + ReadResourceRequestParam { uri }: ReadResourceRequestParam, + _context: rmcp::service::RequestContext, + ) -> Result { + if uri == MEMO_URI { + Ok(ReadResourceResult { + contents: vec![ResourceContents::TextResourceContents { + uri, + mime_type: Some("text/plain".to_string()), + text: Self::memo_text().to_string(), + meta: None, + }], + }) + } else { + Err(McpError::resource_not_found( + "resource_not_found", + Some(json!({ "uri": uri })), + )) + } + } + async fn call_tool( &self, request: CallToolRequestParam, diff --git a/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs b/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs index 81a60404ba..30d8fd7fdf 100644 --- a/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs @@ -18,8 +18,17 @@ use rmcp::handler::server::ServerHandler; use rmcp::model::CallToolRequestParam; use rmcp::model::CallToolResult; use rmcp::model::JsonObject; +use rmcp::model::ListResourceTemplatesResult; +use rmcp::model::ListResourcesResult; use rmcp::model::ListToolsResult; use rmcp::model::PaginatedRequestParam; +use rmcp::model::RawResource; +use rmcp::model::RawResourceTemplate; +use rmcp::model::ReadResourceRequestParam; +use rmcp::model::ReadResourceResult; +use rmcp::model::Resource; +use rmcp::model::ResourceContents; +use rmcp::model::ResourceTemplate; use rmcp::model::ServerCapabilities; use rmcp::model::ServerInfo; use rmcp::model::Tool; @@ -33,13 +42,22 @@ use tokio::task; #[derive(Clone)] struct TestToolServer { tools: Arc>, + resources: Arc>, + resource_templates: Arc>, } +const MEMO_URI: &str = "memo://codex/example-note"; +const MEMO_CONTENT: &str = "This is a sample MCP resource served by the rmcp test server."; + impl TestToolServer { fn new() -> Self { let tools = vec![Self::echo_tool()]; + let resources = vec![Self::memo_resource()]; + let resource_templates = vec![Self::memo_template()]; Self { tools: Arc::new(tools), + resources: Arc::new(resources), + resource_templates: Arc::new(resource_templates), } } @@ -62,6 +80,36 @@ impl TestToolServer { Arc::new(schema), ) } + + fn memo_resource() -> Resource { + let raw = RawResource { + uri: MEMO_URI.to_string(), + name: "example-note".to_string(), + title: Some("Example Note".to_string()), + description: Some("A sample MCP resource exposed for integration tests.".to_string()), + mime_type: Some("text/plain".to_string()), + size: None, + icons: None, + }; + Resource::new(raw, None) + } + + fn memo_template() -> ResourceTemplate { + let raw = RawResourceTemplate { + uri_template: "memo://codex/{slug}".to_string(), + name: "codex-memo".to_string(), + title: Some("Codex Memo".to_string()), + description: Some( + "Template for memo://codex/{slug} resources used in tests.".to_string(), + ), + mime_type: Some("text/plain".to_string()), + }; + ResourceTemplate::new(raw, None) + } + + fn memo_text() -> &'static str { + MEMO_CONTENT + } } #[derive(Deserialize)] @@ -77,6 +125,7 @@ impl ServerHandler for TestToolServer { capabilities: ServerCapabilities::builder() .enable_tools() .enable_tool_list_changed() + .enable_resources() .build(), ..ServerInfo::default() } @@ -96,6 +145,53 @@ impl ServerHandler for TestToolServer { } } + fn list_resources( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> impl std::future::Future> + Send + '_ { + let resources = self.resources.clone(); + async move { + Ok(ListResourcesResult { + resources: (*resources).clone(), + next_cursor: None, + }) + } + } + + async fn list_resource_templates( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> Result { + Ok(ListResourceTemplatesResult { + resource_templates: (*self.resource_templates).clone(), + next_cursor: None, + }) + } + + async fn read_resource( + &self, + ReadResourceRequestParam { uri }: ReadResourceRequestParam, + _context: rmcp::service::RequestContext, + ) -> Result { + if uri == MEMO_URI { + Ok(ReadResourceResult { + contents: vec![ResourceContents::TextResourceContents { + uri, + mime_type: Some("text/plain".to_string()), + text: Self::memo_text().to_string(), + meta: None, + }], + }) + } else { + Err(McpError::resource_not_found( + "resource_not_found", + Some(json!({ "uri": uri })), + )) + } + } + async fn call_tool( &self, request: CallToolRequestParam, diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index 245b4eb355..bc1980f1f5 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -13,12 +13,19 @@ use mcp_types::CallToolRequestParams; use mcp_types::CallToolResult; use mcp_types::InitializeRequestParams; use mcp_types::InitializeResult; +use mcp_types::ListResourceTemplatesRequestParams; +use mcp_types::ListResourceTemplatesResult; +use mcp_types::ListResourcesRequestParams; +use mcp_types::ListResourcesResult; use mcp_types::ListToolsRequestParams; use mcp_types::ListToolsResult; +use mcp_types::ReadResourceRequestParams; +use mcp_types::ReadResourceResult; use reqwest::header::HeaderMap; use rmcp::model::CallToolRequestParam; use rmcp::model::InitializeRequestParam; use rmcp::model::PaginatedRequestParam; +use rmcp::model::ReadResourceRequestParam; use rmcp::service::RoleClient; use rmcp::service::RunningService; use rmcp::service::{self}; @@ -264,6 +271,54 @@ impl RmcpClient { Ok(converted) } + pub async fn list_resources( + &self, + params: Option, + timeout: Option, + ) -> Result { + let service = self.service().await?; + let rmcp_params = params + .map(convert_to_rmcp::<_, PaginatedRequestParam>) + .transpose()?; + + let fut = service.list_resources(rmcp_params); + let result = run_with_timeout(fut, timeout, "resources/list").await?; + let converted = convert_to_mcp(result)?; + self.persist_oauth_tokens().await; + Ok(converted) + } + + pub async fn list_resource_templates( + &self, + params: Option, + timeout: Option, + ) -> Result { + let service = self.service().await?; + let rmcp_params = params + .map(convert_to_rmcp::<_, PaginatedRequestParam>) + .transpose()?; + + let fut = service.list_resource_templates(rmcp_params); + let result = run_with_timeout(fut, timeout, "resources/templates/list").await?; + let converted = convert_to_mcp(result)?; + self.persist_oauth_tokens().await; + Ok(converted) + } + + pub async fn read_resource( + &self, + params: ReadResourceRequestParams, + timeout: Option, + ) -> Result { + let service = self.service().await?; + let rmcp_params: ReadResourceRequestParam = convert_to_rmcp(params)?; + let fut = service.read_resource(rmcp_params); + let result = run_with_timeout(fut, timeout, "resources/read").await?; + let converted = convert_to_mcp(result)?; + self.persist_oauth_tokens().await; + Ok(converted) + } + pub async fn call_tool( &self, name: String, @@ -299,6 +354,8 @@ impl RmcpClient { } } + /// This should be called after every tool call so that if a given tool call triggered + /// a refresh of the OAuth tokens, they are persisted. async fn persist_oauth_tokens(&self) { if let Some(runtime) = self.oauth_persistor().await && let Err(error) = runtime.persist_if_needed().await diff --git a/codex-rs/rmcp-client/tests/resources.rs b/codex-rs/rmcp-client/tests/resources.rs new file mode 100644 index 0000000000..2117f9b14c --- /dev/null +++ b/codex-rs/rmcp-client/tests/resources.rs @@ -0,0 +1,124 @@ +use std::ffi::OsString; +use std::path::PathBuf; +use std::time::Duration; + +use codex_rmcp_client::RmcpClient; +use escargot::CargoBuild; +use mcp_types::ClientCapabilities; +use mcp_types::Implementation; +use mcp_types::InitializeRequestParams; +use mcp_types::ListResourceTemplatesResult; +use mcp_types::ReadResourceRequestParams; +use mcp_types::ReadResourceResultContents; +use mcp_types::Resource; +use mcp_types::ResourceTemplate; +use mcp_types::TextResourceContents; +use serde_json::json; + +const RESOURCE_URI: &str = "memo://codex/example-note"; + +fn stdio_server_bin() -> anyhow::Result { + let build = CargoBuild::new() + .package("codex-rmcp-client") + .bin("test_stdio_server") + .run()?; + Ok(build.path().to_path_buf()) +} + +fn init_params() -> InitializeRequestParams { + InitializeRequestParams { + capabilities: ClientCapabilities { + experimental: None, + roots: None, + sampling: None, + elicitation: Some(json!({})), + }, + client_info: Implementation { + name: "codex-test".into(), + version: "0.0.0-test".into(), + title: Some("Codex rmcp resource test".into()), + user_agent: None, + }, + protocol_version: mcp_types::MCP_SCHEMA_VERSION.to_string(), + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn rmcp_client_can_list_and_read_resources() -> anyhow::Result<()> { + let client = RmcpClient::new_stdio_client( + stdio_server_bin()?.into(), + Vec::::new(), + None, + &[], + None, + ) + .await?; + + client + .initialize(init_params(), Some(Duration::from_secs(5))) + .await?; + + let list = client + .list_resources(None, Some(Duration::from_secs(5))) + .await?; + let memo = list + .resources + .iter() + .find(|resource| resource.uri == RESOURCE_URI) + .expect("memo resource present"); + assert_eq!( + memo, + &Resource { + annotations: None, + description: Some("A sample MCP resource exposed for integration tests.".to_string()), + mime_type: Some("text/plain".to_string()), + name: "example-note".to_string(), + size: None, + title: Some("Example Note".to_string()), + uri: RESOURCE_URI.to_string(), + } + ); + let templates = client + .list_resource_templates(None, Some(Duration::from_secs(5))) + .await?; + assert_eq!( + templates, + ListResourceTemplatesResult { + next_cursor: None, + resource_templates: vec![ResourceTemplate { + annotations: None, + description: Some( + "Template for memo://codex/{slug} resources used in tests.".to_string() + ), + mime_type: Some("text/plain".to_string()), + name: "codex-memo".to_string(), + title: Some("Codex Memo".to_string()), + uri_template: "memo://codex/{slug}".to_string(), + }], + } + ); + + let read = client + .read_resource( + ReadResourceRequestParams { + uri: RESOURCE_URI.to_string(), + }, + Some(Duration::from_secs(5)), + ) + .await?; + let ReadResourceResultContents::TextResourceContents(text) = + read.contents.first().expect("resource contents present") + else { + panic!("expected text resource"); + }; + assert_eq!( + text, + &TextResourceContents { + text: "This is a sample MCP resource served by the rmcp test server.".to_string(), + uri: RESOURCE_URI.to_string(), + mime_type: Some("text/plain".to_string()), + } + ); + + Ok(()) +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f592695346..9f308cd00f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2071,6 +2071,8 @@ impl ChatWidget { self.add_to_history(history_cell::new_mcp_tools_output( &self.config, ev.tools, + ev.resources, + ev.resource_templates, &ev.auth_statuses, )); } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index dae48d487c..742c75e159 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -35,7 +35,9 @@ use codex_protocol::plan_tool::UpdatePlanArgs; use image::DynamicImage; use image::ImageReader; use mcp_types::EmbeddedResourceResource; +use mcp_types::Resource; use mcp_types::ResourceLink; +use mcp_types::ResourceTemplate; use ratatui::prelude::*; use ratatui::style::Modifier; use ratatui::style::Style; @@ -976,6 +978,8 @@ pub(crate) fn empty_mcp_output() -> PlainHistoryCell { pub(crate) fn new_mcp_tools_output( config: &Config, tools: HashMap, + resources: HashMap>, + resource_templates: HashMap>, auth_statuses: &HashMap, ) -> PlainHistoryCell { let mut lines: Vec> = vec![ @@ -1073,12 +1077,64 @@ pub(crate) fn new_mcp_tools_output( } if !cfg.enabled { - lines.push(vec![" • Tools: ".into(), "(disabled)".red()].into()); - } else if names.is_empty() { + let disabled = "(disabled)".red(); + lines.push(vec![" • Tools: ".into(), disabled.clone()].into()); + lines.push(vec![" • Resources: ".into(), disabled.clone()].into()); + lines.push(vec![" • Resource templates: ".into(), disabled].into()); + lines.push(Line::from("")); + continue; + } + + if names.is_empty() { lines.push(" • Tools: (none)".into()); } else { lines.push(vec![" • Tools: ".into(), names.join(", ").into()].into()); } + + let server_resources: Vec = + resources.get(server.as_str()).cloned().unwrap_or_default(); + if server_resources.is_empty() { + lines.push(" • Resources: (none)".into()); + } else { + let mut spans: Vec> = vec![" • Resources: ".into()]; + + for (idx, resource) in server_resources.iter().enumerate() { + if idx > 0 { + spans.push(", ".into()); + } + + let label = resource.title.as_ref().unwrap_or(&resource.name); + spans.push(label.clone().into()); + spans.push(" ".into()); + spans.push(format!("({})", resource.uri).dim()); + } + + lines.push(spans.into()); + } + + let server_templates: Vec = resource_templates + .get(server.as_str()) + .cloned() + .unwrap_or_default(); + if server_templates.is_empty() { + lines.push(" • Resource templates: (none)".into()); + } else { + let mut spans: Vec> = vec![" • Resource templates: ".into()]; + + for (idx, template) in server_templates.iter().enumerate() { + if idx > 0 { + spans.push(", ".into()); + } + + let label = template.title.as_ref().unwrap_or(&template.name); + spans.push(label.clone().into()); + spans.push(" ".into()); + spans.push(format!("({})", template.uri_template).dim()); + } + + lines.push(spans.into()); + } + lines.push(Line::from("")); } diff --git a/docs/config.md b/docs/config.md index dafd9dbae3..e460cb19bb 100644 --- a/docs/config.md +++ b/docs/config.md @@ -377,6 +377,10 @@ experimental_use_rmcp_client = true url = "https://mcp.linear.app/mcp" # Optional environment variable containing a bearer token to use for auth bearer_token_env_var = "" +# Optional map of headers with hard-coded values. +http_headers = { "HEADER_NAME" = "HEADER_VALUE" } +# Optional map of headers whose values will be replaced with the environment variable. +env_http_headers = { "HEADER_NAME" = "ENV_VAR" } ``` For oauth login, you must enable `experimental_use_rmcp_client = true` and then run `codex mcp login server_name` From 50f53e7071fa9073d80db560a24c95fa16f6a5c5 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 16 Oct 2025 23:19:54 -0700 Subject: [PATCH 009/287] feat: add path field to ParsedCommand::Read variant (#5275) `ParsedCommand::Read` has a `name` field that attempts to identify the name of the file being read, but the file may not be in the `cwd` in which the command is invoked as demonstrated by this existing unit test: https://github.com/openai/codex/blob/0139f6780c850d87bb37bbb3a11e763d5dc3b50d/codex-rs/core/src/parse_command.rs#L250-L260 As you can see, `tui/Cargo.toml` is the relative path to the file being read. This PR introduces a new `path: PathBuf` field to `ParsedCommand::Read` that attempts to capture this information. When possible, this is an absolute path, though when relative, it should be resolved against the `cwd` that will be used to run the command to derive the absolute path. This should make it easier for clients to provide UI for a "read file" event that corresponds to the command execution. --- codex-rs/core/src/parse_command.rs | 146 ++++++++++++++++++++++--- codex-rs/protocol/src/parse_command.rs | 6 + codex-rs/tui/src/chatwidget/tests.rs | 3 +- codex-rs/tui/src/history_cell.rs | 7 ++ 4 files changed, 145 insertions(+), 17 deletions(-) diff --git a/codex-rs/core/src/parse_command.rs b/codex-rs/core/src/parse_command.rs index 224d013549..e7fa55687c 100644 --- a/codex-rs/core/src/parse_command.rs +++ b/codex-rs/core/src/parse_command.rs @@ -3,6 +3,7 @@ use crate::bash::try_parse_word_only_commands_sequence; use codex_protocol::parse_command::ParsedCommand; use shlex::split as shlex_split; use shlex::try_join as shlex_try_join; +use std::path::PathBuf; fn shlex_join(tokens: &[String]) -> String { shlex_try_join(tokens.iter().map(String::as_str)) @@ -37,6 +38,7 @@ pub fn parse_command(command: &[String]) -> Vec { /// Tests are at the top to encourage using TDD + Codex to fix the implementation. mod tests { use super::*; + use std::path::PathBuf; use std::string::ToString; fn shlex_split_safe(s: &str) -> Vec { @@ -186,6 +188,7 @@ mod tests { vec![ParsedCommand::Read { cmd: inner.to_string(), name: "README.md".to_string(), + path: PathBuf::from("webview/README.md"), }], ); } @@ -197,6 +200,7 @@ mod tests { vec![ParsedCommand::Read { cmd: "cat foo.txt".to_string(), name: "foo.txt".to_string(), + path: PathBuf::from("foo/foo.txt"), }], ); } @@ -219,6 +223,7 @@ mod tests { vec![ParsedCommand::Read { cmd: "cat foo.txt".to_string(), name: "foo.txt".to_string(), + path: PathBuf::from("foo/foo.txt"), }], ); } @@ -243,6 +248,7 @@ mod tests { vec![ParsedCommand::Read { cmd: inner.to_string(), name: "Cargo.toml".to_string(), + path: PathBuf::from("Cargo.toml"), }], ); } @@ -255,6 +261,7 @@ mod tests { vec![ParsedCommand::Read { cmd: inner.to_string(), name: "Cargo.toml".to_string(), + path: PathBuf::from("tui/Cargo.toml"), }], ); } @@ -267,6 +274,7 @@ mod tests { vec![ParsedCommand::Read { cmd: inner.to_string(), name: "README.md".to_string(), + path: PathBuf::from("README.md"), }], ); } @@ -280,6 +288,7 @@ mod tests { vec![ParsedCommand::Read { cmd: inner.to_string(), name: "README.md".to_string(), + path: PathBuf::from("README.md"), }] ); } @@ -449,6 +458,7 @@ mod tests { vec![ParsedCommand::Read { cmd: inner.to_string(), name: "parse_command.rs".to_string(), + path: PathBuf::from("core/src/parse_command.rs"), }], ); } @@ -461,6 +471,7 @@ mod tests { vec![ParsedCommand::Read { cmd: inner.to_string(), name: "history_cell.rs".to_string(), + path: PathBuf::from("tui/src/history_cell.rs"), }], ); } @@ -474,6 +485,7 @@ mod tests { vec![ParsedCommand::Read { cmd: "cat -- ansi-escape/Cargo.toml".to_string(), name: "Cargo.toml".to_string(), + path: PathBuf::from("ansi-escape/Cargo.toml"), }], ); } @@ -503,6 +515,7 @@ mod tests { vec![ParsedCommand::Read { cmd: "sed -n '260,640p' exec/src/event_processor_with_human_output.rs".to_string(), name: "event_processor_with_human_output.rs".to_string(), + path: PathBuf::from("exec/src/event_processor_with_human_output.rs"), }], ); } @@ -662,6 +675,7 @@ mod tests { vec![ParsedCommand::Read { cmd: r#"cat "pkg\\src\\main.rs""#.to_string(), name: "main.rs".to_string(), + path: PathBuf::from(r#"pkg\src\main.rs"#), }], ); } @@ -673,6 +687,7 @@ mod tests { vec![ParsedCommand::Read { cmd: "head -n50 Cargo.toml".to_string(), name: "Cargo.toml".to_string(), + path: PathBuf::from("Cargo.toml"), }], ); } @@ -703,6 +718,7 @@ mod tests { vec![ParsedCommand::Read { cmd: "tail -n+10 README.md".to_string(), name: "README.md".to_string(), + path: PathBuf::from("README.md"), }], ); } @@ -739,6 +755,7 @@ mod tests { vec![ParsedCommand::Read { cmd: "cat -- ./-strange-file-name".to_string(), name: "-strange-file-name".to_string(), + path: PathBuf::from("./-strange-file-name"), }], ); @@ -748,6 +765,7 @@ mod tests { vec![ParsedCommand::Read { cmd: "sed -n '12,20p' Cargo.toml".to_string(), name: "Cargo.toml".to_string(), + path: PathBuf::from("Cargo.toml"), }], ); } @@ -840,11 +858,39 @@ pub fn parse_command_impl(command: &[String]) -> Vec { // Preserve left-to-right execution order for all commands, including bash -c/-lc // so summaries reflect the order they will run. - // Map each pipeline segment to its parsed summary. - let mut commands: Vec = parts - .iter() - .map(|tokens| summarize_main_tokens(tokens)) - .collect(); + // Map each pipeline segment to its parsed summary, tracking `cd` to compute paths. + let mut commands: Vec = Vec::new(); + let mut cwd: Option = None; + for tokens in &parts { + if let Some((head, tail)) = tokens.split_first() + && head == "cd" + { + if let Some(dir) = tail.first() { + cwd = Some(match &cwd { + Some(base) => join_paths(base, dir), + None => dir.clone(), + }); + } + continue; + } + let parsed = summarize_main_tokens(tokens); + let parsed = match parsed { + ParsedCommand::Read { cmd, name, path } => { + if let Some(base) = &cwd { + let full = join_paths(base, &path.to_string_lossy()); + ParsedCommand::Read { + cmd, + name, + path: PathBuf::from(full), + } + } else { + ParsedCommand::Read { cmd, name, path } + } + } + other => other, + }; + commands.push(parsed); + } while let Some(next) = simplify_once(&commands) { commands = next; @@ -1129,10 +1175,39 @@ fn parse_bash_lc_commands(original: &[String]) -> Option> { cmd: script.clone(), }]); } - let mut commands: Vec = filtered_commands - .into_iter() - .map(|tokens| summarize_main_tokens(&tokens)) - .collect(); + // Build parsed commands, tracking `cd` segments to compute effective file paths. + let mut commands: Vec = Vec::new(); + let mut cwd: Option = None; + for tokens in filtered_commands.into_iter() { + if let Some((head, tail)) = tokens.split_first() + && head == "cd" + { + if let Some(dir) = tail.first() { + cwd = Some(match &cwd { + Some(base) => join_paths(base, dir), + None => dir.clone(), + }); + } + continue; + } + let parsed = summarize_main_tokens(&tokens); + let parsed = match parsed { + ParsedCommand::Read { cmd, name, path } => { + if let Some(base) = &cwd { + let full = join_paths(base, &path.to_string_lossy()); + ParsedCommand::Read { + cmd, + name, + path: PathBuf::from(full), + } + } else { + ParsedCommand::Read { cmd, name, path } + } + } + other => other, + }; + commands.push(parsed); + } if commands.len() > 1 { commands.retain(|pc| !matches!(pc, ParsedCommand::Unknown { cmd } if cmd == "true")); // Apply the same simplifications used for non-bash parsing, e.g., drop leading `cd`. @@ -1152,7 +1227,7 @@ fn parse_bash_lc_commands(original: &[String]) -> Option> { commands = commands .into_iter() .map(|pc| match pc { - ParsedCommand::Read { name, cmd, .. } => { + ParsedCommand::Read { name, cmd, path } => { if had_connectors { let has_pipe = script_tokens.iter().any(|t| t == "|"); let has_sed_n = script_tokens.windows(2).any(|w| { @@ -1163,14 +1238,16 @@ fn parse_bash_lc_commands(original: &[String]) -> Option> { ParsedCommand::Read { cmd: script.clone(), name, + path, } } else { - ParsedCommand::Read { cmd, name } + ParsedCommand::Read { cmd, name, path } } } else { ParsedCommand::Read { cmd: shlex_join(&script_tokens), name, + path, } } } @@ -1335,10 +1412,12 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand { tail }; if effective_tail.len() == 1 { - let name = short_display_path(&effective_tail[0]); + let path = effective_tail[0].clone(); + let name = short_display_path(&path); ParsedCommand::Read { cmd: shlex_join(main_cmd), name, + path: PathBuf::from(path), } } else { ParsedCommand::Unknown { @@ -1373,10 +1452,12 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand { i += 1; } if let Some(p) = candidates.into_iter().find(|p| !p.starts_with('-')) { - let name = short_display_path(p); + let path = p.clone(); + let name = short_display_path(&path); return ParsedCommand::Read { cmd: shlex_join(main_cmd), name, + path: PathBuf::from(path), }; } } @@ -1415,10 +1496,12 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand { i += 1; } if let Some(p) = candidates.into_iter().find(|p| !p.starts_with('-')) { - let name = short_display_path(p); + let path = p.clone(); + let name = short_display_path(&path); return ParsedCommand::Read { cmd: shlex_join(main_cmd), name, + path: PathBuf::from(path), }; } } @@ -1430,10 +1513,12 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand { // Avoid treating option values as paths (e.g., nl -s " "). let candidates = skip_flag_values(tail, &["-s", "-w", "-v", "-i", "-b"]); if let Some(p) = candidates.into_iter().find(|p| !p.starts_with('-')) { - let name = short_display_path(p); + let path = p.clone(); + let name = short_display_path(&path); ParsedCommand::Read { cmd: shlex_join(main_cmd), name, + path: PathBuf::from(path), } } else { ParsedCommand::Unknown { @@ -1448,10 +1533,12 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand { && is_valid_sed_n_arg(tail.get(1).map(String::as_str)) => { if let Some(path) = tail.get(2) { - let name = short_display_path(path); + let path = path.clone(); + let name = short_display_path(&path); ParsedCommand::Read { cmd: shlex_join(main_cmd), name, + path: PathBuf::from(path), } } else { ParsedCommand::Unknown { @@ -1465,3 +1552,30 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand { }, } } + +fn is_abs_like(path: &str) -> bool { + if std::path::Path::new(path).is_absolute() { + return true; + } + let mut chars = path.chars(); + match (chars.next(), chars.next(), chars.next()) { + // Windows drive path like C:\ + (Some(d), Some(':'), Some('\\')) if d.is_ascii_alphabetic() => return true, + // UNC path like \\server\share + (Some('\\'), Some('\\'), _) => return true, + _ => {} + } + false +} + +fn join_paths(base: &str, rel: &str) -> String { + if is_abs_like(rel) { + return rel.to_string(); + } + if base.is_empty() { + return rel.to_string(); + } + let mut buf = PathBuf::from(base); + buf.push(rel); + buf.to_string_lossy().to_string() +} diff --git a/codex-rs/protocol/src/parse_command.rs b/codex-rs/protocol/src/parse_command.rs index d1fbe83508..b06e290436 100644 --- a/codex-rs/protocol/src/parse_command.rs +++ b/codex-rs/protocol/src/parse_command.rs @@ -1,5 +1,6 @@ use serde::Deserialize; use serde::Serialize; +use std::path::PathBuf; use ts_rs::TS; #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, TS)] @@ -8,6 +9,11 @@ pub enum ParsedCommand { Read { cmd: String, name: String, + /// (Best effort) Path to the file being read by the command. When + /// possible, this is an absolute path, though when relative, it should + /// be resolved against the `cwd`` that will be used to run the command + /// to derive the absolute path. + path: PathBuf, }, ListFiles { cmd: String, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 2055e20856..dd6e59af42 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1631,7 +1631,7 @@ fn status_widget_and_approval_modal_snapshot() { let ev = ExecApprovalRequestEvent { call_id: "call-approve-exec".into(), command: vec!["echo".into(), "hello world".into()], - cwd: std::path::PathBuf::from("/tmp"), + cwd: PathBuf::from("/tmp"), reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), @@ -2310,6 +2310,7 @@ fn chatwidget_exec_and_status_layout_vt100_snapshot() { ParsedCommand::Read { name: "diff_render.rs".into(), cmd: "cat diff_render.rs".into(), + path: "diff_render.rs".into(), }, ], }), diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 742c75e159..85acaf9b6a 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1682,10 +1682,12 @@ mod tests { ParsedCommand::Read { name: "shimmer.rs".into(), cmd: "cat shimmer.rs".into(), + path: "shimmer.rs".into(), }, ParsedCommand::Read { name: "status_indicator_widget.rs".into(), cmd: "cat status_indicator_widget.rs".into(), + path: "status_indicator_widget.rs".into(), }, ], output: None, @@ -1742,6 +1744,7 @@ mod tests { vec![ParsedCommand::Read { name: "shimmer.rs".into(), cmd: "cat shimmer.rs".into(), + path: "shimmer.rs".into(), }], ) .unwrap(); @@ -1763,6 +1766,7 @@ mod tests { vec![ParsedCommand::Read { name: "status_indicator_widget.rs".into(), cmd: "cat status_indicator_widget.rs".into(), + path: "status_indicator_widget.rs".into(), }], ) .unwrap(); @@ -1791,14 +1795,17 @@ mod tests { ParsedCommand::Read { name: "auth.rs".into(), cmd: "cat auth.rs".into(), + path: "auth.rs".into(), }, ParsedCommand::Read { name: "auth.rs".into(), cmd: "cat auth.rs".into(), + path: "auth.rs".into(), }, ParsedCommand::Read { name: "shimmer.rs".into(), cmd: "cat shimmer.rs".into(), + path: "shimmer.rs".into(), }, ], output: None, From 6915ba2100108db4d7e10aa67cce32609cf56be9 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 17 Oct 2025 10:06:55 +0100 Subject: [PATCH 010/287] feat: better UX during refusal (#5260) Screenshot 2025-10-16 at 18 28 05 --- codex-rs/core/src/codex.rs | 25 ++++++++++++++++++++----- codex-rs/core/src/executor/mod.rs | 4 ++++ codex-rs/core/src/executor/runner.rs | 17 ++++++++++++----- codex-rs/core/src/executor/sandbox.rs | 2 +- codex-rs/core/src/function_tool.rs | 2 ++ codex-rs/core/src/tools/mod.rs | 1 + 6 files changed, 40 insertions(+), 11 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 8acf8b20a8..93ba73ebca 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -916,6 +916,7 @@ impl Session { duration, exit_code, timed_out: _, + .. } = output; // Send full stdout/stderr to clients; do not truncate. let stdout = stdout.text.clone(); @@ -980,15 +981,28 @@ impl Session { let sub_id = context.sub_id.clone(); let call_id = context.call_id.clone(); - self.on_exec_command_begin(turn_diff_tracker.clone(), context.clone()) - .await; - + let begin_turn_diff = turn_diff_tracker.clone(); + let begin_context = context.clone(); + let session = self; let result = self .services .executor - .run(request, self, approval_policy, &context) + .run(request, self, approval_policy, &context, move || { + let turn_diff = begin_turn_diff.clone(); + let ctx = begin_context.clone(); + async move { + session.on_exec_command_begin(turn_diff, ctx).await; + } + }) .await; + if matches!( + &result, + Err(ExecError::Function(FunctionCallError::Denied(_))) + ) { + return result; + } + let normalized = normalize_exec_result(&result); let borrowed = normalized.event_output(); @@ -2262,7 +2276,8 @@ async fn try_run_turn( response: Some(response), }); } - Err(FunctionCallError::RespondToModel(message)) => { + Err(FunctionCallError::RespondToModel(message)) + | Err(FunctionCallError::Denied(message)) => { let response = ResponseInputItem::FunctionCallOutput { call_id: String::new(), output: FunctionCallOutputPayload { diff --git a/codex-rs/core/src/executor/mod.rs b/codex-rs/core/src/executor/mod.rs index 97d7b29294..3f1e02dff2 100644 --- a/codex-rs/core/src/executor/mod.rs +++ b/codex-rs/core/src/executor/mod.rs @@ -60,5 +60,9 @@ pub mod errors { pub(crate) fn rejection(msg: impl Into) -> Self { FunctionCallError::RespondToModel(msg.into()).into() } + + pub(crate) fn denied(msg: impl Into) -> Self { + FunctionCallError::Denied(msg.into()).into() + } } } diff --git a/codex-rs/core/src/executor/runner.rs b/codex-rs/core/src/executor/runner.rs index e13016e37f..072debb5d3 100644 --- a/codex-rs/core/src/executor/runner.rs +++ b/codex-rs/core/src/executor/runner.rs @@ -1,3 +1,4 @@ +use std::future::Future; use std::path::PathBuf; use std::sync::Arc; use std::sync::RwLock; @@ -74,13 +75,18 @@ impl Executor { /// Runs a prepared execution request end-to-end: prepares parameters, decides on /// sandbox placement (prompting the user when necessary), launches the command, /// and lets the backend post-process the final output. - pub(crate) async fn run( + pub(crate) async fn run( &self, mut request: ExecutionRequest, session: &Session, approval_policy: AskForApproval, context: &ExecCommandContext, - ) -> Result { + on_exec_begin: F, + ) -> Result + where + F: FnOnce() -> Fut, + Fut: Future, + { if matches!(request.mode, ExecutionMode::Shell) { request.params = maybe_translate_shell_command(request.params, session, request.use_shell_profile); @@ -119,7 +125,7 @@ impl Executor { if sandbox_decision.record_session_approval { self.approval_cache.insert(request.approval_command.clone()); } - + on_exec_begin().await; // Step 4: Launch the command within the chosen sandbox. let first_attempt = self .spawn( @@ -210,7 +216,7 @@ impl Executor { Ok(retry_output) } ReviewDecision::Denied | ReviewDecision::Abort => { - Err(ExecError::rejection("exec command rejected by user")) + Err(ExecError::denied("exec command rejected by user")) } } } @@ -301,7 +307,8 @@ pub(crate) fn normalize_exec_result( } Err(err) => { let message = match err { - ExecError::Function(FunctionCallError::RespondToModel(msg)) => msg.clone(), + ExecError::Function(FunctionCallError::RespondToModel(msg)) + | ExecError::Function(FunctionCallError::Denied(msg)) => msg.clone(), ExecError::Codex(e) => get_error_message_ui(e), err => err.to_string(), }; diff --git a/codex-rs/core/src/executor/sandbox.rs b/codex-rs/core/src/executor/sandbox.rs index 5c01ff69b4..f0a421a29a 100644 --- a/codex-rs/core/src/executor/sandbox.rs +++ b/codex-rs/core/src/executor/sandbox.rs @@ -149,7 +149,7 @@ async fn select_shell_sandbox( ReviewDecision::Approved => Ok(SandboxDecision::user_override(false)), ReviewDecision::ApprovedForSession => Ok(SandboxDecision::user_override(true)), ReviewDecision::Denied | ReviewDecision::Abort => { - Err(ExecError::rejection("exec command rejected by user")) + Err(ExecError::denied("exec command rejected by user")) } } } diff --git a/codex-rs/core/src/function_tool.rs b/codex-rs/core/src/function_tool.rs index 240e04361c..a25fff6113 100644 --- a/codex-rs/core/src/function_tool.rs +++ b/codex-rs/core/src/function_tool.rs @@ -4,6 +4,8 @@ use thiserror::Error; pub enum FunctionCallError { #[error("{0}")] RespondToModel(String), + #[error("{0}")] + Denied(String), #[error("LocalShellCall without call_id or id")] MissingLocalShellCallId, #[error("Fatal error: {0}")] diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index 691c6dc0c2..607697e083 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -238,6 +238,7 @@ fn truncate_function_error(err: FunctionCallError) -> FunctionCallError { FunctionCallError::RespondToModel(msg) => { FunctionCallError::RespondToModel(format_exec_output(&msg)) } + FunctionCallError::Denied(msg) => FunctionCallError::Denied(format_exec_output(&msg)), FunctionCallError::Fatal(msg) => FunctionCallError::Fatal(format_exec_output(&msg)), other => other, } From c03e31ecf5d4d9fed16ea163b3bcce83d030f74d Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 17 Oct 2025 11:52:57 -0700 Subject: [PATCH 011/287] Support graceful agent interruption (#5287) --- codex-rs/Cargo.lock | 34 +++++++++ codex-rs/Cargo.toml | 3 + codex-rs/async-utils/Cargo.toml | 15 ++++ codex-rs/async-utils/src/lib.rs | 86 +++++++++++++++++++++ codex-rs/core/Cargo.toml | 2 + codex-rs/core/src/codex.rs | 117 +++++++++++++++++++++-------- codex-rs/core/src/error.rs | 10 +++ codex-rs/core/src/lib.rs | 3 +- codex-rs/core/src/state/turn.rs | 15 ++-- codex-rs/core/src/tasks/compact.rs | 2 + codex-rs/core/src/tasks/mod.rs | 53 ++++++++++--- codex-rs/core/src/tasks/regular.rs | 12 ++- codex-rs/core/src/tasks/review.rs | 12 ++- 13 files changed, 309 insertions(+), 55 deletions(-) create mode 100644 codex-rs/async-utils/Cargo.toml create mode 100644 codex-rs/async-utils/src/lib.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 45a41b819a..9d18361cf8 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -899,6 +899,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "codex-async-utils" +version = "0.0.0" +dependencies = [ + "async-trait", + "pretty_assertions", + "tokio", + "tokio-util", +] + [[package]] name = "codex-backend-client" version = "0.0.0" @@ -1037,6 +1047,7 @@ dependencies = [ "chrono", "codex-app-server-protocol", "codex-apply-patch", + "codex-async-utils", "codex-file-search", "codex-mcp-client", "codex-otel", @@ -1073,6 +1084,7 @@ dependencies = [ "similar", "strum_macros 0.27.2", "tempfile", + "test-log", "thiserror 2.0.16", "time", "tokio", @@ -6022,6 +6034,28 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "test-log" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e33b98a582ea0be1168eba097538ee8dd4bbe0f2b01b22ac92ea30054e5be7b" +dependencies = [ + "env_logger", + "test-log-macros", + "tracing-subscriber", +] + +[[package]] +name = "test-log-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451b374529930d7601b1eef8d32bc79ae870b6079b069401709c2a8bf9e75f36" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "textwrap" version = "0.11.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 4ca4b7e6e1..64eebb62a1 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -2,6 +2,7 @@ members = [ "backend-client", "ansi-escape", + "async-utils", "app-server", "app-server-protocol", "apply-patch", @@ -56,6 +57,7 @@ codex-arg0 = { path = "arg0" } codex-chatgpt = { path = "chatgpt" } codex-common = { path = "common" } codex-core = { path = "core" } +codex-async-utils = { path = "async-utils" } codex-exec = { path = "exec" } codex-feedback = { path = "feedback" } codex-file-search = { path = "file-search" } @@ -164,6 +166,7 @@ strum_macros = "0.27.2" supports-color = "3.0.2" sys-locale = "0.3.2" tempfile = "3.23.0" +test-log = "0.2.18" textwrap = "0.16.2" thiserror = "2.0.16" time = "0.3" diff --git a/codex-rs/async-utils/Cargo.toml b/codex-rs/async-utils/Cargo.toml new file mode 100644 index 0000000000..5203db0f5d --- /dev/null +++ b/codex-rs/async-utils/Cargo.toml @@ -0,0 +1,15 @@ +[package] +edition.workspace = true +name = "codex-async-utils" +version.workspace = true + +[lints] +workspace = true + +[dependencies] +async-trait.workspace = true +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] } +tokio-util.workspace = true + +[dev-dependencies] +pretty_assertions.workspace = true diff --git a/codex-rs/async-utils/src/lib.rs b/codex-rs/async-utils/src/lib.rs new file mode 100644 index 0000000000..bd880ae1fb --- /dev/null +++ b/codex-rs/async-utils/src/lib.rs @@ -0,0 +1,86 @@ +use async_trait::async_trait; +use std::future::Future; +use tokio_util::sync::CancellationToken; + +#[derive(Debug, PartialEq, Eq)] +pub enum CancelErr { + Cancelled, +} + +#[async_trait] +pub trait OrCancelExt: Sized { + type Output; + + async fn or_cancel(self, token: &CancellationToken) -> Result; +} + +#[async_trait] +impl OrCancelExt for F +where + F: Future + Send, + F::Output: Send, +{ + type Output = F::Output; + + async fn or_cancel(self, token: &CancellationToken) -> Result { + tokio::select! { + _ = token.cancelled() => Err(CancelErr::Cancelled), + res = self => Ok(res), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use std::time::Duration; + use tokio::task; + use tokio::time::sleep; + + #[tokio::test] + async fn returns_ok_when_future_completes_first() { + let token = CancellationToken::new(); + let value = async { 42 }; + + let result = value.or_cancel(&token).await; + + assert_eq!(Ok(42), result); + } + + #[tokio::test] + async fn returns_err_when_token_cancelled_first() { + let token = CancellationToken::new(); + let token_clone = token.clone(); + + let cancel_handle = task::spawn(async move { + sleep(Duration::from_millis(10)).await; + token_clone.cancel(); + }); + + let result = async { + sleep(Duration::from_millis(100)).await; + 7 + } + .or_cancel(&token) + .await; + + cancel_handle.await.expect("cancel task panicked"); + assert_eq!(Err(CancelErr::Cancelled), result); + } + + #[tokio::test] + async fn returns_err_when_token_already_cancelled() { + let token = CancellationToken::new(); + token.cancel(); + + let result = async { + sleep(Duration::from_millis(50)).await; + 5 + } + .or_cancel(&token) + .await; + + assert_eq!(Err(CancelErr::Cancelled), result); + } +} diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 4259e64fc4..d13205699e 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -26,6 +26,7 @@ codex-mcp-client = { workspace = true } codex-otel = { workspace = true, features = ["otel"] } codex-protocol = { workspace = true } codex-rmcp-client = { workspace = true } +codex-async-utils = { workspace = true } codex-utils-string = { workspace = true } dirs = { workspace = true } dunce = { workspace = true } @@ -47,6 +48,7 @@ shlex = { workspace = true } similar = { workspace = true } strum_macros = { workspace = true } tempfile = { workspace = true } +test-log = { workspace = true } thiserror = { workspace = true } time = { workspace = true, features = [ "formatting", diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 93ba73ebca..23751d1b6d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -38,6 +38,7 @@ use serde_json; use serde_json::Value; use tokio::sync::Mutex; use tokio::sync::oneshot; +use tokio_util::sync::CancellationToken; use tracing::debug; use tracing::error; use tracing::info; @@ -119,6 +120,7 @@ use crate::unified_exec::UnifiedExecSessionManager; use crate::user_instructions::UserInstructions; use crate::user_notification::UserNotification; use crate::util::backoff; +use codex_async_utils::OrCancelExt; use codex_otel::otel_event_manager::OtelEventManager; use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; @@ -1170,19 +1172,6 @@ impl Session { self.abort_all_tasks(TurnAbortReason::Interrupted).await; } - fn interrupt_task_sync(&self) { - if let Ok(mut active) = self.active_turn.try_lock() - && let Some(at) = active.as_mut() - { - at.try_clear_pending_sync(); - let tasks = at.drain_tasks(); - *active = None; - for (_sub_id, task) in tasks { - task.handle.abort(); - } - } - } - pub(crate) fn notifier(&self) -> &UserNotifier { &self.services.notifier } @@ -1196,12 +1185,6 @@ impl Session { } } -impl Drop for Session { - fn drop(&mut self) { - self.interrupt_task_sync(); - } -} - async fn submission_loop( sess: Arc, turn_context: TurnContext, @@ -1711,6 +1694,7 @@ pub(crate) async fn run_task( sub_id: String, input: Vec, task_kind: TaskKind, + cancellation_token: CancellationToken, ) -> Option { if input.is_empty() { return None; @@ -1795,6 +1779,7 @@ pub(crate) async fn run_task( sub_id.clone(), turn_input, task_kind, + cancellation_token.child_token(), ) .await { @@ -1956,6 +1941,10 @@ pub(crate) async fn run_task( } continue; } + Err(CodexErr::TurnAborted) => { + // Aborted turn is reported via a different event. + break; + } Err(e) => { info!("Turn error: {e:#}"); let event = Event { @@ -2022,6 +2011,7 @@ async fn run_turn( sub_id: String, input: Vec, task_kind: TaskKind, + cancellation_token: CancellationToken, ) -> CodexResult { let mcp_tools = sess.services.mcp_connection_manager.list_all_tools(); let router = Arc::new(ToolRouter::from_config( @@ -2052,10 +2042,12 @@ async fn run_turn( &sub_id, &prompt, task_kind, + cancellation_token.child_token(), ) .await { Ok(output) => return Ok(output), + Err(CodexErr::TurnAborted) => return Err(CodexErr::TurnAborted), Err(CodexErr::Interrupted) => return Err(CodexErr::Interrupted), Err(CodexErr::EnvVar(var)) => return Err(CodexErr::EnvVar(var)), Err(e @ CodexErr::Fatal(_)) => return Err(e), @@ -2118,6 +2110,7 @@ struct TurnRunResult { total_token_usage: Option, } +#[allow(clippy::too_many_arguments)] async fn try_run_turn( router: Arc, sess: Arc, @@ -2126,6 +2119,7 @@ async fn try_run_turn( sub_id: &str, prompt: &Prompt, task_kind: TaskKind, + cancellation_token: CancellationToken, ) -> CodexResult { // call_ids that are part of this response. let completed_call_ids = prompt @@ -2195,7 +2189,8 @@ async fn try_run_turn( .client .clone() .stream_with_task_kind(prompt.as_ref(), task_kind) - .await?; + .or_cancel(&cancellation_token) + .await??; let tool_runtime = ToolCallRuntime::new( Arc::clone(&router), @@ -2211,7 +2206,8 @@ async fn try_run_turn( // Poll the next item from the model stream. We must inspect *both* Ok and Err // cases so that transient stream failures (e.g., dropped SSE connection before // `response.completed`) bubble up and trigger the caller's retry logic. - let event = stream.next().await; + let event = stream.next().or_cancel(&cancellation_token).await?; + let event = match event { Some(res) => res?, None => { @@ -2316,7 +2312,10 @@ async fn try_run_turn( sess.update_token_usage_info(sub_id, turn_context.as_ref(), token_usage.as_ref()) .await; - let processed_items: Vec = output.try_collect().await?; + let processed_items = output + .try_collect() + .or_cancel(&cancellation_token) + .await??; let unified_diff = { let mut tracker = turn_diff_tracker.lock().await; @@ -2554,6 +2553,8 @@ mod tests { use codex_app_server_protocol::AuthMode; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; + use std::time::Duration; + use tokio::time::sleep; use mcp_types::ContentBlock; use mcp_types::TextContent; @@ -2563,8 +2564,6 @@ mod tests { use std::path::PathBuf; use std::sync::Arc; use std::time::Duration as StdDuration; - use tokio::time::Duration; - use tokio::time::sleep; #[test] fn reconstruct_history_matches_live_compactions() { @@ -2944,12 +2943,15 @@ mod tests { } #[derive(Clone, Copy)] - struct NeverEndingTask(TaskKind); + struct NeverEndingTask { + kind: TaskKind, + listen_to_cancellation_token: bool, + } #[async_trait::async_trait] impl SessionTask for NeverEndingTask { fn kind(&self) -> TaskKind { - self.0 + self.kind } async fn run( @@ -2958,20 +2960,26 @@ mod tests { _ctx: Arc, _sub_id: String, _input: Vec, + cancellation_token: CancellationToken, ) -> Option { + if self.listen_to_cancellation_token { + cancellation_token.cancelled().await; + return None; + } loop { sleep(Duration::from_secs(60)).await; } } async fn abort(&self, session: Arc, sub_id: &str) { - if let TaskKind::Review = self.0 { + if let TaskKind::Review = self.kind { exit_review_mode(session.clone_session(), sub_id.to_string(), None).await; } } } - #[tokio::test] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + #[test_log::test] async fn abort_regular_task_emits_turn_aborted_only() { let (sess, tc, rx) = make_session_and_context_with_rx(); let sub_id = "sub-regular".to_string(); @@ -2982,7 +2990,41 @@ mod tests { Arc::clone(&tc), sub_id.clone(), input, - NeverEndingTask(TaskKind::Regular), + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: false, + }, + ) + .await; + + sess.abort_all_tasks(TurnAbortReason::Interrupted).await; + + let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("timeout waiting for event") + .expect("event"); + match evt.msg { + EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason), + other => panic!("unexpected event: {other:?}"), + } + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn abort_gracefuly_emits_turn_aborted_only() { + let (sess, tc, rx) = make_session_and_context_with_rx(); + let sub_id = "sub-regular".to_string(); + let input = vec![InputItem::Text { + text: "hello".to_string(), + }]; + sess.spawn_task( + Arc::clone(&tc), + sub_id.clone(), + input, + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: true, + }, ) .await; @@ -2996,7 +3038,7 @@ mod tests { assert!(rx.try_recv().is_err()); } - #[tokio::test] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn abort_review_task_emits_exited_then_aborted_and_records_history() { let (sess, tc, rx) = make_session_and_context_with_rx(); let sub_id = "sub-review".to_string(); @@ -3007,18 +3049,27 @@ mod tests { Arc::clone(&tc), sub_id.clone(), input, - NeverEndingTask(TaskKind::Review), + NeverEndingTask { + kind: TaskKind::Review, + listen_to_cancellation_token: false, + }, ) .await; sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - let first = rx.recv().await.expect("first event"); + let first = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("timeout waiting for first event") + .expect("first event"); match first.msg { EventMsg::ExitedReviewMode(ev) => assert!(ev.review_output.is_none()), other => panic!("unexpected first event: {other:?}"), } - let second = rx.recv().await.expect("second event"); + let second = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("timeout waiting for second event") + .expect("second event"); match second.msg { EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason), other => panic!("unexpected second event: {other:?}"), diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index da2a868d70..951ba3954f 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -2,6 +2,7 @@ use crate::exec::ExecToolCallOutput; use crate::token_data::KnownPlan; use crate::token_data::PlanType; use crate::truncate::truncate_middle; +use codex_async_utils::CancelErr; use codex_protocol::ConversationId; use codex_protocol::protocol::RateLimitSnapshot; use reqwest::StatusCode; @@ -50,6 +51,9 @@ pub enum SandboxErr { #[derive(Error, Debug)] pub enum CodexErr { + #[error("turn aborted")] + TurnAborted, + /// Returned by ResponsesClient when the SSE stream disconnects or errors out **after** the HTTP /// handshake has succeeded but **before** it finished emitting `response.completed`. /// @@ -150,6 +154,12 @@ pub enum CodexErr { EnvVar(EnvVarError), } +impl From for CodexErr { + fn from(_: CancelErr) -> Self { + CodexErr::TurnAborted + } +} + #[derive(Debug)] pub struct ConnectionFailedError { pub source: reqwest::Error, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 22e1d4cdf0..8ddf8bff9c 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -13,7 +13,6 @@ mod client; mod client_common; pub mod codex; mod codex_conversation; -pub mod token_data; pub use codex_conversation::CodexConversation; mod command_safety; pub mod config; @@ -39,6 +38,7 @@ mod mcp_tool_call; mod message_history; mod model_provider_info; pub mod parse_command; +pub mod token_data; mod truncate; mod unified_exec; mod user_instructions; @@ -107,5 +107,4 @@ pub use codex_protocol::models::LocalShellExecAction; pub use codex_protocol::models::LocalShellStatus; pub use codex_protocol::models::ReasoningItemContent; pub use codex_protocol::models::ResponseItem; - pub mod otel_init; diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index 89af13a1a5..3ed63c5b8f 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -4,7 +4,9 @@ use indexmap::IndexMap; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex; -use tokio::task::AbortHandle; +use tokio::sync::Notify; +use tokio_util::sync::CancellationToken; +use tokio_util::task::AbortOnDropHandle; use codex_protocol::models::ResponseInputItem; use tokio::sync::oneshot; @@ -46,9 +48,11 @@ impl TaskKind { #[derive(Clone)] pub(crate) struct RunningTask { - pub(crate) handle: AbortHandle, + pub(crate) done: Arc, pub(crate) kind: TaskKind, pub(crate) task: Arc, + pub(crate) cancellation_token: CancellationToken, + pub(crate) handle: Arc>, } impl ActiveTurn { @@ -115,13 +119,6 @@ impl ActiveTurn { let mut ts = self.turn_state.lock().await; ts.clear_pending(); } - - /// Best-effort, non-blocking variant for synchronous contexts (Drop/interrupt). - pub(crate) fn try_clear_pending_sync(&self) { - if let Ok(mut ts) = self.turn_state.try_lock() { - ts.clear_pending(); - } - } } #[cfg(test)] diff --git a/codex-rs/core/src/tasks/compact.rs b/codex-rs/core/src/tasks/compact.rs index 823febfc94..12e61b1f65 100644 --- a/codex-rs/core/src/tasks/compact.rs +++ b/codex-rs/core/src/tasks/compact.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use async_trait::async_trait; +use tokio_util::sync::CancellationToken; use crate::codex::TurnContext; use crate::codex::compact; @@ -25,6 +26,7 @@ impl SessionTask for CompactTask { ctx: Arc, sub_id: String, input: Vec, + _cancellation_token: CancellationToken, ) -> Option { compact::run_compact_task(session.clone_session(), ctx, sub_id, input).await } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 464c1e633a..15ec419f9b 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -3,9 +3,15 @@ mod regular; mod review; use std::sync::Arc; +use std::time::Duration; use async_trait::async_trait; +use tokio::select; +use tokio::sync::Notify; +use tokio_util::sync::CancellationToken; +use tokio_util::task::AbortOnDropHandle; use tracing::trace; +use tracing::warn; use crate::codex::Session; use crate::codex::TurnContext; @@ -23,6 +29,8 @@ pub(crate) use compact::CompactTask; pub(crate) use regular::RegularTask; pub(crate) use review::ReviewTask; +const GRACEFULL_INTERRUPTION_TIMEOUT_MS: u64 = 100; + /// Thin wrapper that exposes the parts of [`Session`] task runners need. #[derive(Clone)] pub(crate) struct SessionTaskContext { @@ -49,6 +57,7 @@ pub(crate) trait SessionTask: Send + Sync + 'static { ctx: Arc, sub_id: String, input: Vec, + cancellation_token: CancellationToken, ) -> Option; async fn abort(&self, session: Arc, sub_id: &str) { @@ -69,26 +78,42 @@ impl Session { let task: Arc = Arc::new(task); let task_kind = task.kind(); + let cancellation_token = CancellationToken::new(); + let done = Arc::new(Notify::new()); + + let done_clone = Arc::clone(&done); let handle = { let session_ctx = Arc::new(SessionTaskContext::new(Arc::clone(self))); let ctx = Arc::clone(&turn_context); let task_for_run = Arc::clone(&task); let sub_clone = sub_id.clone(); + let task_cancellation_token = cancellation_token.child_token(); tokio::spawn(async move { let last_agent_message = task_for_run - .run(Arc::clone(&session_ctx), ctx, sub_clone.clone(), input) + .run( + Arc::clone(&session_ctx), + ctx, + sub_clone.clone(), + input, + task_cancellation_token.child_token(), + ) .await; - // Emit completion uniformly from spawn site so all tasks share the same lifecycle. - let sess = session_ctx.clone_session(); - sess.on_task_finished(sub_clone, last_agent_message).await; + + if !task_cancellation_token.is_cancelled() { + // Emit completion uniformly from spawn site so all tasks share the same lifecycle. + let sess = session_ctx.clone_session(); + sess.on_task_finished(sub_clone, last_agent_message).await; + } + done_clone.notify_waiters(); }) - .abort_handle() }; let running_task = RunningTask { - handle, + done, + handle: Arc::new(AbortOnDropHandle::new(handle)), kind: task_kind, task, + cancellation_token, }; self.register_new_active_task(sub_id, running_task).await; } @@ -143,14 +168,24 @@ impl Session { task: RunningTask, reason: TurnAbortReason, ) { - if task.handle.is_finished() { + if task.cancellation_token.is_cancelled() { return; } trace!(task_kind = ?task.kind, sub_id, "aborting running task"); + task.cancellation_token.cancel(); let session_task = task.task; - let handle = task.handle; - handle.abort(); + + select! { + _ = task.done.notified() => { + }, + _ = tokio::time::sleep(Duration::from_millis(GRACEFULL_INTERRUPTION_TIMEOUT_MS)) => { + warn!("task {sub_id} didn't complete gracefully after {}ms", GRACEFULL_INTERRUPTION_TIMEOUT_MS); + } + } + + task.handle.abort(); + let session_ctx = Arc::new(SessionTaskContext::new(Arc::clone(self))); session_task.abort(session_ctx, &sub_id).await; diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index b3758d5fc6..3a3fa267ce 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use async_trait::async_trait; +use tokio_util::sync::CancellationToken; use crate::codex::TurnContext; use crate::codex::run_task; @@ -25,8 +26,17 @@ impl SessionTask for RegularTask { ctx: Arc, sub_id: String, input: Vec, + cancellation_token: CancellationToken, ) -> Option { let sess = session.clone_session(); - run_task(sess, ctx, sub_id, input, TaskKind::Regular).await + run_task( + sess, + ctx, + sub_id, + input, + TaskKind::Regular, + cancellation_token, + ) + .await } } diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index cec9243234..6b4b2175db 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use async_trait::async_trait; +use tokio_util::sync::CancellationToken; use crate::codex::TurnContext; use crate::codex::exit_review_mode; @@ -26,9 +27,18 @@ impl SessionTask for ReviewTask { ctx: Arc, sub_id: String, input: Vec, + cancellation_token: CancellationToken, ) -> Option { let sess = session.clone_session(); - run_task(sess, ctx, sub_id, input, TaskKind::Review).await + run_task( + sess, + ctx, + sub_id, + input, + TaskKind::Review, + cancellation_token, + ) + .await } async fn abort(&self, session: Arc, sub_id: &str) { From 44ceaf085b0ffafe127a050fd5d35797b8ab1fc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Fri, 17 Oct 2025 20:19:08 +0100 Subject: [PATCH 012/287] Fix nix build (#4048) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I dropped the build of the old cli from the flake, where the default.nix already seemed to removed in a previous iterations. Then I updated flake.nix and codex-rs expression to be able to build again (see individual commits for details). Tested by running the following builds: ``` $ nix build .#packages.x86_64-linux.codex-rs $ nix build .#packages.aarch64-darwin.codex-cli ``` --------- Signed-off-by: Jörg Thalheim --- codex-rs/default.nix | 65 ++++++++++++++++---------------------- flake.lock | 63 +++---------------------------------- flake.nix | 75 ++++++++++++++------------------------------ 3 files changed, 54 insertions(+), 149 deletions(-) diff --git a/codex-rs/default.nix b/codex-rs/default.nix index d2d4dfb6ba..3d598a9c2b 100644 --- a/codex-rs/default.nix +++ b/codex-rs/default.nix @@ -1,42 +1,31 @@ -{ pkgs, monorep-deps ? [], ... }: -let +{ + openssl, + rustPlatform, + pkg-config, + lib, + ... +}: +rustPlatform.buildRustPackage (finalAttrs: { env = { - PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig:$PKG_CONFIG_PATH"; + PKG_CONFIG_PATH = "${openssl.dev}/lib/pkgconfig:$PKG_CONFIG_PATH"; }; -in -rec { - package = pkgs.rustPlatform.buildRustPackage { - inherit env; - pname = "codex-rs"; - version = "0.1.0"; - cargoLock.lockFile = ./Cargo.lock; - doCheck = false; - src = ./.; - nativeBuildInputs = with pkgs; [ - pkg-config - openssl - ]; - meta = with pkgs.lib; { - description = "OpenAI Codex command‑line interface rust implementation"; - license = licenses.asl20; - homepage = "https://github.com/openai/codex"; - }; + pname = "codex-rs"; + version = "0.1.0"; + cargoLock.lockFile = ./Cargo.lock; + doCheck = false; + src = ./.; + nativeBuildInputs = [ + pkg-config + openssl + ]; + + cargoLock.outputHashes = { + "ratatui-0.29.0" = "sha256-HBvT5c8GsiCxMffNjJGLmHnvG77A6cqEL+1ARurBXho="; }; - devShell = pkgs.mkShell { - inherit env; - name = "codex-rs-dev"; - packages = monorep-deps ++ [ - pkgs.cargo - package - ]; - shellHook = '' - echo "Entering development shell for codex-rs" - alias codex="cd ${package.src}/tui; cargo run; cd -" - ${pkgs.rustPlatform.cargoSetupHook} - ''; + + meta = with lib; { + description = "OpenAI Codex command‑line interface rust implementation"; + license = licenses.asl20; + homepage = "https://github.com/openai/codex"; }; - app = { - type = "app"; - program = "${package}/bin/codex"; - }; -} +}) diff --git a/flake.lock b/flake.lock index 6e4f3acce7..21550893f4 100644 --- a/flake.lock +++ b/flake.lock @@ -1,30 +1,12 @@ { "nodes": { - "flake-utils": { - "inputs": { - "systems": "systems" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, "nixpkgs": { "locked": { - "lastModified": 1744463964, - "narHash": "sha256-LWqduOgLHCFxiTNYi3Uj5Lgz0SR+Xhw3kr/3Xd0GPTM=", + "lastModified": 1758427187, + "narHash": "sha256-pHpxZ/IyCwoTQPtFIAG2QaxuSm8jWzrzBGjwQZIttJc=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "2631b0b7abcea6e640ce31cd78ea58910d31e650", + "rev": "554be6495561ff07b6c724047bdd7e0716aa7b46", "type": "github" }, "original": { @@ -36,44 +18,7 @@ }, "root": { "inputs": { - "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs", - "rust-overlay": "rust-overlay" - } - }, - "rust-overlay": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1746844454, - "narHash": "sha256-GcUWDQUDRYrD34ol90KGUpjbVcOfUNbv0s955jPecko=", - "owner": "oxalica", - "repo": "rust-overlay", - "rev": "be092436d4c0c303b654e4007453b69c0e33009e", - "type": "github" - }, - "original": { - "owner": "oxalica", - "repo": "rust-overlay", - "type": "github" - } - }, - "systems": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" + "nixpkgs": "nixpkgs" } } }, diff --git a/flake.nix b/flake.nix index 7247333c5a..b331c443bb 100644 --- a/flake.nix +++ b/flake.nix @@ -1,57 +1,28 @@ { description = "Development Nix flake for OpenAI Codex CLI"; - inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - flake-utils.url = "github:numtide/flake-utils"; - rust-overlay = { - url = "github:oxalica/rust-overlay"; - inputs.nixpkgs.follows = "nixpkgs"; + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + outputs = { nixpkgs, ... }: + let + systems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + forAllSystems = f: nixpkgs.lib.genAttrs systems f; + in + { + packages = forAllSystems (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + codex-rs = pkgs.callPackage ./codex-rs { }; + in + { + codex-rs = codex-rs; + default = codex-rs; + } + ); }; - }; - - outputs = { nixpkgs, flake-utils, rust-overlay, ... }: - flake-utils.lib.eachDefaultSystem (system: - let - pkgs = import nixpkgs { - inherit system; - }; - pkgsWithRust = import nixpkgs { - inherit system; - overlays = [ rust-overlay.overlays.default ]; - }; - monorepo-deps = with pkgs; [ - # for precommit hook - pnpm - husky - ]; - codex-cli = import ./codex-cli { - inherit pkgs monorepo-deps; - }; - codex-rs = import ./codex-rs { - pkgs = pkgsWithRust; - inherit monorepo-deps; - }; - in - rec { - packages = { - codex-cli = codex-cli.package; - codex-rs = codex-rs.package; - }; - - devShells = { - codex-cli = codex-cli.devShell; - codex-rs = codex-rs.devShell; - }; - - apps = { - codex-cli = codex-cli.app; - codex-rs = codex-rs.app; - }; - - defaultPackage = packages.codex-cli; - defaultApp = apps.codex-cli; - defaultDevShell = devShells.codex-cli; - } - ); } From 6b0c486861ffcf6e0bbab65697d181985e692ded Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Fri, 17 Oct 2025 14:47:50 -0700 Subject: [PATCH 013/287] [MCP] Render full MCP errors to the model (#5298) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, the model couldn't see why MCP tool calls failed, many of which were the model using the parameters incorrectly. A common failure is the model stringifying the json for the notion-update-page tool which it then couldn't correct. I want to do some system prompt massaging around this as well. However, it is crucial that the model sees the error so it can fix it. Before: CleanShot 2025-10-17 at 13 02 36 After: CleanShot 2025-10-17 at 13 01 18 🎉 CleanShot 2025-10-17 at 13 09 30 Fixes #4707 --- codex-rs/core/src/mcp_tool_call.rs | 5 ++++- codex-rs/tui/src/history_cell.rs | 7 ++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 7091a05656..21fdaf0026 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -58,7 +58,10 @@ pub(crate) async fn handle_mcp_tool_call( let result = sess .call_tool(&server, &tool_name, arguments_value.clone()) .await - .map_err(|e| format!("tool call error: {e}")); + .map_err(|e| format!("tool call error: {e:?}")); + if let Err(e) = &result { + tracing::warn!("MCP tool call error: {e:?}"); + } let tool_call_end_event = EventMsg::McpToolCallEnd(McpToolCallEndEvent { call_id: call_id.clone(), invocation, diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 85acaf9b6a..0a3582577d 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -854,7 +854,12 @@ impl HistoryCell for McpToolCallCell { } } Err(err) => { - let err_line = Line::from(format!("Error: {err}").dim()); + let err_text = format_and_truncate_tool_result( + &format!("Error: {err}"), + TOOL_CALL_MAX_LINES, + width as usize, + ); + let err_line = Line::from(err_text.dim()); let wrapped = word_wrap_line( &err_line, RtOptions::new((width as usize).saturating_sub(4)) From c1bde2a4ef9faeb3ae158cd7990015ac0570db97 Mon Sep 17 00:00:00 2001 From: Ryan Lopopolo Date: Fri, 17 Oct 2025 14:58:27 -0700 Subject: [PATCH 014/287] Fix link to MCP Servers config section (#5301) fix a broken link in https://github.com/openai/codex/blob/main/codex-rs/config.md to point to the anchor for configuring MCPs: https://github.com/openai/codex/blob/main/docs/config.md#connecting-to-mcp-servers --- codex-rs/config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/config.md b/codex-rs/config.md index af51df4197..5de2a2d9c4 100644 --- a/codex-rs/config.md +++ b/codex-rs/config.md @@ -3,4 +3,4 @@ This file has moved. Please see the latest configuration documentation here: - Full config docs: [docs/config.md](../docs/config.md) -- MCP servers section: [docs/config.md#mcp_servers](../docs/config.md#mcp_servers) \ No newline at end of file +- MCP servers section: [docs/config.md#connecting-to-mcp-servers](../docs/config.md#connecting-to-mcp-servers) From 41900e9d0ff338f05571ab71e59b9bb023cac907 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Fri, 17 Oct 2025 15:16:22 -0700 Subject: [PATCH 015/287] [MCP] When MCP auth expires, prompt the user to log in again. (#5300) Similar to https://github.com/openai/codex/pull/5193 but catches a case where the user _has_ authenticated but the auth expired or was revoked. Before: CleanShot 2025-10-17 at 14 28 11 After: image --- codex-rs/core/src/codex.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 23751d1b6d..2eb80cfaa6 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -426,13 +426,16 @@ impl Session { // Surface individual client start-up failures to the user. if !failed_clients.is_empty() { for (server_name, err) in failed_clients { + let auth_status = auth_statuses.get(&server_name); + let requires_login = match auth_status { + Some(McpAuthStatus::NotLoggedIn) => true, + Some(McpAuthStatus::OAuth) => is_mcp_client_auth_required_error(&err), + _ => false, + }; let log_message = format!("MCP client for `{server_name}` failed to start: {err:#}"); error!("{log_message}"); - let display_message = if matches!( - auth_statuses.get(&server_name), - Some(McpAuthStatus::NotLoggedIn) - ) { + let display_message = if requires_login { format!( "The {server_name} MCP server is not logged in. Run `codex mcp login {server_name}` to log in." ) @@ -2524,6 +2527,11 @@ pub(crate) async fn exit_review_mode( .await; } +fn is_mcp_client_auth_required_error(error: &anyhow::Error) -> bool { + // StreamableHttpError::AuthRequired from the MCP SDK. + error.to_string().contains("Auth required") +} + use crate::executor::errors::ExecError; use crate::executor::linkers::PreparedExec; use crate::tools::context::ApplyPatchCommandContext; From 0e08dd6055da1f1eb58a0ac0a15acfb038878029 Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Fri, 17 Oct 2025 17:39:37 -0700 Subject: [PATCH 016/287] fix: switch rate limit reset handling to timestamps (#5304) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change ensures that we store the absolute time instead of relative offsets of when the primary and secondary rate limits will reset. Previously these got recalculated relative to current time, which leads to the displayed reset times to change over time, including after doing a codex resume. For previously changed sessions, this will cause the reset times to not show due to this being a breaking change: Screenshot 2025-10-17 at 5 14 18 PM Fixes https://github.com/openai/codex/issues/4761 --- codex-rs/clippy.toml | 4 + codex-rs/core/src/client.rs | 44 +++--- codex-rs/core/src/error.rs | 181 ++++++++++++++++--------- codex-rs/core/tests/suite/client.rs | 22 +-- codex-rs/protocol/src/protocol.rs | 6 +- codex-rs/tui/src/status/rate_limits.rs | 9 +- codex-rs/tui/src/status/tests.rs | 64 +++++---- 7 files changed, 203 insertions(+), 127 deletions(-) diff --git a/codex-rs/clippy.toml b/codex-rs/clippy.toml index 5a6ff7f052..a2aae6591b 100644 --- a/codex-rs/clippy.toml +++ b/codex-rs/clippy.toml @@ -7,3 +7,7 @@ disallowed-methods = [ { path = "ratatui::style::Stylize::black", reason = "Avoid hardcoding black; prefer default fg or dim/bold. Exception: Disable this rule if rendering over a hardcoded ANSI background." }, { path = "ratatui::style::Stylize::yellow", reason = "Avoid yellow; prefer other colors in `tui/styles.md`." }, ] + +# Increase the size threshold for result_large_err to accommodate +# richer error variants. +large-error-threshold = 256 diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 3a259fce67..b215106d2b 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -52,6 +52,8 @@ use crate::protocol::TokenUsage; use crate::state::TaskKind; use crate::token_data::PlanType; use crate::util::backoff; +use chrono::DateTime; +use chrono::Utc; use codex_otel::otel_event_manager::OtelEventManager; use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; @@ -71,7 +73,7 @@ struct Error { // Optional fields available on "usage_limit_reached" and "usage_not_included" errors plan_type: Option, - resets_in_seconds: Option, + resets_at: Option, } #[derive(Debug, Clone)] @@ -419,10 +421,14 @@ impl ModelClient { let plan_type = error .plan_type .or_else(|| auth.as_ref().and_then(CodexAuth::get_plan_type)); - let resets_in_seconds = error.resets_in_seconds; + let resets_at = error + .resets_at + .as_deref() + .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) + .map(|dt| dt.with_timezone(&Utc)); let codex_err = CodexErr::UsageLimitReached(UsageLimitReachedError { plan_type, - resets_in_seconds, + resets_at, rate_limits: rate_limit_snapshot, }); return Err(StreamAttemptError::Fatal(codex_err)); @@ -605,14 +611,14 @@ fn parse_rate_limit_snapshot(headers: &HeaderMap) -> Option { headers, "x-codex-primary-used-percent", "x-codex-primary-window-minutes", - "x-codex-primary-reset-after-seconds", + "x-codex-primary-reset-at", ); let secondary = parse_rate_limit_window( headers, "x-codex-secondary-used-percent", "x-codex-secondary-window-minutes", - "x-codex-secondary-reset-after-seconds", + "x-codex-secondary-reset-at", ); Some(RateLimitSnapshot { primary, secondary }) @@ -628,16 +634,19 @@ fn parse_rate_limit_window( used_percent.and_then(|used_percent| { let window_minutes = parse_header_u64(headers, window_minutes_header); - let resets_in_seconds = parse_header_u64(headers, resets_header); + let resets_at = parse_header_str(headers, resets_header) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(std::string::ToString::to_string); let has_data = used_percent != 0.0 || window_minutes.is_some_and(|minutes| minutes != 0) - || resets_in_seconds.is_some_and(|seconds| seconds != 0); + || resets_at.is_some(); has_data.then_some(RateLimitWindow { used_percent, window_minutes, - resets_in_seconds, + resets_at, }) }) } @@ -1390,7 +1399,7 @@ mod tests { message: Some("Rate limit reached for gpt-5 in organization org- on tokens per min (TPM): Limit 1, Used 1, Requested 19304. Please try again in 28ms. Visit https://platform.openai.com/account/rate-limits to learn more.".to_string()), code: Some("rate_limit_exceeded".to_string()), plan_type: None, - resets_in_seconds: None + resets_at: None }; let delay = try_parse_retry_after(&err); @@ -1404,20 +1413,19 @@ mod tests { message: Some("Rate limit reached for gpt-5 in organization on tokens per min (TPM): Limit 30000, Used 6899, Requested 24050. Please try again in 1.898s. Visit https://platform.openai.com/account/rate-limits to learn more.".to_string()), code: Some("rate_limit_exceeded".to_string()), plan_type: None, - resets_in_seconds: None + resets_at: None }; let delay = try_parse_retry_after(&err); assert_eq!(delay, Some(Duration::from_secs_f64(1.898))); } #[test] - fn error_response_deserializes_old_schema_known_plan_type_and_serializes_back() { + fn error_response_deserializes_schema_known_plan_type_and_serializes_back() { use crate::token_data::KnownPlan; use crate::token_data::PlanType; - let json = r#"{"error":{"type":"usage_limit_reached","plan_type":"pro","resets_in_seconds":3600}}"#; - let resp: ErrorResponse = - serde_json::from_str(json).expect("should deserialize old schema"); + let json = r#"{"error":{"type":"usage_limit_reached","plan_type":"pro","resets_at":"2024-01-01T00:00:00Z"}}"#; + let resp: ErrorResponse = serde_json::from_str(json).expect("should deserialize schema"); assert_matches!(resp.error.plan_type, Some(PlanType::Known(KnownPlan::Pro))); @@ -1426,13 +1434,11 @@ mod tests { } #[test] - fn error_response_deserializes_old_schema_unknown_plan_type_and_serializes_back() { + fn error_response_deserializes_schema_unknown_plan_type_and_serializes_back() { use crate::token_data::PlanType; - let json = - r#"{"error":{"type":"usage_limit_reached","plan_type":"vip","resets_in_seconds":60}}"#; - let resp: ErrorResponse = - serde_json::from_str(json).expect("should deserialize old schema"); + let json = r#"{"error":{"type":"usage_limit_reached","plan_type":"vip","resets_at":"2024-01-01T00:01:00Z"}}"#; + let resp: ErrorResponse = serde_json::from_str(json).expect("should deserialize schema"); assert_matches!(resp.error.plan_type, Some(PlanType::Unknown(ref s)) if s == "vip"); diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 951ba3954f..5597ffd431 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -2,6 +2,8 @@ use crate::exec::ExecToolCallOutput; use crate::token_data::KnownPlan; use crate::token_data::PlanType; use crate::truncate::truncate_middle; +use chrono::DateTime; +use chrono::Utc; use codex_async_utils::CancelErr; use codex_protocol::ConversationId; use codex_protocol::protocol::RateLimitSnapshot; @@ -237,7 +239,7 @@ impl std::fmt::Display for RetryLimitReachedError { #[derive(Debug)] pub struct UsageLimitReachedError { pub(crate) plan_type: Option, - pub(crate) resets_in_seconds: Option, + pub(crate) resets_at: Option>, pub(crate) rate_limits: Option, } @@ -246,12 +248,12 @@ impl std::fmt::Display for UsageLimitReachedError { let message = match self.plan_type.as_ref() { Some(PlanType::Known(KnownPlan::Plus)) => format!( "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing){}", - retry_suffix_after_or(self.resets_in_seconds) + retry_suffix_after_or(self.resets_at.as_ref()) ), Some(PlanType::Known(KnownPlan::Team)) | Some(PlanType::Known(KnownPlan::Business)) => { format!( "You've hit your usage limit. To get more access now, send a request to your admin{}", - retry_suffix_after_or(self.resets_in_seconds) + retry_suffix_after_or(self.resets_at.as_ref()) ) } Some(PlanType::Known(KnownPlan::Free)) => { @@ -262,11 +264,11 @@ impl std::fmt::Display for UsageLimitReachedError { | Some(PlanType::Known(KnownPlan::Enterprise)) | Some(PlanType::Known(KnownPlan::Edu)) => format!( "You've hit your usage limit.{}", - retry_suffix(self.resets_in_seconds) + retry_suffix(self.resets_at.as_ref()) ), Some(PlanType::Unknown(_)) | None => format!( "You've hit your usage limit.{}", - retry_suffix(self.resets_in_seconds) + retry_suffix(self.resets_at.as_ref()) ), }; @@ -274,8 +276,8 @@ impl std::fmt::Display for UsageLimitReachedError { } } -fn retry_suffix(resets_in_seconds: Option) -> String { - if let Some(secs) = resets_in_seconds { +fn retry_suffix(resets_at: Option<&DateTime>) -> String { + if let Some(secs) = remaining_seconds(resets_at) { let reset_duration = format_reset_duration(secs); format!(" Try again in {reset_duration}.") } else { @@ -283,8 +285,8 @@ fn retry_suffix(resets_in_seconds: Option) -> String { } } -fn retry_suffix_after_or(resets_in_seconds: Option) -> String { - if let Some(secs) = resets_in_seconds { +fn retry_suffix_after_or(resets_at: Option<&DateTime>) -> String { + if let Some(secs) = remaining_seconds(resets_at) { let reset_duration = format_reset_duration(secs); format!(" or try again in {reset_duration}.") } else { @@ -292,6 +294,29 @@ fn retry_suffix_after_or(resets_in_seconds: Option) -> String { } } +fn remaining_seconds(resets_at: Option<&DateTime>) -> Option { + let resets_at = resets_at.cloned()?; + let now = now_for_retry(); + let secs = resets_at.signed_duration_since(now).num_seconds(); + Some(if secs <= 0 { 0 } else { secs as u64 }) +} + +#[cfg(test)] +thread_local! { + static NOW_OVERRIDE: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; +} + +fn now_for_retry() -> DateTime { + #[cfg(test)] + { + if let Some(now) = NOW_OVERRIDE.with(|cell| *cell.borrow()) { + return now; + } + } + Utc::now() +} + fn format_reset_duration(total_secs: u64) -> String { let days = total_secs / 86_400; let hours = (total_secs % 86_400) / 3_600; @@ -388,6 +413,10 @@ pub fn get_error_message_ui(e: &CodexErr) -> String { mod tests { use super::*; use crate::exec::StreamOutput; + use chrono::DateTime; + use chrono::Duration as ChronoDuration; + use chrono::TimeZone; + use chrono::Utc; use codex_protocol::protocol::RateLimitWindow; use pretty_assertions::assert_eq; @@ -396,21 +425,30 @@ mod tests { primary: Some(RateLimitWindow { used_percent: 50.0, window_minutes: Some(60), - resets_in_seconds: Some(3600), + resets_at: Some("2024-01-01T01:00:00Z".to_string()), }), secondary: Some(RateLimitWindow { used_percent: 30.0, window_minutes: Some(120), - resets_in_seconds: Some(7200), + resets_at: Some("2024-01-01T02:00:00Z".to_string()), }), } } + fn with_now_override(now: DateTime, f: impl FnOnce() -> T) -> T { + NOW_OVERRIDE.with(|cell| { + *cell.borrow_mut() = Some(now); + let result = f(); + *cell.borrow_mut() = None; + result + }) + } + #[test] fn usage_limit_reached_error_formats_plus_plan() { let err = UsageLimitReachedError { plan_type: Some(PlanType::Known(KnownPlan::Plus)), - resets_in_seconds: None, + resets_at: None, rate_limits: Some(rate_limit_snapshot()), }; assert_eq!( @@ -490,7 +528,7 @@ mod tests { fn usage_limit_reached_error_formats_free_plan() { let err = UsageLimitReachedError { plan_type: Some(PlanType::Known(KnownPlan::Free)), - resets_in_seconds: Some(3600), + resets_at: None, rate_limits: Some(rate_limit_snapshot()), }; assert_eq!( @@ -503,7 +541,7 @@ mod tests { fn usage_limit_reached_error_formats_default_when_none() { let err = UsageLimitReachedError { plan_type: None, - resets_in_seconds: None, + resets_at: None, rate_limits: Some(rate_limit_snapshot()), }; assert_eq!( @@ -514,22 +552,26 @@ mod tests { #[test] fn usage_limit_reached_error_formats_team_plan() { - let err = UsageLimitReachedError { - plan_type: Some(PlanType::Known(KnownPlan::Team)), - resets_in_seconds: Some(3600), - rate_limits: Some(rate_limit_snapshot()), - }; - assert_eq!( - err.to_string(), - "You've hit your usage limit. To get more access now, send a request to your admin or try again in 1 hour." - ); + let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + let resets_at = base + ChronoDuration::hours(1); + with_now_override(base, move || { + let err = UsageLimitReachedError { + plan_type: Some(PlanType::Known(KnownPlan::Team)), + resets_at: Some(resets_at), + rate_limits: Some(rate_limit_snapshot()), + }; + assert_eq!( + err.to_string(), + "You've hit your usage limit. To get more access now, send a request to your admin or try again in 1 hour." + ); + }); } #[test] fn usage_limit_reached_error_formats_business_plan_without_reset() { let err = UsageLimitReachedError { plan_type: Some(PlanType::Known(KnownPlan::Business)), - resets_in_seconds: None, + resets_at: None, rate_limits: Some(rate_limit_snapshot()), }; assert_eq!( @@ -542,7 +584,7 @@ mod tests { fn usage_limit_reached_error_formats_default_for_other_plans() { let err = UsageLimitReachedError { plan_type: Some(PlanType::Known(KnownPlan::Pro)), - resets_in_seconds: None, + resets_at: None, rate_limits: Some(rate_limit_snapshot()), }; assert_eq!( @@ -553,53 +595,70 @@ mod tests { #[test] fn usage_limit_reached_includes_minutes_when_available() { - let err = UsageLimitReachedError { - plan_type: None, - resets_in_seconds: Some(5 * 60), - rate_limits: Some(rate_limit_snapshot()), - }; - assert_eq!( - err.to_string(), - "You've hit your usage limit. Try again in 5 minutes." - ); + let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + let resets_at = base + ChronoDuration::minutes(5); + with_now_override(base, move || { + let err = UsageLimitReachedError { + plan_type: None, + resets_at: Some(resets_at), + rate_limits: Some(rate_limit_snapshot()), + }; + assert_eq!( + err.to_string(), + "You've hit your usage limit. Try again in 5 minutes." + ); + }); } #[test] fn usage_limit_reached_includes_hours_and_minutes() { - let err = UsageLimitReachedError { - plan_type: Some(PlanType::Known(KnownPlan::Plus)), - resets_in_seconds: Some(3 * 3600 + 32 * 60), - rate_limits: Some(rate_limit_snapshot()), - }; - assert_eq!( - err.to_string(), - "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing) or try again in 3 hours 32 minutes." - ); + let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + let resets_at = base + ChronoDuration::hours(3) + ChronoDuration::minutes(32); + with_now_override(base, move || { + let err = UsageLimitReachedError { + plan_type: Some(PlanType::Known(KnownPlan::Plus)), + resets_at: Some(resets_at), + rate_limits: Some(rate_limit_snapshot()), + }; + assert_eq!( + err.to_string(), + "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing) or try again in 3 hours 32 minutes." + ); + }); } #[test] fn usage_limit_reached_includes_days_hours_minutes() { - let err = UsageLimitReachedError { - plan_type: None, - resets_in_seconds: Some(2 * 86_400 + 3 * 3600 + 5 * 60), - rate_limits: Some(rate_limit_snapshot()), - }; - assert_eq!( - err.to_string(), - "You've hit your usage limit. Try again in 2 days 3 hours 5 minutes." - ); + let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + let resets_at = + base + ChronoDuration::days(2) + ChronoDuration::hours(3) + ChronoDuration::minutes(5); + with_now_override(base, move || { + let err = UsageLimitReachedError { + plan_type: None, + resets_at: Some(resets_at), + rate_limits: Some(rate_limit_snapshot()), + }; + assert_eq!( + err.to_string(), + "You've hit your usage limit. Try again in 2 days 3 hours 5 minutes." + ); + }); } #[test] fn usage_limit_reached_less_than_minute() { - let err = UsageLimitReachedError { - plan_type: None, - resets_in_seconds: Some(30), - rate_limits: Some(rate_limit_snapshot()), - }; - assert_eq!( - err.to_string(), - "You've hit your usage limit. Try again in less than a minute." - ); + let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + let resets_at = base + ChronoDuration::seconds(30); + with_now_override(base, move || { + let err = UsageLimitReachedError { + plan_type: None, + resets_at: Some(resets_at), + rate_limits: Some(rate_limit_snapshot()), + }; + assert_eq!( + err.to_string(), + "You've hit your usage limit. Try again in less than a minute." + ); + }); } } diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index beaa87a853..817f2c2a08 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -768,8 +768,8 @@ async fn token_count_includes_rate_limits_snapshot() { .insert_header("x-codex-secondary-used-percent", "40.0") .insert_header("x-codex-primary-window-minutes", "10") .insert_header("x-codex-secondary-window-minutes", "60") - .insert_header("x-codex-primary-reset-after-seconds", "1800") - .insert_header("x-codex-secondary-reset-after-seconds", "7200") + .insert_header("x-codex-primary-reset-at", "2024-01-01T00:30:00Z") + .insert_header("x-codex-secondary-reset-at", "2024-01-01T02:00:00Z") .set_body_raw(sse_body, "text/event-stream"); Mock::given(method("POST")) @@ -818,12 +818,12 @@ async fn token_count_includes_rate_limits_snapshot() { "primary": { "used_percent": 12.5, "window_minutes": 10, - "resets_in_seconds": 1800 + "resets_at": "2024-01-01T00:30:00Z" }, "secondary": { "used_percent": 40.0, "window_minutes": 60, - "resets_in_seconds": 7200 + "resets_at": "2024-01-01T02:00:00Z" } } }) @@ -865,12 +865,12 @@ async fn token_count_includes_rate_limits_snapshot() { "primary": { "used_percent": 12.5, "window_minutes": 10, - "resets_in_seconds": 1800 + "resets_at": "2024-01-01T00:30:00Z" }, "secondary": { "used_percent": 40.0, "window_minutes": 60, - "resets_in_seconds": 7200 + "resets_at": "2024-01-01T02:00:00Z" } } }) @@ -893,8 +893,8 @@ async fn token_count_includes_rate_limits_snapshot() { final_snapshot .primary .as_ref() - .and_then(|window| window.resets_in_seconds), - Some(1800) + .and_then(|window| window.resets_at.as_deref()), + Some("2024-01-01T00:30:00Z") ); wait_for_event(&codex, |msg| matches!(msg, EventMsg::TaskComplete(_))).await; @@ -915,7 +915,7 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { "error": { "type": "usage_limit_reached", "message": "limit reached", - "resets_in_seconds": 42, + "resets_at": "2024-01-01T00:00:42Z", "plan_type": "pro" } })); @@ -935,12 +935,12 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { "primary": { "used_percent": 100.0, "window_minutes": 15, - "resets_in_seconds": null + "resets_at": null }, "secondary": { "used_percent": 87.5, "window_minutes": 60, - "resets_in_seconds": null + "resets_at": null } }); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index d888194776..5429e4d402 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -644,9 +644,9 @@ pub struct RateLimitWindow { /// Rolling window duration, in minutes. #[ts(type = "number | null")] pub window_minutes: Option, - /// Seconds until the window resets. - #[ts(type = "number | null")] - pub resets_in_seconds: Option, + /// Timestamp (RFC3339) when the window resets. + #[ts(type = "string | null")] + pub resets_at: Option, } // Includes prompts, tools and space to call compact. diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index 875001ef8a..f6a9c06aab 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -2,11 +2,9 @@ use crate::chatwidget::get_limits_duration; use super::helpers::format_reset_timestamp; use chrono::DateTime; -use chrono::Duration as ChronoDuration; use chrono::Local; use codex_core::protocol::RateLimitSnapshot; use codex_core::protocol::RateLimitWindow; -use std::convert::TryFrom; const STATUS_LIMIT_BAR_SEGMENTS: usize = 20; const STATUS_LIMIT_BAR_FILLED: &str = "█"; @@ -35,9 +33,10 @@ pub(crate) struct RateLimitWindowDisplay { impl RateLimitWindowDisplay { fn from_window(window: &RateLimitWindow, captured_at: DateTime) -> Self { let resets_at = window - .resets_in_seconds - .and_then(|seconds| i64::try_from(seconds).ok()) - .and_then(|secs| captured_at.checked_add_signed(ChronoDuration::seconds(secs))) + .resets_at + .as_deref() + .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) + .map(|dt| dt.with_timezone(&Local)) .map(|dt| format_reset_timestamp(dt, captured_at)); Self { diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 395d952f41..5cb730c905 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -1,7 +1,9 @@ use super::new_status_output; use super::rate_limit_snapshot_display; use crate::history_cell::HistoryCell; +use chrono::Duration as ChronoDuration; use chrono::TimeZone; +use chrono::Utc; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; @@ -60,6 +62,12 @@ fn sanitize_directory(lines: Vec) -> Vec { .collect() } +fn reset_at_from(captured_at: &chrono::DateTime, seconds: i64) -> String { + (*captured_at + ChronoDuration::seconds(seconds)) + .with_timezone(&Utc) + .to_rfc3339() +} + #[test] fn status_snapshot_includes_reasoning_details() { let temp_home = TempDir::new().expect("temp home"); @@ -85,22 +93,22 @@ fn status_snapshot_includes_reasoning_details() { total_tokens: 2_250, }; - let snapshot = RateLimitSnapshot { - primary: Some(RateLimitWindow { - used_percent: 72.5, - window_minutes: Some(300), - resets_in_seconds: Some(600), - }), - secondary: Some(RateLimitWindow { - used_percent: 45.0, - window_minutes: Some(10080), - resets_in_seconds: Some(1_200), - }), - }; let captured_at = chrono::Local .with_ymd_and_hms(2024, 1, 2, 3, 4, 5) .single() .expect("timestamp"); + let snapshot = RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 72.5, + window_minutes: Some(300), + resets_at: Some(reset_at_from(&captured_at, 600)), + }), + secondary: Some(RateLimitWindow { + used_percent: 45.0, + window_minutes: Some(10080), + resets_at: Some(reset_at_from(&captured_at, 1_200)), + }), + }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display)); @@ -130,18 +138,18 @@ fn status_snapshot_includes_monthly_limit() { total_tokens: 1_200, }; - let snapshot = RateLimitSnapshot { - primary: Some(RateLimitWindow { - used_percent: 12.0, - window_minutes: Some(43_200), - resets_in_seconds: Some(86_400), - }), - secondary: None, - }; let captured_at = chrono::Local .with_ymd_and_hms(2024, 5, 6, 7, 8, 9) .single() .expect("timestamp"); + let snapshot = RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 12.0, + window_minutes: Some(43_200), + resets_at: Some(reset_at_from(&captured_at, 86_400)), + }), + secondary: None, + }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display)); @@ -197,18 +205,18 @@ fn status_snapshot_truncates_in_narrow_terminal() { total_tokens: 2_250, }; - let snapshot = RateLimitSnapshot { - primary: Some(RateLimitWindow { - used_percent: 72.5, - window_minutes: Some(300), - resets_in_seconds: Some(600), - }), - secondary: None, - }; let captured_at = chrono::Local .with_ymd_and_hms(2024, 1, 2, 3, 4, 5) .single() .expect("timestamp"); + let snapshot = RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 72.5, + window_minutes: Some(300), + resets_at: Some(reset_at_from(&captured_at, 600)), + }), + secondary: None, + }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display)); From 98c6dfa53711c499a989309c30b0fe38bfa54590 Mon Sep 17 00:00:00 2001 From: MomentDerek <40252940+MomentDerek@users.noreply.github.com> Date: Sat, 18 Oct 2025 10:03:36 +0800 Subject: [PATCH 017/287] fix: diff_buffers clear-to-end when deleting wide graphemes (#4921) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #4870 #4717 #3260 #4431 #2718 #4898 #5036 - Fix the chat composer “phantom space” bug that appeared when backspacing CJK (and other double-width) characters after the composer got a uniform background in 43b63ccae89c…. - Pull diff_buffers’s clear-to-end logic forward to iterate by display width, so wide graphemes are counted correctly when computing the trailing column. - Keep modifier-aware detection so styled cells are still flushed, and add a regression test (diff_buffers_clear_to_end_starts_after_wide_char) that covers the CJK deletion scenario. --------- Co-authored-by: Josh McKinney --- codex-rs/tui/src/custom_terminal.rs | 58 +++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 16 deletions(-) diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index bbd8900632..bd37fe8155 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -411,28 +411,35 @@ fn diff_buffers<'a>(a: &'a Buffer, b: &'a Buffer) -> Vec> { let next_buffer = &b.content; let mut updates = vec![]; - let mut last_nonblank_column = vec![0; a.area.height as usize]; + let mut last_nonblank_columns = vec![0; a.area.height as usize]; for y in 0..a.area.height { let row_start = y as usize * a.area.width as usize; let row_end = row_start + a.area.width as usize; let row = &next_buffer[row_start..row_end]; let bg = row.last().map(|cell| cell.bg).unwrap_or(Color::Reset); - let x = row - .iter() - .rposition(|cell| { - cell.symbol() != " " || cell.bg != bg || cell.modifier != Modifier::empty() - }) - .unwrap_or(0); - last_nonblank_column[y as usize] = x as u16; - if x < (a.area.width as usize).saturating_sub(1) { - let (x_abs, y_abs) = a.pos_of(row_start + x + 1); - updates.push(DrawCommand::ClearToEnd { - x: x_abs, - y: y_abs, - bg, - }); + // Scan the row to find the rightmost column that still matters: any non-space glyph, + // any cell whose bg differs from the row’s trailing bg, or any cell with modifiers. + // Multi-width glyphs extend that region through their full displayed width. + // After that point the rest of the row can be cleared with a single ClearToEnd, a perf win + // versus emitting multiple space Put commands. + let mut last_nonblank_column = 0usize; + let mut column = 0usize; + while column < row.len() { + let cell = &row[column]; + let width = cell.symbol().width(); + if cell.symbol() != " " || cell.bg != bg || cell.modifier != Modifier::empty() { + last_nonblank_column = column + (width.saturating_sub(1)); + } + column += width.max(1); // treat zero-width symbols as width 1 } + + if last_nonblank_column + 1 < row.len() { + let (x, y) = a.pos_of(row_start + last_nonblank_column + 1); + updates.push(DrawCommand::ClearToEnd { x, y, bg }); + } + + last_nonblank_columns[y as usize] = last_nonblank_column as u16; } // Cells invalidated by drawing/replacing preceding multi-width characters: @@ -444,7 +451,7 @@ fn diff_buffers<'a>(a: &'a Buffer, b: &'a Buffer) -> Vec> { if !current.skip && (current != previous || invalidated > 0) && to_skip == 0 { let (x, y) = a.pos_of(i); let row = i / a.area.width as usize; - if x <= last_nonblank_column[row] { + if x <= last_nonblank_columns[row] { updates.push(DrawCommand::Put { x, y, @@ -592,6 +599,7 @@ mod tests { use super::*; use pretty_assertions::assert_eq; use ratatui::layout::Rect; + use ratatui::style::Style; #[test] fn diff_buffers_does_not_emit_clear_to_end_for_full_width_row() { @@ -620,4 +628,22 @@ mod tests { "expected diff_buffers to update the final cell; commands: {commands:?}", ); } + + #[test] + fn diff_buffers_clear_to_end_starts_after_wide_char() { + let area = Rect::new(0, 0, 10, 1); + let mut previous = Buffer::empty(area); + let mut next = Buffer::empty(area); + + previous.set_string(0, 0, "中文", Style::default()); + next.set_string(0, 0, "中", Style::default()); + + let commands = diff_buffers(&previous, &next); + assert!( + commands + .iter() + .any(|command| matches!(command, DrawCommand::ClearToEnd { x: 2, y: 0, .. })), + "expected clear-to-end to start after the remaining wide char; commands: {commands:?}" + ); + } } From a182c1315cdf9fcfc83187addd96a8d596285d09 Mon Sep 17 00:00:00 2001 From: Truls Borgvall <51020190+truls27a@users.noreply.github.com> Date: Sat, 18 Oct 2025 20:02:22 +0200 Subject: [PATCH 018/287] docs(changelog): update install command to @openai/codex@ (#2073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # What Updates the install command in the changelog template (`cliff.toml`) from ``` npm install -g codex@version ``` to ``` npm install -g @openai/codex@ ``` # Why The current command is incorrect, it tries installs the old “codex” static site generator rather than the OpenAI Codex CLI. # How Edited only the header string in `cliff.toml` to point to @openai/codex@. No changelog regeneration or other files touched. Fixes #2059 Co-authored-by: Thibault Sottiaux --- cliff.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cliff.toml b/cliff.toml index 30aa1f1ac6..f31e1bd89c 100644 --- a/cliff.toml +++ b/cliff.toml @@ -4,7 +4,7 @@ header = """ # Changelog -You can install any of these versions: `npm install -g codex@version` +You can install any of these versions: `npm install -g @openai/codex@` """ body = """ From 11c019d6c5de998022330f29ba040642174c0a98 Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Sat, 18 Oct 2025 11:55:24 -0700 Subject: [PATCH 019/287] fix: handle missing resume session id gracefully (#5329) Exit when a requested resume session is missing after restoring the terminal and print a helpful message instructing users how to resume existing sessions. Partially addresses #5247. --- codex-rs/tui/src/lib.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index aa40b05c55..3cae393982 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -437,7 +437,20 @@ async fn run_ratatui_app( Some(path) => resume_picker::ResumeSelection::Resume(path), None => { error!("Error finding conversation path: {id_str}"); - resume_picker::ResumeSelection::StartFresh + restore(); + session_log::log_session_end(); + let _ = tui.terminal.clear(); + if let Err(err) = writeln!( + std::io::stdout(), + "No saved session found with ID {id_str}. Run `codex resume` without an ID to choose from existing sessions." + ) { + error!("Failed to write resume error message: {err}"); + } + return Ok(AppExitInfo { + token_usage: codex_core::protocol::TokenUsage::default(), + conversation_id: None, + update_action: None, + }); } } } else if cli.resume_last { From c81e1477ae6f07fcdc7e81d3316e1692c460dadb Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Sat, 18 Oct 2025 15:58:31 -0700 Subject: [PATCH 020/287] fix: improve custom prompt documentation and actually use prompt descriptions (#5332) Expand the custom prompts documentation and link it from other guides. Show saved prompt metadata in the slash-command popup, with tests covering description fallbacks. --- README.md | 1 + codex-rs/tui/src/bottom_pane/command_popup.rs | 47 ++++++++++-- docs/getting-started.md | 2 + docs/prompts.md | 71 ++++++++++++++----- 4 files changed, 100 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index c9cc4e1fd4..3a78f96573 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ Codex CLI supports a rich set of configuration options, with preferences stored - [CLI usage](./docs/getting-started.md#cli-usage) - [Running with a prompt as input](./docs/getting-started.md#running-with-a-prompt-as-input) - [Example prompts](./docs/getting-started.md#example-prompts) + - [Custom prompts](./docs/prompts.md) - [Memory with AGENTS.md](./docs/getting-started.md#memory-with-agentsmd) - [Configuration](./docs/config.md) - [**Sandbox & approvals**](./docs/sandbox.md) diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 1842a999e3..d7501cebbc 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -164,10 +164,17 @@ impl CommandPopup { CommandItem::Builtin(cmd) => { (format!("/{}", cmd.command()), cmd.description().to_string()) } - CommandItem::UserPrompt(i) => ( - format!("/{PROMPTS_CMD_PREFIX}:{}", self.prompts[i].name), - "send saved prompt".to_string(), - ), + CommandItem::UserPrompt(i) => { + let prompt = &self.prompts[i]; + let description = prompt + .description + .clone() + .unwrap_or_else(|| "send saved prompt".to_string()); + ( + format!("/{PROMPTS_CMD_PREFIX}:{}", prompt.name), + description, + ) + } }; GenericDisplayRow { name, @@ -221,6 +228,7 @@ impl WidgetRef for CommandPopup { #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; #[test] fn filter_includes_init_when_typing_prefix() { @@ -322,4 +330,35 @@ mod tests { "prompt with builtin name should be ignored" ); } + + #[test] + fn prompt_description_uses_frontmatter_metadata() { + let popup = CommandPopup::new(vec![CustomPrompt { + name: "draftpr".to_string(), + path: "/tmp/draftpr.md".to_string().into(), + content: "body".to_string(), + description: Some("Create feature branch, commit and open draft PR.".to_string()), + argument_hint: None, + }]); + let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); + let description = rows.first().and_then(|row| row.description.as_deref()); + assert_eq!( + description, + Some("Create feature branch, commit and open draft PR.") + ); + } + + #[test] + fn prompt_description_falls_back_when_missing() { + let popup = CommandPopup::new(vec![CustomPrompt { + name: "foo".to_string(), + path: "/tmp/foo.md".to_string().into(), + content: "body".to_string(), + description: None, + argument_hint: None, + }]); + let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); + let description = rows.first().and_then(|row| row.description.as_deref()); + assert_eq!(description, Some("send saved prompt")); + } } diff --git a/docs/getting-started.md b/docs/getting-started.md index e97de6a048..6df93b5817 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -59,6 +59,8 @@ Below are a few bite-size examples you can copy-paste. Replace the text in quote | 6 | `codex "Carefully review this repo, and propose 3 high impact well-scoped PRs"` | Suggests impactful PRs in the current codebase. | | 7 | `codex "Look for vulnerabilities and create a security review report"` | Finds and explains security bugs. | +Looking to reuse your own instructions? Create slash commands with [custom prompts](./prompts.md). + ### Memory with AGENTS.md You can give Codex extra instructions and guidance using `AGENTS.md` files. Codex looks for `AGENTS.md` files in the following places, and merges them top-down: diff --git a/docs/prompts.md b/docs/prompts.md index 5157c4ecea..2e6c9fc1e6 100644 --- a/docs/prompts.md +++ b/docs/prompts.md @@ -1,20 +1,57 @@ ## Custom Prompts -Save frequently used prompts as Markdown files and reuse them quickly from the slash menu. +Custom prompts turn your repeatable instructions into reusable slash commands, so you can trigger them without retyping or copy/pasting. Each prompt is a Markdown file that Codex expands into the conversation the moment you run it. -- Location: Put files in `$CODEX_HOME/prompts/` (defaults to `~/.codex/prompts/`). -- File type: Only Markdown files with the `.md` extension are recognized. -- Name: The filename without the `.md` extension becomes the slash entry. For a file named `my-prompt.md`, type `/my-prompt`. -- Content: The file contents are sent as your message when you select the item in the slash popup and press Enter. -- Arguments: Local prompts support placeholders in their content: - - `$1..$9` expand to the first nine positional arguments typed after the slash name - - `$ARGUMENTS` expands to all arguments joined by a single space - - `$$` is preserved literally - - Quoted args: Wrap a single argument in double quotes to include spaces, e.g. `/review "docs/My File.md"`. -- How to use: - - Start a new session (Codex loads custom prompts on session start). - - In the composer, type `/` to open the slash popup and begin typing your prompt name. - - Use Up/Down to select it. Press Enter to submit its contents, or Tab to autocomplete the name. -- Notes: - - Files with names that collide with built‑in commands (e.g. `/init`) are ignored and won’t appear. - - New or changed files are discovered on session start. If you add a new prompt while Codex is running, start a new session to pick it up. +### Where prompts live + +- Location: store prompts in `$CODEX_HOME/prompts/` (defaults to `~/.codex/prompts/`). Set `CODEX_HOME` if you want to use a different folder. +- File type: Codex only loads `.md` files. Non-Markdown files are ignored. +- Naming: The filename (without `.md`) becomes the prompt name. A file called `review.md` registers the prompt `review`. +- Refresh: Prompts are loaded when a session starts. Restart Codex (or start a new session) after adding or editing files. +- Conflicts: Files whose names collide with built-in commands (like `init`) are skipped. + +### File format + +- Body: The file contents are sent verbatim when you run the prompt (after placeholder expansion). +- Frontmatter (optional): Add YAML-style metadata at the top of the file to improve the slash popup. + + ```markdown + --- + description: Request a concise git diff review + argument-hint: FILE= [FOCUS=
] + --- + ``` + + - `description` shows under the entry in the popup. + - `argument-hint` (or `argument_hint`) displays a short hint about expected inputs. + +### Placeholders and arguments + +- Numeric placeholders: `$1`–`$9` insert the first nine positional arguments you type after the command. `$ARGUMENTS` inserts all positional arguments joined by a single space. Use `$$` to emit a literal dollar sign (Codex leaves `$$` untouched). +- Named placeholders: Tokens such as `$FILE` or `$TICKET_ID` expand from `KEY=value` pairs you supply. Keys are case-sensitive—use the same uppercase name in the command (for example, `FILE=...`). +- Quoted arguments: Double-quote any value that contains spaces, e.g. `TICKET_TITLE="Fix logging"`. +- Invocation syntax: Run prompts via `/prompts: ...`. When the slash popup is open, typing either `prompts:` or the bare prompt name will surface `/prompts:` suggestions. +- Error handling: If a prompt contains named placeholders, Codex requires them all. You will see a validation message if any are missing or malformed. + +### Running a prompt + +1. Start a new Codex session (ensures the prompt list is fresh). +2. In the composer, type `/` to open the slash popup. +3. Type `prompts:` (or start typing the prompt name) and select it with ↑/↓. +4. Provide any required arguments, press Enter, and Codex sends the expanded content. + +### Examples + +**Draft PR helper** + +`~/.codex/prompts/draftpr.md` + +```markdown +--- +description: Create feature branch, commit and open draft PR. +--- + +Create a branch named `tibo/`, commit the changes, and open a draft PR. +``` + +Usage: type `/prompts:draftpr` to have codex perform the work. From d6a9e3857521ada1d9f53391a9ff1598f3d7e5b0 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Sat, 18 Oct 2025 17:31:46 -0700 Subject: [PATCH 021/287] Move rust analyzer target dir (#5328) --- .vscode/settings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.vscode/settings.json b/.vscode/settings.json index aadeca0867..5adec04a83 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,6 +3,7 @@ "rust-analyzer.check.command": "clippy", "rust-analyzer.check.extraArgs": ["--all-features", "--tests"], "rust-analyzer.rustfmt.extraArgs": ["--config", "imports_granularity=Item"], + "rust-analyzer.cargo.targetDir": "${workspaceFolder}/codex-rs/target/rust-analyzer", "[rust]": { "editor.defaultFormatter": "rust-lang.rust-analyzer", "editor.formatOnSave": true, From 2287d2afde11ce6f64bc0f9709b51698974b8b74 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Sat, 18 Oct 2025 17:43:08 -0700 Subject: [PATCH 022/287] Create independent TurnContexts (#5308) The goal of this change: 1. Unify user input and user turn implementation. 2. Have a single place where turn/session setting overrides are applied. 3. Have a single place where turn context is created. 4. Create TurnContext only for actual turn and have a separate structure for current session settings (reuse ConfigureSession) --- codex-rs/core/src/codex.rs | 586 ++++++++++---------- codex-rs/core/src/environment_context.rs | 19 + codex-rs/core/src/state/service.rs | 6 + codex-rs/core/src/state/session.rs | 9 +- codex-rs/core/tests/suite/prompt_caching.rs | 11 +- 5 files changed, 320 insertions(+), 311 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 2eb80cfaa6..2ce0997075 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -107,6 +107,7 @@ use crate::rollout::RolloutRecorderParams; use crate::shell; use crate::state::ActiveTurn; use crate::state::SessionServices; +use crate::state::SessionState; use crate::state::TaskKind; use crate::tasks::CompactTask; use crate::tasks::RegularTask; @@ -169,7 +170,7 @@ impl Codex { let config = Arc::new(config); - let configure_session = ConfigureSession { + let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), model: config.model.clone(), model_reasoning_effort: config.model_reasoning_effort, @@ -178,13 +179,13 @@ impl Codex { base_instructions: config.base_instructions.clone(), approval_policy: config.approval_policy, sandbox_policy: config.sandbox_policy.clone(), - notify: UserNotifier::new(config.notify.clone()), cwd: config.cwd.clone(), + original_config_do_not_use: Arc::clone(&config), }; // Generate a unique ID for the lifetime of this Codex session. - let (session, turn_context) = Session::new( - configure_session, + let session = Session::new( + session_configuration, config.clone(), auth_manager.clone(), tx_event.clone(), @@ -199,7 +200,7 @@ impl Codex { let conversation_id = session.conversation_id; // This task will run until Op::Shutdown is received. - tokio::spawn(submission_loop(session, turn_context, config, rx_sub)); + tokio::spawn(submission_loop(session, config, rx_sub)); let codex = Codex { next_id: AtomicU64::new(0), tx_sub, @@ -243,8 +244,6 @@ impl Codex { } } -use crate::state::SessionState; - /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -283,8 +282,8 @@ impl TurnContext { } } -/// Configure the model session. -struct ConfigureSession { +#[derive(Clone)] +pub(crate) struct SessionConfiguration { /// Provider identifier ("openai", "openrouter", ...). provider: ModelProviderInfo, @@ -305,8 +304,6 @@ struct ConfigureSession { /// How to sandbox commands executed in the system sandbox_policy: SandboxPolicy, - notify: UserNotifier, - /// Working directory that should be treated as the *root* of the /// session. All relative paths supplied by the model as well as the /// execution sandbox are resolved against this directory **instead** @@ -315,32 +312,118 @@ struct ConfigureSession { /// `ConfigureSession` operation so that the business-logic layer can /// operate deterministically. cwd: PathBuf, + + // TODO(pakrym): Remove config from here + original_config_do_not_use: Arc, +} + +impl SessionConfiguration { + pub(crate) fn apply(&self, updates: &SessionSettingsUpdate) -> Self { + let mut next_configuration = self.clone(); + if let Some(model) = updates.model.clone() { + next_configuration.model = model; + } + if let Some(effort) = updates.reasoning_effort { + next_configuration.model_reasoning_effort = effort; + } + if let Some(summary) = updates.reasoning_summary { + next_configuration.model_reasoning_summary = summary; + } + if let Some(approval_policy) = updates.approval_policy { + next_configuration.approval_policy = approval_policy; + } + if let Some(sandbox_policy) = updates.sandbox_policy.clone() { + next_configuration.sandbox_policy = sandbox_policy; + } + if let Some(cwd) = updates.cwd.clone() { + next_configuration.cwd = cwd; + } + next_configuration + } +} + +#[derive(Default, Clone)] +pub(crate) struct SessionSettingsUpdate { + pub(crate) cwd: Option, + pub(crate) approval_policy: Option, + pub(crate) sandbox_policy: Option, + pub(crate) model: Option, + pub(crate) reasoning_effort: Option>, + pub(crate) reasoning_summary: Option, + pub(crate) final_output_json_schema: Option>, } impl Session { + fn make_turn_context( + auth_manager: Option>, + otel_event_manager: &OtelEventManager, + provider: ModelProviderInfo, + session_configuration: &SessionConfiguration, + conversation_id: ConversationId, + ) -> TurnContext { + let config = session_configuration.original_config_do_not_use.clone(); + let model_family = find_family_for_model(&session_configuration.model) + .unwrap_or_else(|| config.model_family.clone()); + let mut per_turn_config = (*config).clone(); + per_turn_config.model = session_configuration.model.clone(); + per_turn_config.model_family = model_family.clone(); + per_turn_config.model_reasoning_effort = session_configuration.model_reasoning_effort; + per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; + if let Some(model_info) = get_model_info(&model_family) { + per_turn_config.model_context_window = Some(model_info.context_window); + } + + let otel_event_manager = otel_event_manager.clone().with_model( + session_configuration.model.as_str(), + session_configuration.model.as_str(), + ); + + let client = ModelClient::new( + Arc::new(per_turn_config), + auth_manager, + otel_event_manager, + provider, + session_configuration.model_reasoning_effort, + session_configuration.model_reasoning_summary, + conversation_id, + ); + + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_family: &model_family, + features: &config.features, + }); + + TurnContext { + client, + cwd: session_configuration.cwd.clone(), + base_instructions: session_configuration.base_instructions.clone(), + user_instructions: session_configuration.user_instructions.clone(), + approval_policy: session_configuration.approval_policy, + sandbox_policy: session_configuration.sandbox_policy.clone(), + shell_environment_policy: config.shell_environment_policy.clone(), + tools_config, + is_review_mode: false, + final_output_json_schema: None, + } + } + async fn new( - configure_session: ConfigureSession, + session_configuration: SessionConfiguration, config: Arc, auth_manager: Arc, tx_event: Sender, initial_history: InitialHistory, session_source: SessionSource, - ) -> anyhow::Result<(Arc, TurnContext)> { - let ConfigureSession { - provider, - model, - model_reasoning_effort, - model_reasoning_summary, - user_instructions, - base_instructions, - approval_policy, - sandbox_policy, - notify, - cwd, - } = configure_session; - debug!("Configuring session: model={model}; provider={provider:?}"); - if !cwd.is_absolute() { - return Err(anyhow::anyhow!("cwd is not absolute: {cwd:?}")); + ) -> anyhow::Result> { + debug!( + "Configuring session: model={}; provider={:?}", + session_configuration.model, session_configuration.provider + ); + if !session_configuration.cwd.is_absolute() { + return Err(anyhow::anyhow!( + "cwd is not absolute: {:?}", + session_configuration.cwd + )); } let (conversation_id, rollout_params) = match &initial_history { @@ -350,7 +433,7 @@ impl Session { conversation_id, RolloutRecorderParams::new( conversation_id, - user_instructions.clone(), + session_configuration.user_instructions.clone(), session_source, ), ) @@ -406,8 +489,6 @@ impl Session { anyhow::anyhow!("failed to initialize rollout recorder: {e:#}") })?; let rollout_path = rollout_recorder.rollout_path.clone(); - // Create the mutable state for the Session. - let state = SessionState::new(); // Handle MCP manager result and record any startup failures. let (mcp_connection_manager, failed_clients) = match mcp_res { @@ -475,45 +556,24 @@ impl Session { config.active_profile.clone(), ); - // Now that the conversation id is final (may have been updated by resume), - // construct the model client. - let client = ModelClient::new( - config.clone(), - Some(auth_manager.clone()), - otel_event_manager, - provider.clone(), - model_reasoning_effort, - model_reasoning_summary, - conversation_id, - ); - let turn_context = TurnContext { - client, - tools_config: ToolsConfig::new(&ToolsConfigParams { - model_family: &config.model_family, - features: &config.features, - }), - user_instructions, - base_instructions, - approval_policy, - sandbox_policy, - shell_environment_policy: config.shell_environment_policy.clone(), - cwd, - is_review_mode: false, - final_output_json_schema: None, - }; + // Create the mutable state for the Session. + let state = SessionState::new(session_configuration.clone()); + let services = SessionServices { mcp_connection_manager, session_manager: ExecSessionManager::default(), unified_exec_manager: UnifiedExecSessionManager::default(), - notifier: notify, + notifier: UserNotifier::new(config.notify.clone()), rollout: Mutex::new(Some(rollout_recorder)), user_shell: default_shell, show_raw_agent_reasoning: config.show_raw_agent_reasoning, executor: Executor::new(ExecutorConfig::new( - turn_context.sandbox_policy.clone(), - turn_context.cwd.clone(), + session_configuration.sandbox_policy.clone(), + session_configuration.cwd.clone(), config.codex_linux_sandbox_exe.clone(), )), + auth_manager: Arc::clone(&auth_manager), + otel_event_manager, }; let sess = Arc::new(Session { @@ -528,15 +588,14 @@ impl Session { // Dispatch the SessionConfiguredEvent first and then report any errors. // If resuming, include converted initial messages in the payload so UIs can render them immediately. let initial_messages = initial_history.get_event_msgs(); - sess.record_initial_history(&turn_context, initial_history) - .await; + sess.record_initial_history(initial_history).await; let events = std::iter::once(Event { id: INITIAL_SUBMIT_ID.to_owned(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, - model, - reasoning_effort: model_reasoning_effort, + model: session_configuration.model.clone(), + reasoning_effort: session_configuration.model_reasoning_effort, history_log_id, history_entry_count, initial_messages, @@ -548,7 +607,7 @@ impl Session { sess.send_event(event).await; } - Ok((sess, turn_context)) + Ok(sess) } pub(crate) fn get_tx_event(&self) -> Sender { @@ -562,15 +621,12 @@ impl Session { format!("auto-compact-{id}") } - async fn record_initial_history( - &self, - turn_context: &TurnContext, - conversation_history: InitialHistory, - ) { + async fn record_initial_history(&self, conversation_history: InitialHistory) { + let turn_context = self.new_turn(SessionSettingsUpdate::default()).await; match conversation_history { InitialHistory::New => { // Build and record initial items (user instructions + environment context) - let items = self.build_initial_context(turn_context); + let items = self.build_initial_context(&turn_context); self.record_conversation_items(&items).await; } InitialHistory::Resumed(_) | InitialHistory::Forked(_) => { @@ -579,7 +635,7 @@ impl Session { // Always add response items to conversation history let reconstructed_history = - self.reconstruct_history_from_rollout(turn_context, &rollout_items); + self.reconstruct_history_from_rollout(&turn_context, &rollout_items); if !reconstructed_history.is_empty() { self.record_into_history(&reconstructed_history).await; } @@ -592,6 +648,52 @@ impl Session { } } + pub(crate) async fn update_settings(&self, updates: SessionSettingsUpdate) { + let mut state = self.state.lock().await; + + state.session_configuration = state.session_configuration.apply(&updates); + } + + pub(crate) async fn new_turn(&self, updates: SessionSettingsUpdate) -> Arc { + let current_configuration = self.state.lock().await.session_configuration.clone(); + let session_configuration = current_configuration.apply(&updates); + + self.services.executor.update_environment( + session_configuration.sandbox_policy.clone(), + session_configuration.cwd.clone(), + ); + + let mut turn_context: TurnContext = Self::make_turn_context( + Some(Arc::clone(&self.services.auth_manager)), + &self.services.otel_event_manager, + session_configuration.provider.clone(), + &session_configuration, + self.conversation_id, + ); + if let Some(final_schema) = updates.final_output_json_schema { + turn_context.final_output_json_schema = final_schema; + } + Arc::new(turn_context) + } + + fn build_environment_update_item( + &self, + previous: Option<&Arc>, + next: &TurnContext, + ) -> Option { + let prev = previous?; + + let prev_context = EnvironmentContext::from(prev.as_ref()); + let next_context = EnvironmentContext::from(next); + if prev_context.equals_except_shell(&next_context) { + return None; + } + Some(ResponseItem::from(EnvironmentContext::diff( + prev.as_ref(), + next, + ))) + } + /// Persist the event to rollout and send it to clients. pub(crate) async fn send_event(&self, event: Event) { // Persist the event into rollout (recorder filters as needed) @@ -1188,14 +1290,8 @@ impl Session { } } -async fn submission_loop( - sess: Arc, - turn_context: TurnContext, - config: Arc, - rx_sub: Receiver, -) { - // Wrap once to avoid cloning TurnContext for each task. - let mut turn_context = Arc::new(turn_context); +async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiver) { + let mut previous_context: Option> = None; // To break out of this loop, send Op::Shutdown. while let Ok(sub) = rx_sub.recv().await { debug!(?sub, "Submission"); @@ -1211,174 +1307,58 @@ async fn submission_loop( effort, summary, } => { - // Recalculate the persistent turn context with provided overrides. - let prev = Arc::clone(&turn_context); - let provider = prev.client.get_provider(); - - // Effective model + family - let (effective_model, effective_family) = if let Some(ref m) = model { - let fam = - find_family_for_model(m).unwrap_or_else(|| config.model_family.clone()); - (m.clone(), fam) - } else { - (prev.client.get_model(), prev.client.get_model_family()) + let updates = SessionSettingsUpdate { + cwd, + approval_policy, + sandbox_policy, + model, + reasoning_effort: effort, + reasoning_summary: summary, + ..Default::default() }; + sess.update_settings(updates).await; + } - // Effective reasoning settings - let effective_effort = effort.unwrap_or(prev.client.get_reasoning_effort()); - let effective_summary = summary.unwrap_or(prev.client.get_reasoning_summary()); - - let auth_manager = prev.client.get_auth_manager(); - - // Build updated config for the client - let mut updated_config = (*config).clone(); - updated_config.model = effective_model.clone(); - updated_config.model_family = effective_family.clone(); - if let Some(model_info) = get_model_info(&effective_family) { - updated_config.model_context_window = Some(model_info.context_window); - } - - let otel_event_manager = prev.client.get_otel_event_manager().with_model( - updated_config.model.as_str(), - updated_config.model_family.slug.as_str(), - ); - - let client = ModelClient::new( - Arc::new(updated_config), - auth_manager, - otel_event_manager, - provider, - effective_effort, - effective_summary, - sess.conversation_id, - ); - - let new_approval_policy = approval_policy.unwrap_or(prev.approval_policy); - let new_sandbox_policy = sandbox_policy - .clone() - .unwrap_or(prev.sandbox_policy.clone()); - let new_cwd = cwd.clone().unwrap_or_else(|| prev.cwd.clone()); - - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_family: &effective_family, - features: &config.features, - }); - - let new_turn_context = TurnContext { - client, - tools_config, - user_instructions: prev.user_instructions.clone(), - base_instructions: prev.base_instructions.clone(), - approval_policy: new_approval_policy, - sandbox_policy: new_sandbox_policy.clone(), - shell_environment_policy: prev.shell_environment_policy.clone(), - cwd: new_cwd.clone(), - is_review_mode: false, - final_output_json_schema: None, - }; - - // Install the new persistent context for subsequent tasks/turns. - turn_context = Arc::new(new_turn_context); - - // Optionally persist changes to model / effort - if cwd.is_some() || approval_policy.is_some() || sandbox_policy.is_some() { - sess.record_conversation_items(&[ResponseItem::from(EnvironmentContext::new( + Op::UserInput { .. } | Op::UserTurn { .. } => { + let (items, updates) = match sub.op { + Op::UserTurn { cwd, approval_policy, sandbox_policy, - // Shell is not configurable from turn to turn - None, - ))]) - .await; - } - } - Op::UserInput { items } => { - turn_context - .client - .get_otel_event_manager() - .user_prompt(&items); - // attempt to inject input into current task - if let Err(items) = sess.inject_input(items).await { - // no current task, spawn a new one - sess.spawn_task(Arc::clone(&turn_context), sub.id, items, RegularTask) - .await; - } - } - Op::UserTurn { - items, - cwd, - approval_policy, - sandbox_policy, - model, - effort, - summary, - final_output_json_schema, - } => { - turn_context - .client - .get_otel_event_manager() - .user_prompt(&items); - // attempt to inject input into current task - if let Err(items) = sess.inject_input(items).await { - // Derive a fresh TurnContext for this turn using the provided overrides. - let provider = turn_context.client.get_provider(); - let auth_manager = turn_context.client.get_auth_manager(); - - // Derive a model family for the requested model; fall back to the session's. - let model_family = find_family_for_model(&model) - .unwrap_or_else(|| config.model_family.clone()); - - // Create a per‑turn Config clone with the requested model/family. - let mut per_turn_config = (*config).clone(); - per_turn_config.model = model.clone(); - per_turn_config.model_family = model_family.clone(); - if let Some(model_info) = get_model_info(&model_family) { - per_turn_config.model_context_window = Some(model_info.context_window); - } - - let otel_event_manager = - turn_context.client.get_otel_event_manager().with_model( - per_turn_config.model.as_str(), - per_turn_config.model_family.slug.as_str(), - ); - - // Build a new client with per‑turn reasoning settings. - // Reuse the same provider and session id; auth defaults to env/API key. - let client = ModelClient::new( - Arc::new(per_turn_config), - auth_manager, - otel_event_manager, - provider, + model, effort, summary, - sess.conversation_id, - ); - - let fresh_turn_context = TurnContext { - client, - tools_config: ToolsConfig::new(&ToolsConfigParams { - model_family: &model_family, - features: &config.features, - }), - user_instructions: turn_context.user_instructions.clone(), - base_instructions: turn_context.base_instructions.clone(), - approval_policy, - sandbox_policy, - shell_environment_policy: turn_context.shell_environment_policy.clone(), - cwd, - is_review_mode: false, final_output_json_schema, - }; - - // if the environment context has changed, record it in the conversation history - let previous_env_context = EnvironmentContext::from(turn_context.as_ref()); - let new_env_context = EnvironmentContext::from(&fresh_turn_context); - if !new_env_context.equals_except_shell(&previous_env_context) { - let env_response_item = ResponseItem::from(new_env_context); - sess.record_conversation_items(std::slice::from_ref(&env_response_item)) + items, + } => ( + items, + SessionSettingsUpdate { + cwd: Some(cwd), + approval_policy: Some(approval_policy), + sandbox_policy: Some(sandbox_policy), + model: Some(model), + reasoning_effort: Some(effort), + reasoning_summary: Some(summary), + final_output_json_schema: Some(final_output_json_schema), + }, + ), + Op::UserInput { items } => (items, SessionSettingsUpdate::default()), + _ => unreachable!(), + }; + let current_context = sess.new_turn(updates).await; + current_context + .client + .get_otel_event_manager() + .user_prompt(&items); + // attempt to inject input into current task + if let Err(items) = sess.inject_input(items).await { + if let Some(env_item) = sess + .build_environment_update_item(previous_context.as_ref(), ¤t_context) + { + sess.record_conversation_items(std::slice::from_ref(&env_item)) .await; for msg in map_response_item_to_event_messages( - &env_response_item, + &env_item, sess.show_raw_agent_reasoning(), ) { let event = Event { @@ -1389,12 +1369,9 @@ async fn submission_loop( } } - // Install the new persistent context for subsequent tasks/turns. - turn_context = Arc::new(fresh_turn_context); - - // no current task, spawn a new one with the per-turn context - sess.spawn_task(Arc::clone(&turn_context), sub.id, items, RegularTask) + sess.spawn_task(Arc::clone(¤t_context), sub.id, items, RegularTask) .await; + previous_context = Some(current_context); } } Op::ExecApproval { id, decision } => match decision { @@ -1500,6 +1477,7 @@ async fn submission_loop( sess.send_event(event).await; } Op::Compact => { + let turn_context = sess.new_turn(SessionSettingsUpdate::default()).await; // Attempt to inject input into current task if let Err(items) = sess .inject_input(vec![InputItem::Text { @@ -1569,6 +1547,7 @@ async fn submission_loop( sess.send_event(event).await; } Op::Review { review_request } => { + let turn_context = sess.new_turn(SessionSettingsUpdate::default()).await; spawn_review_thread( sess.clone(), config.clone(), @@ -2588,14 +2567,13 @@ mod tests { let (session, turn_context) = make_session_and_context(); let (rollout_items, expected) = sample_rollout(&session, &turn_context); - tokio_test::block_on(session.record_initial_history( - &turn_context, - InitialHistory::Resumed(ResumedHistory { + tokio_test::block_on(session.record_initial_history(InitialHistory::Resumed( + ResumedHistory { conversation_id: ConversationId::default(), history: rollout_items, rollout_path: PathBuf::from("/tmp/resume.jsonl"), - }), - )); + }, + ))); let actual = tokio_test::block_on(async { session.state.lock().await.history_snapshot() }); assert_eq!(expected, actual); @@ -2606,9 +2584,7 @@ mod tests { let (session, turn_context) = make_session_and_context(); let (rollout_items, expected) = sample_rollout(&session, &turn_context); - tokio_test::block_on( - session.record_initial_history(&turn_context, InitialHistory::Forked(rollout_items)), - ); + tokio_test::block_on(session.record_initial_history(InitialHistory::Forked(rollout_items))); let actual = tokio_test::block_on(async { session.state.lock().await.history_snapshot() }); assert_eq!(expected, actual); @@ -2832,53 +2808,56 @@ mod tests { let config = Arc::new(config); let conversation_id = ConversationId::default(); let otel_event_manager = otel_event_manager(conversation_id, config.as_ref()); - let client = ModelClient::new( - config.clone(), - None, - otel_event_manager, - config.model_provider.clone(), - config.model_reasoning_effort, - config.model_reasoning_summary, - conversation_id, - ); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_family: &config.model_family, - features: &config.features, - }); - let turn_context = TurnContext { - client, - cwd: config.cwd.clone(), - base_instructions: config.base_instructions.clone(), + let auth_manager = AuthManager::shared(config.cwd.clone(), false); + + let session_configuration = SessionConfiguration { + provider: config.model_provider.clone(), + model: config.model.clone(), + model_reasoning_effort: config.model_reasoning_effort, + model_reasoning_summary: config.model_reasoning_summary, user_instructions: config.user_instructions.clone(), + base_instructions: config.base_instructions.clone(), approval_policy: config.approval_policy, sandbox_policy: config.sandbox_policy.clone(), - shell_environment_policy: config.shell_environment_policy.clone(), - tools_config, - is_review_mode: false, - final_output_json_schema: None, + cwd: config.cwd.clone(), + original_config_do_not_use: Arc::clone(&config), }; + + let state = SessionState::new(session_configuration.clone()); + let services = SessionServices { mcp_connection_manager: McpConnectionManager::default(), session_manager: ExecSessionManager::default(), unified_exec_manager: UnifiedExecSessionManager::default(), - notifier: UserNotifier::default(), + notifier: UserNotifier::new(None), rollout: Mutex::new(None), user_shell: shell::Shell::Unknown, show_raw_agent_reasoning: config.show_raw_agent_reasoning, executor: Executor::new(ExecutorConfig::new( - turn_context.sandbox_policy.clone(), - turn_context.cwd.clone(), + session_configuration.sandbox_policy.clone(), + session_configuration.cwd.clone(), None, )), + auth_manager: Arc::clone(&auth_manager), + otel_event_manager: otel_event_manager.clone(), }; + let session = Session { conversation_id, tx_event, - state: Mutex::new(SessionState::new()), + state: Mutex::new(state), active_turn: Mutex::new(None), services, next_internal_sub_id: AtomicU64::new(0), }; + + let turn_context = Session::make_turn_context( + Some(Arc::clone(&auth_manager)), + &otel_event_manager, + session_configuration.provider.clone(), + &session_configuration, + conversation_id, + ); (session, turn_context) } @@ -2900,53 +2879,56 @@ mod tests { let config = Arc::new(config); let conversation_id = ConversationId::default(); let otel_event_manager = otel_event_manager(conversation_id, config.as_ref()); - let client = ModelClient::new( - config.clone(), - None, - otel_event_manager, - config.model_provider.clone(), - config.model_reasoning_effort, - config.model_reasoning_summary, - conversation_id, - ); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_family: &config.model_family, - features: &config.features, - }); - let turn_context = Arc::new(TurnContext { - client, - cwd: config.cwd.clone(), - base_instructions: config.base_instructions.clone(), + let auth_manager = AuthManager::shared(config.cwd.clone(), false); + + let session_configuration = SessionConfiguration { + provider: config.model_provider.clone(), + model: config.model.clone(), + model_reasoning_effort: config.model_reasoning_effort, + model_reasoning_summary: config.model_reasoning_summary, user_instructions: config.user_instructions.clone(), + base_instructions: config.base_instructions.clone(), approval_policy: config.approval_policy, sandbox_policy: config.sandbox_policy.clone(), - shell_environment_policy: config.shell_environment_policy.clone(), - tools_config, - is_review_mode: false, - final_output_json_schema: None, - }); + cwd: config.cwd.clone(), + original_config_do_not_use: Arc::clone(&config), + }; + + let state = SessionState::new(session_configuration.clone()); + let services = SessionServices { mcp_connection_manager: McpConnectionManager::default(), session_manager: ExecSessionManager::default(), unified_exec_manager: UnifiedExecSessionManager::default(), - notifier: UserNotifier::default(), + notifier: UserNotifier::new(None), rollout: Mutex::new(None), user_shell: shell::Shell::Unknown, show_raw_agent_reasoning: config.show_raw_agent_reasoning, executor: Executor::new(ExecutorConfig::new( - config.sandbox_policy.clone(), - config.cwd.clone(), + session_configuration.sandbox_policy.clone(), + session_configuration.cwd.clone(), None, )), + auth_manager: Arc::clone(&auth_manager), + otel_event_manager: otel_event_manager.clone(), }; + let session = Arc::new(Session { conversation_id, tx_event, - state: Mutex::new(SessionState::new()), + state: Mutex::new(state), active_turn: Mutex::new(None), services, next_internal_sub_id: AtomicU64::new(0), }); + + let turn_context = Arc::new(Session::make_turn_context( + Some(Arc::clone(&auth_manager)), + &otel_event_manager, + session_configuration.provider.clone(), + &session_configuration, + conversation_id, + )); (session, turn_context, rx_event) } diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index 8f3292a226..e7b2e19ffb 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -93,6 +93,25 @@ impl EnvironmentContext { && self.network_access == *network_access && self.writable_roots == *writable_roots } + + pub fn diff(before: &TurnContext, after: &TurnContext) -> Self { + let cwd = if before.cwd != after.cwd { + Some(after.cwd.clone()) + } else { + None + }; + let approval_policy = if before.approval_policy != after.approval_policy { + Some(after.approval_policy) + } else { + None + }; + let sandbox_policy = if before.sandbox_policy != after.sandbox_policy { + Some(after.sandbox_policy.clone()) + } else { + None + }; + EnvironmentContext::new(cwd, approval_policy, sandbox_policy, None) + } } impl From<&TurnContext> for EnvironmentContext { diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 994352eddf..b18f1b9940 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -1,9 +1,13 @@ +use std::sync::Arc; + +use crate::AuthManager; use crate::RolloutRecorder; use crate::exec_command::ExecSessionManager; use crate::executor::Executor; use crate::mcp_connection_manager::McpConnectionManager; use crate::unified_exec::UnifiedExecSessionManager; use crate::user_notification::UserNotifier; +use codex_otel::otel_event_manager::OtelEventManager; use tokio::sync::Mutex; pub(crate) struct SessionServices { @@ -15,4 +19,6 @@ pub(crate) struct SessionServices { pub(crate) user_shell: crate::shell::Shell, pub(crate) show_raw_agent_reasoning: bool, pub(crate) executor: Executor, + pub(crate) auth_manager: Arc, + pub(crate) otel_event_manager: OtelEventManager, } diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 8310d91c0c..fa7543ff53 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -2,14 +2,15 @@ use codex_protocol::models::ResponseItem; +use crate::codex::SessionConfiguration; use crate::conversation_history::ConversationHistory; use crate::protocol::RateLimitSnapshot; use crate::protocol::TokenUsage; use crate::protocol::TokenUsageInfo; /// Persistent, session-scoped state previously stored directly on `Session`. -#[derive(Default)] pub(crate) struct SessionState { + pub(crate) session_configuration: SessionConfiguration, pub(crate) history: ConversationHistory, pub(crate) token_info: Option, pub(crate) latest_rate_limits: Option, @@ -17,10 +18,12 @@ pub(crate) struct SessionState { impl SessionState { /// Create a new session state mirroring previous `State::default()` semantics. - pub(crate) fn new() -> Self { + pub(crate) fn new(session_configuration: SessionConfiguration) -> Self { Self { + session_configuration, history: ConversationHistory::new(), - ..Default::default() + token_info: None, + latest_rate_limits: None, } } diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index fcfc960224..caa4cb68de 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -507,7 +507,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() { {} "#, - writable.path().to_string_lossy() + writable.path().to_string_lossy(), ); let expected_env_msg_2 = serde_json::json!({ "type": "message", @@ -866,15 +866,14 @@ async fn send_user_turn_with_changes_sends_environment_context() { ]); assert_eq!(body1["input"], expected_input_1); - let expected_env_msg_2 = text_user_input(format!( + let expected_env_msg_2 = text_user_input( r#" - {} never danger-full-access enabled -"#, - default_cwd.to_string_lossy() - )); +"# + .to_string(), + ); let expected_user_message_2 = text_user_input("hello 2".to_string()); let expected_input_2 = serde_json::Value::Array(vec![ expected_ui_msg, From 4f46360aa493b04eae12045ac8c34a4e42441734 Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Sat, 18 Oct 2025 22:13:53 -0700 Subject: [PATCH 023/287] feat: add --add-dir flag for extra writable roots (#5335) Add a `--add-dir` CLI flag so sessions can use extra writable roots in addition to the ones specified in the config file. These are ephemerally added during the session only. Fixes #3303 Fixes #2797 --- codex-rs/README.md | 10 +++ .../app-server/src/codex_message_processor.rs | 1 + codex-rs/cli/src/main.rs | 3 + codex-rs/core/src/config.rs | 67 ++++++++++++++++++- codex-rs/exec/src/lib.rs | 1 + codex-rs/mcp-server/src/codex_tool_config.rs | 1 + codex-rs/tui/src/cli.rs | 5 ++ codex-rs/tui/src/lib.rs | 2 + 8 files changed, 89 insertions(+), 1 deletion(-) diff --git a/codex-rs/README.md b/codex-rs/README.md index a05a8d48b1..7f944d7a0c 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -61,6 +61,16 @@ In the transcript preview, the footer shows an `Esc edit prev` hint while editin Sometimes it is not convenient to `cd` to the directory you want Codex to use as the "working root" before running Codex. Fortunately, `codex` supports a `--cd` option so you can specify whatever folder you want. You can confirm that Codex is honoring `--cd` by double-checking the **workdir** it reports in the TUI at the start of a new session. +### `--add-dir` flag + +Need to work across multiple projects? Pass `--add-dir` one or more times to expose extra directories as writable roots for the current session while keeping the main working directory unchanged. For example: + +```shell +codex --cd apps/frontend --add-dir ../backend --add-dir ../shared +``` + +Codex can now inspect and edit files in each listed directory without leaving the primary workspace. + ### Shell completions Generate shell completion scripts via: diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index f455ad32e1..23f1a37891 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1353,6 +1353,7 @@ async fn derive_config_from_params( include_view_image_tool: None, show_raw_agent_reasoning: None, tools_web_search_request: None, + additional_writable_roots: Vec::new(), }; let cli_overrides = cli_overrides diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 71fee4fd6b..e01bd259a5 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -563,6 +563,9 @@ fn merge_resume_cli_flags(interactive: &mut TuiCli, resume_cli: TuiCli) { if !resume_cli.images.is_empty() { interactive.images = resume_cli.images; } + if !resume_cli.add_dir.is_empty() { + interactive.add_dir.extend(resume_cli.add_dir); + } if let Some(prompt) = resume_cli.prompt { interactive.prompt = Some(prompt); } diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 6f5defcedc..efc5ca3dd4 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -42,6 +42,7 @@ use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::Verbosity; use codex_rmcp_client::OAuthCredentialsStoreMode; use dirs::home_dir; +use dunce::canonicalize; use serde::Deserialize; use similar::DiffableStr; use std::collections::BTreeMap; @@ -1094,6 +1095,8 @@ pub struct ConfigOverrides { pub include_view_image_tool: Option, pub show_raw_agent_reasoning: Option, pub tools_web_search_request: Option, + /// Additional directories that should be treated as writable roots for this session. + pub additional_writable_roots: Vec, } impl Config { @@ -1122,6 +1125,7 @@ impl Config { include_view_image_tool: include_view_image_tool_override, show_raw_agent_reasoning, tools_web_search_request: override_tools_web_search_request, + additional_writable_roots, } = overrides; let active_profile_name = config_profile_key @@ -1169,11 +1173,32 @@ impl Config { } } }; + let additional_writable_roots: Vec = additional_writable_roots + .into_iter() + .map(|path| { + let absolute = if path.is_absolute() { + path + } else { + resolved_cwd.join(path) + }; + match canonicalize(&absolute) { + Ok(canonical) => canonical, + Err(_) => absolute, + } + }) + .collect(); let active_project = cfg .get_active_project(&resolved_cwd) .unwrap_or(ProjectConfig { trust_level: None }); - let sandbox_policy = cfg.derive_sandbox_policy(sandbox_mode, &resolved_cwd); + let mut sandbox_policy = cfg.derive_sandbox_policy(sandbox_mode, &resolved_cwd); + if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut sandbox_policy { + for path in additional_writable_roots { + if !writable_roots.iter().any(|existing| existing == &path) { + writable_roots.push(path); + } + } + } let mut approval_policy = approval_policy_override .or(config_profile.approval_policy) .or(cfg.approval_policy) @@ -1613,6 +1638,46 @@ trust_level = "trusted" ); } + #[test] + fn add_dir_override_extends_workspace_writable_roots() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let frontend = temp_dir.path().join("frontend"); + let backend = temp_dir.path().join("backend"); + std::fs::create_dir_all(&frontend)?; + std::fs::create_dir_all(&backend)?; + + let overrides = ConfigOverrides { + cwd: Some(frontend), + sandbox_mode: Some(SandboxMode::WorkspaceWrite), + additional_writable_roots: vec![PathBuf::from("../backend"), backend.clone()], + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + overrides, + temp_dir.path().to_path_buf(), + )?; + + let expected_backend = canonicalize(&backend).expect("canonicalize backend directory"); + match config.sandbox_policy { + SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + assert_eq!( + writable_roots + .iter() + .filter(|root| **root == expected_backend) + .count(), + 1, + "expected single writable root entry for {}", + expected_backend.display() + ); + } + other => panic!("expected workspace-write policy, got {other:?}"), + } + + Ok(()) + } + #[test] fn approve_all_feature_forces_on_request_policy() -> std::io::Result<()> { let cfg = r#" diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 144e7cabb0..efd2ff54e7 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -181,6 +181,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any include_view_image_tool: None, show_raw_agent_reasoning: oss.then_some(true), tools_web_search_request: None, + additional_writable_roots: Vec::new(), }; // Parse `-c` overrides. let cli_kv_overrides = match config_overrides.parse_overrides() { diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index 3cd2cccdce..5b5242f2be 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -164,6 +164,7 @@ impl CodexToolCallParam { include_view_image_tool: None, show_raw_agent_reasoning: None, tools_web_search_request: None, + additional_writable_roots: Vec::new(), }; let cli_overrides = cli_overrides diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index ee2a0355da..d86040b5c9 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -1,4 +1,5 @@ use clap::Parser; +use clap::ValueHint; use codex_common::ApprovalModeCliArg; use codex_common::CliConfigOverrides; use std::path::PathBuf; @@ -72,6 +73,10 @@ pub struct Cli { #[arg(long = "search", default_value_t = false)] pub web_search: bool, + /// Additional directories that should be writable alongside the primary workspace. + #[arg(long = "add-dir", value_name = "DIR", value_hint = ValueHint::DirPath)] + pub add_dir: Vec, + #[clap(skip)] pub config_overrides: CliConfigOverrides, } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 3cae393982..1753f311ac 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -161,6 +161,7 @@ pub async fn run_main( // canonicalize the cwd let cwd = cli.cwd.clone().map(|p| p.canonicalize().unwrap_or(p)); + let additional_dirs = cli.add_dir.clone(); let overrides = ConfigOverrides { model, @@ -177,6 +178,7 @@ pub async fn run_main( include_view_image_tool: None, show_raw_agent_reasoning: cli.oss.then_some(true), tools_web_search_request: cli.web_search.then_some(true), + additional_writable_roots: additional_dirs, }; let raw_overrides = cli.config_overrides.raw_overrides.clone(); let overrides_cli = codex_common::CliConfigOverrides { raw_overrides }; From 1d9b27387bc61a67d7f7caf0b153537565f31abc Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Sun, 19 Oct 2025 14:07:20 -0700 Subject: [PATCH 024/287] docs: add AGENTS.md discovery guide (#5353) --- docs/agents_md.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 docs/agents_md.md diff --git a/docs/agents_md.md b/docs/agents_md.md new file mode 100644 index 0000000000..e3949d6b1a --- /dev/null +++ b/docs/agents_md.md @@ -0,0 +1,51 @@ +# AGENTS.md Discovery + +Codex uses [`AGENTS.md`](https://agents.md/) files to gather helpful guidance before it starts assisting you. This page explains how those files are discovered and combined, so you can decide where to place your instructions. + +## Global Instructions (`~/.codex`) + +- Codex looks for global guidance in your Codex home directory (usually `~/.codex`; set `CODEX_HOME` to change it). For a quick overview, see the [Memory with AGENTS.md section](../docs/getting-started.md#memory-with-agentsmd) in the getting started guide. +- If an `AGENTS.override.md` file exists there, it takes priority. If not, Codex falls back to `AGENTS.md`. +- Only the first non-empty file is used. Other filenames, such as `instructions.md`, have no effect unless Codex is specifically instructed to use them. +- Whatever Codex finds here stays active for the whole session, and Codex combines it with any project-specific instructions it discovers. + +## Project Instructions (per-repository) + +When you work inside a project, Codex builds on those global instructions by collecting project docs: + +- The search starts at the repository root and continues down to your current directory. If a Git root is not found, only the current directory is checked. +- In each directory along that path, Codex looks for `AGENTS.override.md` first, then `AGENTS.md`, and then any fallback names listed in your Codex configuration (see [`project_doc_fallback_filenames`](../docs/config.md#project_doc_fallback_filenames)). At most one file per directory is included. +- Files are read in order from root to leaf and joined together with blank lines. Empty files are skipped, and very large files are truncated once the combined size reaches 32 KiB (the default [`project_doc_max_bytes`](../docs/config.md#project_doc_max_bytes) limit). If you need more space, split guidance across nested directories or raise the limit in your configuration. + +## How They Come Together + +Before Codex gets to work, the instructions are ingested in precedence order: global guidance from `~/.codex` comes first, then each project doc from the repository root down to your current directory. Guidance in deeper directories overrides earlier layers, so the most specific file controls the final behavior. + +### Priority Summary + +1. Global `AGENTS.override.md` (if present), otherwise global `AGENTS.md`. +2. For each directory from the repository root to your working directory: `AGENTS.override.md`, then `AGENTS.md`, then configured fallback names. + +Only these filenames are considered. To use a different name, add it to the fallback list in your Codex configuration or rename the file accordingly. + +## Fallback Filenames + +Codex can look for additional instruction filenames beyond the two defaults if you add them to `project_doc_fallback_filenames` in your Codex configuration. Each fallback is checked after `AGENTS.override.md` and `AGENTS.md` in every directory along the search path. + +Example: suppose your configuration lists `["TEAM_GUIDE.md", ".agents.md"]`. Inside each directory Codex will look in this order: + +1. `AGENTS.override.md` +2. `AGENTS.md` +3. `TEAM_GUIDE.md` +4. `.agents.md` + +If the repository root contains `TEAM_GUIDE.md` and the `backend/` directory contains `AGENTS.override.md`, the overall instructions will combine the root `TEAM_GUIDE.md` (because no override or default file was present there) with the `backend/AGENTS.override.md` file (which takes precedence over the fallback names). + +You can configure those fallbacks in `~/.codex/config.toml` (or another profile) like this: + +```toml +[project] +project_doc_fallback_filenames = ["TEAM_GUIDE.md", ".agents.md"] +``` + +For additional configuration details, see [Config](../docs/config.md) and revisit the [Memory with AGENTS.md guide](../docs/getting-started.md#memory-with-agentsmd) for practical usage tips. From c127062b40c492890d33cb2083e4b8ccdb502bf4 Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Sun, 19 Oct 2025 15:07:33 -0700 Subject: [PATCH 025/287] docs: improve overall documentation (#5354) Update FAQ, improve general structure for config, add more links across the sections in the documentation, remove out of date and duplicate content and better explain certain concepts such as approvals and sandboxing. --- docs/advanced.md | 16 +- docs/config.md | 561 +++++++++++++++++++----------------- docs/faq.md | 27 +- docs/platform-sandboxing.md | 9 +- docs/sandbox.md | 26 +- 5 files changed, 345 insertions(+), 294 deletions(-) diff --git a/docs/advanced.md b/docs/advanced.md index 7c986f583a..4bf05a8032 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -1,6 +1,12 @@ ## Advanced -## Tracing / verbose logging +If you already lean on Codex every day and just need a little more control, this page collects the knobs you are most likely to reach for: tweak defaults in [Config](./config.md), add extra tools through [Model Context Protocol support](./advanced.md#model-context-protocol), and script full runs with [`codex exec`](./exec.md). Jump to the section you need and keep building. + +## Config quickstart {#config-quickstart} + +Most day-to-day tuning lives in `config.toml`: set approval + sandbox presets, pin model defaults, and add MCP server launchers. The [Config guide](./config.md) walks through every option and provides copy-paste examples for common setups. + +## Tracing / verbose logging {#tracing-verbose-logging} Because Codex is written in Rust, it honors the `RUST_LOG` environment variable to configure its logging behavior. @@ -14,15 +20,15 @@ By comparison, the non-interactive mode (`codex exec`) defaults to `RUST_LOG=err See the Rust documentation on [`RUST_LOG`](https://docs.rs/env_logger/latest/env_logger/#enabling-logging) for more information on the configuration options. -## Model Context Protocol (MCP) +## Model Context Protocol (MCP) {#model-context-protocol} The Codex CLI and IDE extension is a MCP client which means that it can be configured to connect to MCP servers. For more information, refer to the [`config docs`](./config.md#connecting-to-mcp-servers). -## Using Codex as an MCP Server +## Using Codex as an MCP Server {#mcp-server} The Codex CLI can also be run as an MCP _server_ via `codex mcp-server`. For example, you can use `codex mcp-server` to make Codex available as a tool inside of a multi-agent framework like the OpenAI [Agents SDK](https://platform.openai.com/docs/guides/agents). Use `codex mcp` separately to add/list/get/remove MCP server launchers in your configuration. -### Codex MCP Server Quickstart +### Codex MCP Server Quickstart {#mcp-server-quickstart} You can launch a Codex MCP server with the [Model Context Protocol Inspector](https://modelcontextprotocol.io/legacy/tools/inspector): @@ -53,7 +59,7 @@ Send a `tools/list` request and you will see that there are two tools available: | **`prompt`** (required) | string | The next user prompt to continue the Codex conversation. | | **`conversationId`** (required) | string | The id of the conversation to continue. | -### Trying it Out +### Trying it Out {#mcp-server-trying-it-out} > [!TIP] > Codex often takes a few minutes to run. To accommodate this, adjust the MCP inspector's Request and Total timeouts to 600000ms (10 minutes) under ⛭ Configuration. diff --git a/docs/config.md b/docs/config.md index e460cb19bb..79246137ca 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1,5 +1,16 @@ # Config +Codex configuration gives you fine-grained control over the model, execution environment, and integrations available to the CLI. Use this guide alongside the workflows in [`codex exec`](./exec.md), the guardrails in [Sandbox & approvals](./sandbox.md), and project guidance from [AGENTS.md discovery](./agents_md.md). + +## Quick navigation + +- [Model selection](#model-selection) +- [Execution environment](#execution-environment) +- [MCP integration](#mcp-integration) +- [Observability and telemetry](#observability-and-telemetry) +- [Profiles and overrides](#profiles-and-overrides) +- [Reference table](#config-reference) + Codex supports several mechanisms for setting config values: - Config-specific command-line flags, such as `--model o3` (highest precedence). @@ -15,15 +26,17 @@ Codex supports several mechanisms for setting config values: Both the `--config` flag and the `config.toml` file support the following options: -## model +## Model selection + +### model The model that Codex should use. ```toml -model = "o3" # overrides the default of "gpt-5-codex" +model = "gpt-5" # overrides the default of "gpt-5-codex" ``` -## model_providers +### model_providers This option lets you override and amend the default set of model providers bundled with Codex. This value is a map where the key is the value to use with `model_provider` to select the corresponding provider. @@ -84,7 +97,7 @@ http_headers = { "X-Example-Header" = "example-value" } env_http_headers = { "X-Example-Features" = "EXAMPLE_FEATURES" } ``` -### Azure model provider example +#### Azure model provider example Note that Azure requires `api-version` to be passed as a query parameter, so be sure to specify it as part of `query_params` when defining the Azure provider: @@ -100,7 +113,7 @@ wire_api = "responses" Export your key before launching Codex: `export AZURE_OPENAI_API_KEY=…` -### Per-provider network tuning +#### Per-provider network tuning The following optional settings control retry behaviour and streaming idle timeouts **per model provider**. They must be specified inside the corresponding `[model_providers.]` block in `config.toml`. (Older releases accepted top‑level keys; those are now ignored.) @@ -117,19 +130,19 @@ stream_max_retries = 10 # retry dropped SSE streams stream_idle_timeout_ms = 300000 # 5m idle timeout ``` -#### request_max_retries +##### request_max_retries How many times Codex will retry a failed HTTP request to the model provider. Defaults to `4`. -#### stream_max_retries +##### stream_max_retries Number of times Codex will attempt to reconnect when a streaming response is interrupted. Defaults to `5`. -#### stream_idle_timeout_ms +##### stream_idle_timeout_ms How long Codex will wait for activity on a streaming response before treating the connection as lost. Defaults to `300_000` (5 minutes). -## model_provider +### model_provider Identifies which provider to use from the `model_providers` map. Defaults to `"openai"`. You can override the `base_url` for the built-in `openai` provider via the `OPENAI_BASE_URL` environment variable. @@ -142,7 +155,73 @@ model_provider = "ollama" model = "mistral" ``` -## approval_policy +### model_reasoning_effort + +If the selected model is known to support reasoning (for example: `o3`, `o4-mini`, `codex-*`, `gpt-5`, `gpt-5-codex`), reasoning is enabled by default when using the Responses API. As explained in the [OpenAI Platform documentation](https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning), this can be set to: + +- `"minimal"` +- `"low"` +- `"medium"` (default) +- `"high"` + +Note: to minimize reasoning, choose `"minimal"`. + +### model_reasoning_summary + +If the model name starts with `"o"` (as in `"o3"` or `"o4-mini"`) or `"codex"`, reasoning is enabled by default when using the Responses API. As explained in the [OpenAI Platform documentation](https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries), this can be set to: + +- `"auto"` (default) +- `"concise"` +- `"detailed"` + +To disable reasoning summaries, set `model_reasoning_summary` to `"none"` in your config: + +```toml +model_reasoning_summary = "none" # disable reasoning summaries +``` + +### model_verbosity + +Controls output length/detail on GPT‑5 family models when using the Responses API. Supported values: + +- `"low"` +- `"medium"` (default when omitted) +- `"high"` + +When set, Codex includes a `text` object in the request payload with the configured verbosity, for example: `"text": { "verbosity": "low" }`. + +Example: + +```toml +model = "gpt-5" +model_verbosity = "low" +``` + +Note: This applies only to providers using the Responses API. Chat Completions providers are unaffected. + +### model_supports_reasoning_summaries + +By default, `reasoning` is only set on requests to OpenAI models that are known to support them. To force `reasoning` to set on requests to the current model, you can force this behavior by setting the following in `config.toml`: + +```toml +model_supports_reasoning_summaries = true +``` + +### model_context_window + +The size of the context window for the model, in tokens. + +In general, Codex knows the context window for the most common OpenAI models, but if you are using a new model with an old version of the Codex CLI, then you can use `model_context_window` to tell Codex what value to use to determine how much context is left during a conversation. + +### model_max_output_tokens + +This is analogous to `model_context_window`, but for the maximum number of output tokens for the model. + +> See also [`codex exec`](./exec.md) to see how these model settings influence non-interactive runs. + +## Execution environment + +### approval_policy Determines when the user should be prompted to approve whether Codex can execute a command: @@ -179,104 +258,7 @@ Alternatively, you can have the model run until it is done, and never ask to run approval_policy = "never" ``` -## profiles - -A _profile_ is a collection of configuration values that can be set together. Multiple profiles can be defined in `config.toml` and you can specify the one you -want to use at runtime via the `--profile` flag. - -Here is an example of a `config.toml` that defines multiple profiles: - -```toml -model = "o3" -approval_policy = "untrusted" - -# Setting `profile` is equivalent to specifying `--profile o3` on the command -# line, though the `--profile` flag can still be used to override this value. -profile = "o3" - -[model_providers.openai-chat-completions] -name = "OpenAI using Chat Completions" -base_url = "https://api.openai.com/v1" -env_key = "OPENAI_API_KEY" -wire_api = "chat" - -[profiles.o3] -model = "o3" -model_provider = "openai" -approval_policy = "never" -model_reasoning_effort = "high" -model_reasoning_summary = "detailed" - -[profiles.gpt3] -model = "gpt-3.5-turbo" -model_provider = "openai-chat-completions" - -[profiles.zdr] -model = "o3" -model_provider = "openai" -approval_policy = "on-failure" -``` - -Users can specify config values at multiple levels. Order of precedence is as follows: - -1. custom command-line argument, e.g., `--model o3` -2. as part of a profile, where the `--profile` is specified via a CLI (or in the config file itself) -3. as an entry in `config.toml`, e.g., `model = "o3"` -4. the default value that comes with Codex CLI (i.e., Codex CLI defaults to `gpt-5-codex`) - -## model_reasoning_effort - -If the selected model is known to support reasoning (for example: `o3`, `o4-mini`, `codex-*`, `gpt-5`, `gpt-5-codex`), reasoning is enabled by default when using the Responses API. As explained in the [OpenAI Platform documentation](https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning), this can be set to: - -- `"minimal"` -- `"low"` -- `"medium"` (default) -- `"high"` - -Note: to minimize reasoning, choose `"minimal"`. - -## model_reasoning_summary - -If the model name starts with `"o"` (as in `"o3"` or `"o4-mini"`) or `"codex"`, reasoning is enabled by default when using the Responses API. As explained in the [OpenAI Platform documentation](https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries), this can be set to: - -- `"auto"` (default) -- `"concise"` -- `"detailed"` - -To disable reasoning summaries, set `model_reasoning_summary` to `"none"` in your config: - -```toml -model_reasoning_summary = "none" # disable reasoning summaries -``` - -## model_verbosity - -Controls output length/detail on GPT‑5 family models when using the Responses API. Supported values: - -- `"low"` -- `"medium"` (default when omitted) -- `"high"` - -When set, Codex includes a `text` object in the request payload with the configured verbosity, for example: `"text": { "verbosity": "low" }`. - -Example: - -```toml -model = "gpt-5" -model_verbosity = "low" -``` - -Note: This applies only to providers using the Responses API. Chat Completions providers are unaffected. - -## model_supports_reasoning_summaries - -By default, `reasoning` is only set on requests to OpenAI models that are known to support them. To force `reasoning` to set on requests to the current model, you can force this behavior by setting the following in `config.toml`: - -```toml -model_supports_reasoning_summaries = true -``` - -## sandbox_mode +### sandbox_mode Codex executes model-generated shell commands inside an OS-level sandbox. @@ -325,7 +307,7 @@ This is reasonable to use if Codex is running in an environment that provides it Though using this option may also be necessary if you try to use Codex in environments where its native sandboxing mechanisms are unsupported, such as older Linux kernels or on Windows. -## Approval presets +### approval_presets Codex provides three main Approval Presets: @@ -335,123 +317,9 @@ Codex provides three main Approval Presets: You can further customize how Codex runs at the command line using the `--ask-for-approval` and `--sandbox` options. -## Connecting to MCP servers +> See also [Sandbox & approvals](./sandbox.md) for in-depth examples and platform-specific behaviour. -You can configure Codex to use [MCP servers](https://modelcontextprotocol.io/about) to give Codex access to external applications, resources, or services. - -### Server configuration - -#### STDIO - -[STDIO servers](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio) are MCP servers that you can launch directly via commands on your computer. - -```toml -# The top-level table name must be `mcp_servers` -# The sub-table name (`server-name` in this example) can be anything you would like. -[mcp_servers.server_name] -command = "npx" -# Optional -args = ["-y", "mcp-server"] -# Optional: propagate additional env vars to the MVP server. -# A default whitelist of env vars will be propagated to the MCP server. -# https://github.com/openai/codex/blob/main/codex-rs/rmcp-client/src/utils.rs#L82 -env = { "API_KEY" = "value" } -# or -[mcp_servers.server_name.env] -API_KEY = "value" -# Optional: Additional list of environment variables that will be whitelisted in the MCP server's environment. -env_vars = ["API_KEY2"] - -# Optional: cwd that the command will be run from -cwd = "/Users//code/my-server" -``` - -#### Streamable HTTP - -[Streamable HTTP servers](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) enable Codex to talk to resources that are accessed via a http url (either on localhost or another domain). - -```toml -# Streamable HTTP requires the experimental rmcp client -experimental_use_rmcp_client = true -[mcp_servers.figma] -url = "https://mcp.linear.app/mcp" -# Optional environment variable containing a bearer token to use for auth -bearer_token_env_var = "" -# Optional map of headers with hard-coded values. -http_headers = { "HEADER_NAME" = "HEADER_VALUE" } -# Optional map of headers whose values will be replaced with the environment variable. -env_http_headers = { "HEADER_NAME" = "ENV_VAR" } -``` - -For oauth login, you must enable `experimental_use_rmcp_client = true` and then run `codex mcp login server_name` - -### Other configuration options - -```toml -# Optional: override the default 10s startup timeout -startup_timeout_sec = 20 -# Optional: override the default 60s per-tool timeout -tool_timeout_sec = 30 -# Optional: disable a server without removing it -enabled = false -``` - -### Experimental RMCP client - -Codex is transitioning to the [official Rust MCP SDK](https://github.com/modelcontextprotocol/rust-sdk). - -The flag enabled OAuth support for streamable HTTP servers and uses a new STDIO client implementation. - -Please try and report issues with the new client. To enable it, add this to the top level of your `config.toml` - -```toml -experimental_use_rmcp_client = true - -[mcp_servers.server_name] -… -``` - -### MCP CLI commands - -```shell -# List all available commands -codex mcp --help - -# Add a server (env can be repeated; `--` separates the launcher command) -codex mcp add docs -- docs-server --port 4000 - -# List configured servers (pretty table or JSON) -codex mcp list -codex mcp list --json - -# Show one server (table or JSON) -codex mcp get docs -codex mcp get docs --json - -# Remove a server -codex mcp remove docs - -# Log in to a streamable HTTP server that supports oauth -codex mcp login SERVER_NAME - -# Log out from a streamable HTTP server that supports oauth -codex mcp logout SERVER_NAME -``` - -## Examples of useful MCPs - -There is an ever growing list of useful MCP servers that can be helpful while you are working with Codex. - -Some of the most common MCPs we've seen are: - -- [Context7](https://github.com/upstash/context7) — connect to a wide range of up-to-date developer documentation -- Figma [Local](https://developers.figma.com/docs/figma-mcp-server/local-server-installation/) and [Remote](https://developers.figma.com/docs/figma-mcp-server/remote-server-installation/) - access to your Figma designs -- [Playwright](https://www.npmjs.com/package/@playwright/mcp) - control and inspect a browser using Playwright -- [Chrome Developer Tools](https://github.com/ChromeDevTools/chrome-devtools-mcp/) — control and inspect a Chrome browser -- [Sentry](https://docs.sentry.io/product/sentry-mcp/#codex) — access to your Sentry logs -- [GitHub](https://github.com/github/github-mcp-server) — Control over your GitHub account beyond what git allows (like controlling PRs, issues, etc.) - -## shell_environment_policy +### shell_environment_policy Codex spawns subprocesses (e.g. when executing a `local_shell` tool-call suggested by the assistant). By default it now passes **your full environment** to those subprocesses. You can tune this behavior via the **`shell_environment_policy`** block in `config.toml`: @@ -493,7 +361,127 @@ set = { PATH = "/usr/bin", MY_FLAG = "1" } Currently, `CODEX_SANDBOX_NETWORK_DISABLED=1` is also added to the environment, assuming network is disabled. This is not configurable. -## otel +## MCP integration + +### mcp_servers + +You can configure Codex to use [MCP servers](https://modelcontextprotocol.io/about) to give Codex access to external applications, resources, or services. + +#### Server configuration + +##### STDIO + +[STDIO servers](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio) are MCP servers that you can launch directly via commands on your computer. + +```toml +# The top-level table name must be `mcp_servers` +# The sub-table name (`server-name` in this example) can be anything you would like. +[mcp_servers.server_name] +command = "npx" +# Optional +args = ["-y", "mcp-server"] +# Optional: propagate additional env vars to the MVP server. +# A default whitelist of env vars will be propagated to the MCP server. +# https://github.com/openai/codex/blob/main/codex-rs/rmcp-client/src/utils.rs#L82 +env = { "API_KEY" = "value" } +# or +[mcp_servers.server_name.env] +API_KEY = "value" +# Optional: Additional list of environment variables that will be whitelisted in the MCP server's environment. +env_vars = ["API_KEY2"] + +# Optional: cwd that the command will be run from +cwd = "/Users//code/my-server" +``` + +##### Streamable HTTP + +[Streamable HTTP servers](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) enable Codex to talk to resources that are accessed via a http url (either on localhost or another domain). + +```toml +# Streamable HTTP requires the experimental rmcp client +experimental_use_rmcp_client = true +[mcp_servers.figma] +url = "https://mcp.linear.app/mcp" +# Optional environment variable containing a bearer token to use for auth +bearer_token_env_var = "" +# Optional map of headers with hard-coded values. +http_headers = { "HEADER_NAME" = "HEADER_VALUE" } +# Optional map of headers whose values will be replaced with the environment variable. +env_http_headers = { "HEADER_NAME" = "ENV_VAR" } +``` + +For oauth login, you must enable `experimental_use_rmcp_client = true` and then run `codex mcp login server_name` + +#### Other configuration options + +```toml +# Optional: override the default 10s startup timeout +startup_timeout_sec = 20 +# Optional: override the default 60s per-tool timeout +tool_timeout_sec = 30 +# Optional: disable a server without removing it +enabled = false +``` + +#### Experimental RMCP client + +Codex is transitioning to the [official Rust MCP SDK](https://github.com/modelcontextprotocol/rust-sdk). + +The flag enabled OAuth support for streamable HTTP servers and uses a new STDIO client implementation. + +Please try and report issues with the new client. To enable it, add this to the top level of your `config.toml` + +```toml +experimental_use_rmcp_client = true + +[mcp_servers.server_name] +… +``` + +#### MCP CLI commands + +```shell +# List all available commands +codex mcp --help + +# Add a server (env can be repeated; `--` separates the launcher command) +codex mcp add docs -- docs-server --port 4000 + +# List configured servers (pretty table or JSON) +codex mcp list +codex mcp list --json + +# Show one server (table or JSON) +codex mcp get docs +codex mcp get docs --json + +# Remove a server +codex mcp remove docs + +# Log in to a streamable HTTP server that supports oauth +codex mcp login SERVER_NAME + +# Log out from a streamable HTTP server that supports oauth +codex mcp logout SERVER_NAME +``` + +### Examples of useful MCPs + +There is an ever growing list of useful MCP servers that can be helpful while you are working with Codex. + +Some of the most common MCPs we've seen are: + +- [Context7](https://github.com/upstash/context7) — connect to a wide range of up-to-date developer documentation +- Figma [Local](https://developers.figma.com/docs/figma-mcp-server/local-server-installation/) and [Remote](https://developers.figma.com/docs/figma-mcp-server/remote-server-installation/) - access to your Figma designs +- [Playwright](https://www.npmjs.com/package/@playwright/mcp) - control and inspect a browser using Playwright +- [Chrome Developer Tools](https://github.com/ChromeDevTools/chrome-devtools-mcp/) — control and inspect a Chrome browser +- [Sentry](https://docs.sentry.io/product/sentry-mcp/#codex) — access to your Sentry logs +- [GitHub](https://github.com/github/github-mcp-server) — Control over your GitHub account beyond what git allows (like controlling PRs, issues, etc.) + +## Observability and telemetry + +### otel Codex can emit [OpenTelemetry](https://opentelemetry.io/) **log events** that describe each run: outbound API requests, streamed responses, user input, @@ -604,7 +592,7 @@ flag; the official prebuilt binaries ship with the feature enabled. When the feature is disabled the telemetry hooks become no-ops so the CLI continues to function without the extra dependencies. -## notify +### notify Specify a program that will be executed to get notified about events generated by Codex. Note that the program will receive the notification argument as a string of JSON, e.g.: @@ -689,7 +677,79 @@ notify = ["python3", "/Users/mbolin/.codex/notify.py"] > [!NOTE] > Use `notify` for automation and integrations: Codex invokes your external program with a single JSON argument for each event, independent of the TUI. If you only want lightweight desktop notifications while using the TUI, prefer `tui.notifications`, which uses terminal escape codes and requires no external program. You can enable both; `tui.notifications` covers in‑TUI alerts (e.g., approval prompts), while `notify` is best for system‑level hooks or custom notifiers. Currently, `notify` emits only `agent-turn-complete`, whereas `tui.notifications` supports `agent-turn-complete` and `approval-requested` with optional filtering. -## history +### hide_agent_reasoning + +Codex intermittently emits "reasoning" events that show the model's internal "thinking" before it produces a final answer. Some users may find these events distracting, especially in CI logs or minimal terminal output. + +Setting `hide_agent_reasoning` to `true` suppresses these events in **both** the TUI as well as the headless `exec` sub-command: + +```toml +hide_agent_reasoning = true # defaults to false +``` + +### show_raw_agent_reasoning + +Surfaces the model’s raw chain-of-thought ("raw reasoning content") when available. + +Notes: + +- Only takes effect if the selected model/provider actually emits raw reasoning content. Many models do not. When unsupported, this option has no visible effect. +- Raw reasoning may include intermediate thoughts or sensitive context. Enable only if acceptable for your workflow. + +Example: + +```toml +show_raw_agent_reasoning = true # defaults to false +``` + +## Profiles and overrides + +### profiles + +A _profile_ is a collection of configuration values that can be set together. Multiple profiles can be defined in `config.toml` and you can specify the one you +want to use at runtime via the `--profile` flag. + +Here is an example of a `config.toml` that defines multiple profiles: + +```toml +model = "o3" +approval_policy = "untrusted" + +# Setting `profile` is equivalent to specifying `--profile o3` on the command +# line, though the `--profile` flag can still be used to override this value. +profile = "o3" + +[model_providers.openai-chat-completions] +name = "OpenAI using Chat Completions" +base_url = "https://api.openai.com/v1" +env_key = "OPENAI_API_KEY" +wire_api = "chat" + +[profiles.o3] +model = "o3" +model_provider = "openai" +approval_policy = "never" +model_reasoning_effort = "high" +model_reasoning_summary = "detailed" + +[profiles.gpt3] +model = "gpt-3.5-turbo" +model_provider = "openai-chat-completions" + +[profiles.zdr] +model = "o3" +model_provider = "openai" +approval_policy = "on-failure" +``` + +Users can specify config values at multiple levels. Order of precedence is as follows: + +1. custom command-line argument, e.g., `--model o3` +2. as part of a profile, where the `--profile` is specified via a CLI (or in the config file itself) +3. as an entry in `config.toml`, e.g., `model = "o3"` +4. the default value that comes with Codex CLI (i.e., Codex CLI defaults to `gpt-5-codex`) + +### history By default, Codex CLI records messages sent to the model in `$CODEX_HOME/history.jsonl`. Note that on UNIX, the file permissions are set to `o600`, so it should only be readable and writable by the owner. @@ -700,7 +760,7 @@ To disable this behavior, configure `[history]` as follows: persistence = "none" # "save-all" is the default value ``` -## file_opener +### file_opener Identifies the editor/URI scheme to use for hyperlinking citations in model output. If set, citations to files in the model output will be hyperlinked using the specified URI scheme so they can be ctrl/cmd-clicked from the terminal to open them. @@ -716,46 +776,11 @@ Note this is **not** a general editor setting (like `$EDITOR`), as it only accep Currently, `"vscode"` is the default, though Codex does not verify VS Code is installed. As such, `file_opener` may default to `"none"` or something else in the future. -## hide_agent_reasoning - -Codex intermittently emits "reasoning" events that show the model's internal "thinking" before it produces a final answer. Some users may find these events distracting, especially in CI logs or minimal terminal output. - -Setting `hide_agent_reasoning` to `true` suppresses these events in **both** the TUI as well as the headless `exec` sub-command: - -```toml -hide_agent_reasoning = true # defaults to false -``` - -## show_raw_agent_reasoning - -Surfaces the model’s raw chain-of-thought ("raw reasoning content") when available. - -Notes: - -- Only takes effect if the selected model/provider actually emits raw reasoning content. Many models do not. When unsupported, this option has no visible effect. -- Raw reasoning may include intermediate thoughts or sensitive context. Enable only if acceptable for your workflow. - -Example: - -```toml -show_raw_agent_reasoning = true # defaults to false -``` - -## model_context_window - -The size of the context window for the model, in tokens. - -In general, Codex knows the context window for the most common OpenAI models, but if you are using a new model with an old version of the Codex CLI, then you can use `model_context_window` to tell Codex what value to use to determine how much context is left during a conversation. - -## model_max_output_tokens - -This is analogous to `model_context_window`, but for the maximum number of output tokens for the model. - -## project_doc_max_bytes +### project_doc_max_bytes Maximum number of bytes to read from an `AGENTS.md` file to include in the instructions sent with the first turn of a session. Defaults to 32 KiB. -## project_doc_fallback_filenames +### project_doc_fallback_filenames Ordered list of additional filenames to look for when `AGENTS.md` is missing at a given directory level. The CLI always checks `AGENTS.md` first; the configured fallbacks are tried in the order provided. This lets monorepos that already use alternate instruction files (for example, `CLAUDE.md`) work out of the box while you migrate to `AGENTS.md` over time. @@ -765,7 +790,9 @@ project_doc_fallback_filenames = ["CLAUDE.md", ".exampleagentrules.md"] We recommend migrating instructions to AGENTS.md; other filenames may reduce model performance. -## tui +> See also [AGENTS.md discovery](./agents_md.md) for how Codex locates these files during a session. + +### tui Options that are specific to the TUI. diff --git a/docs/faq.md b/docs/faq.md index 33cc1fb932..55a6e5e33b 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -1,23 +1,44 @@ ## FAQ +This FAQ highlights the most common questions and points you to the right deep-dive guides in `docs/`. + ### OpenAI released a model called Codex in 2021 - is this related? In 2021, OpenAI released Codex, an AI system designed to generate code from natural language prompts. That original Codex model was deprecated as of March 2023 and is separate from the CLI tool. ### Which models are supported? -We recommend using Codex with GPT-5, our best coding model. The default reasoning level is medium, and you can upgrade to high for complex tasks with the `/model` command. +We recommend using Codex with GPT-5 Codex, our best coding model. The default reasoning level is medium, and you can upgrade to high for complex tasks with the `/model` command. You can also use older models by using API-based auth and launching codex with the `--model` flag. -### Why does `o3` or `o4-mini` not work for me? +### How do approvals and sandbox modes work together? -It's possible that your [API account needs to be verified](https://help.openai.com/en/articles/10910291-api-organization-verification) in order to start streaming responses and seeing chain of thought summaries from the API. If you're still running into issues, please let us know! +Approvals are the mechanism Codex uses to ask before running a tool call with elevated permissions - typically to leave the sandbox or re-run a failed command without isolation. Sandbox mode provides the baseline isolation (`Read Only`, `Workspace Write`, or `Danger Full Access`; see [Sandbox & approvals](./sandbox.md)). + +### Can I automate tasks without the TUI? + +Yes. [`codex exec`](./exec.md) runs Codex in non-interactive mode with streaming logs, JSONL output, and structured schema support. The command respects the same sandbox and approval settings you configure in the [Config guide](./config.md). ### How do I stop Codex from editing my files? By default, Codex can modify files in your current working directory (Auto mode). To prevent edits, run `codex` in read-only mode with the CLI flag `--sandbox read-only`. Alternatively, you can change the approval level mid-conversation with `/approvals`. +### How do I connect Codex to MCP servers? + +Configure MCP servers through your `config.toml` using the examples in [Config -> Connecting to MCP servers](./config.md#connecting-to-mcp-servers). + +### I'm having trouble logging in. What should I check? + +Confirm your setup in three steps: + +1. Walk through the auth flows in [Authentication](./authentication.md) to ensure the correct credentials are present in `~/.codex/auth.json`. +2. If you're on a headless or remote machine, make sure port-forwarding is configured as described in [Authentication -> Connecting on a "Headless" Machine](./authentication.md#connecting-on-a-headless-machine). + ### Does it work on Windows? Running Codex directly on Windows may work, but is not officially supported. We recommend using [Windows Subsystem for Linux (WSL2)](https://learn.microsoft.com/en-us/windows/wsl/install). + +### Where should I start after installation? + +Follow the quick setup in [Install & build](./install.md) and then jump into [Getting started](./getting-started.md) for interactive usage tips, prompt examples, and AGENTS.md guidance. diff --git a/docs/platform-sandboxing.md b/docs/platform-sandboxing.md index 32619a48ef..39ffce67c3 100644 --- a/docs/platform-sandboxing.md +++ b/docs/platform-sandboxing.md @@ -1,8 +1,3 @@ -### Platform sandboxing details +## Platform sandboxing -The mechanism Codex uses to implement the sandbox policy depends on your OS: - -- **macOS 12+** uses **Apple Seatbelt** and runs commands using `sandbox-exec` with a profile (`-p`) that corresponds to the `--sandbox` that was specified. -- **Linux** uses a combination of Landlock/seccomp APIs to enforce the `sandbox` configuration. - -Note that when running Linux in a containerized environment such as Docker, sandboxing may not work if the host/container configuration does not support the necessary Landlock/seccomp APIs. In such cases, we recommend configuring your Docker container so that it provides the sandbox guarantees you are looking for and then running `codex` with `--sandbox danger-full-access` (or, more simply, the `--dangerously-bypass-approvals-and-sandbox` flag) within your container. +This content now lives alongside the rest of the sandbox guidance. See [Sandbox mechanics by platform](./sandbox.md#platform-sandboxing-details) for up-to-date details. diff --git a/docs/sandbox.md b/docs/sandbox.md index 711ecc8f25..369dced017 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -1,8 +1,10 @@ ## Sandbox & approvals -### Approval modes +What Codex is allowed to do is governed by a combination of **sandbox modes** (what Codex is allowed to do without supervision) and **approval policies** (when you must confirm an action). This page explains the options, how they interact, and how the sandbox behaves on each platform. -We've chosen a powerful default for how Codex works on your computer: `Auto`. In this approval mode, Codex can read files, make edits, and run commands in the working directory automatically. However, Codex will need your approval to work outside the working directory or access network. +### Approval policies + +We've chosen a powerful default for how Codex works on your computer: `Auto`. Under this approval policy, Codex can read files, make edits, and run commands in the working directory automatically. However, Codex will need your approval to work outside the working directory or access network. When you just want to chat, or if you want to plan before diving in, you can switch to `Read Only` mode with the `/approvals` command. @@ -63,9 +65,18 @@ approval_policy = "never" sandbox_mode = "read-only" ``` +### Sandbox mechanics by platform {#platform-sandboxing-details} + +The mechanism Codex uses to enforce the sandbox policy depends on your OS: + +- **macOS 12+** uses **Apple Seatbelt**. Codex invokes `sandbox-exec` with a profile that corresponds to the selected `--sandbox` mode, constraining filesystem and network access at the OS level. +- **Linux** combines **Landlock** and **seccomp** APIs to approximate the same guarantees. Kernel support is required; older kernels may not expose the necessary features. + +In containerized Linux environments (for example Docker), sandboxing may not work when the host or container configuration does not expose Landlock/seccomp. In those cases, configure the container to provide the isolation you need and run Codex with `--sandbox danger-full-access` (or the shorthand `--dangerously-bypass-approvals-and-sandbox`) inside that container. + ### Experimenting with the Codex Sandbox -To test to see what happens when a command is run under the sandbox provided by Codex, we provide the following subcommands in Codex CLI: +To test how commands behave under Codex's sandbox, use the CLI helpers: ``` # macOS @@ -78,12 +89,3 @@ codex sandbox linux [--full-auto] [COMMAND]... codex debug seatbelt [--full-auto] [COMMAND]... codex debug landlock [--full-auto] [COMMAND]... ``` - -### Platform sandboxing details - -The mechanism Codex uses to implement the sandbox policy depends on your OS: - -- **macOS 12+** uses **Apple Seatbelt** and runs commands using `sandbox-exec` with a profile (`-p`) that corresponds to the `--sandbox` that was specified. -- **Linux** uses a combination of Landlock/seccomp APIs to enforce the `sandbox` configuration. - -Note that when running Linux in a containerized environment such as Docker, sandboxing may not work if the host/container configuration does not support the necessary Landlock/seccomp APIs. In such cases, we recommend configuring your Docker container so that it provides the sandbox guarantees you are looking for and then running `codex` with `--sandbox danger-full-access` (or, more simply, the `--dangerously-bypass-approvals-and-sandbox` flag) within your container. From 3e071c4c95a2539b61472a64216a4040557a300e Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Sun, 19 Oct 2025 15:32:13 -0700 Subject: [PATCH 026/287] fix: config.md docs inaccuracies (#5355) Updated the configuration guide so it matches the current CLI behavior. Clarified the platform-specific default model, explained how custom model-providers interact with bundled ones, refreshed the streamable HTTP/MCP section with accurate guidance on the RMCP client and OAuth flag, and removed stale keys from the reference table. --- docs/config.md | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/docs/config.md b/docs/config.md index 79246137ca..e8d94a8439 100644 --- a/docs/config.md +++ b/docs/config.md @@ -33,12 +33,15 @@ Both the `--config` flag and the `config.toml` file support the following option The model that Codex should use. ```toml -model = "gpt-5" # overrides the default of "gpt-5-codex" +model = "gpt-5" # overrides the default ("gpt-5-codex" on macOS/Linux, "gpt-5" on Windows) ``` ### model_providers -This option lets you override and amend the default set of model providers bundled with Codex. This value is a map where the key is the value to use with `model_provider` to select the corresponding provider. +This option lets you add to the default set of model providers bundled with Codex. The map key becomes the value you use with `model_provider` to select the provider. + +> [!NOTE] +> Built-in providers are not overwritten when you reuse their key. Entries you add only take effect when the key is **new**; for example `[model_providers.openai]` leaves the original OpenAI definition untouched. To customize the bundled OpenAI provider, prefer the dedicated knobs (for example the `OPENAI_BASE_URL` environment variable) or register a new provider key and point `model_provider` at it. For example, if you wanted to add a provider that uses the OpenAI 4o model via the chat completions API, then you could add the following configuration: @@ -399,8 +402,6 @@ cwd = "/Users//code/my-server" [Streamable HTTP servers](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) enable Codex to talk to resources that are accessed via a http url (either on localhost or another domain). ```toml -# Streamable HTTP requires the experimental rmcp client -experimental_use_rmcp_client = true [mcp_servers.figma] url = "https://mcp.linear.app/mcp" # Optional environment variable containing a bearer token to use for auth @@ -411,7 +412,13 @@ http_headers = { "HEADER_NAME" = "HEADER_VALUE" } env_http_headers = { "HEADER_NAME" = "ENV_VAR" } ``` -For oauth login, you must enable `experimental_use_rmcp_client = true` and then run `codex mcp login server_name` +Streamable HTTP connections always use the experimental Rust MCP client under the hood, so expect occasional rough edges. OAuth login flows are gated on the `experimental_use_rmcp_client = true` flag: + +```toml +experimental_use_rmcp_client = true +``` + +After enabling it, run `codex mcp login ` when the server supports OAuth. #### Other configuration options @@ -428,7 +435,7 @@ enabled = false Codex is transitioning to the [official Rust MCP SDK](https://github.com/modelcontextprotocol/rust-sdk). -The flag enabled OAuth support for streamable HTTP servers and uses a new STDIO client implementation. +This flag enables OAuth support for streamable HTTP servers and switches STDIO servers over to the new client implementation. Please try and report issues with the new client. To enable it, add this to the top level of your `config.toml` @@ -826,7 +833,6 @@ notifications = [ "agent-turn-complete", "approval-requested" ] | `sandbox_workspace_write.network_access` | boolean | Allow network in workspace‑write (default: false). | | `sandbox_workspace_write.exclude_tmpdir_env_var` | boolean | Exclude `$TMPDIR` from writable roots (default: false). | | `sandbox_workspace_write.exclude_slash_tmp` | boolean | Exclude `/tmp` from writable roots (default: false). | -| `disable_response_storage` | boolean | Required for ZDR orgs. | | `notify` | array | External program for notifications. | | `instructions` | string | Currently ignored; use `experimental_instructions_file` or `AGENTS.md`. | | `mcp_servers..command` | string | MCP server launcher command (stdio servers only). | @@ -863,9 +869,7 @@ notifications = [ "agent-turn-complete", "approval-requested" ] | `model_supports_reasoning_summaries` | boolean | Force‑enable reasoning summaries. | | `model_reasoning_summary_format` | `none` \| `experimental` | Force reasoning summary format. | | `chatgpt_base_url` | string | Base URL for ChatGPT auth flow. | -| `experimental_resume` | string (path) | Resume JSONL path (internal/experimental). | | `experimental_instructions_file` | string (path) | Replace built‑in instructions (experimental). | | `experimental_use_exec_command_tool` | boolean | Use experimental exec command tool. | -| `responses_originator_header_internal_override` | string | Override `originator` header value. | | `projects..trust_level` | string | Mark project/worktree as trusted (only `"trusted"` is recognized). | | `tools.web_search` | boolean | Enable web search tool (alias: `web_search_request`) (default: false). | From 3ed728790ba711440bd81626c816148b787ce2c9 Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Sun, 19 Oct 2025 16:17:51 -0700 Subject: [PATCH 027/287] fix: update CLI usage order for codex -h (#5356) Set clap usage override so help lists subcommands before the prompt argument. --- codex-rs/cli/src/main.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index e01bd259a5..30f7a91b14 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -42,7 +42,8 @@ use codex_core::config::ConfigOverrides; // The executable is sometimes invoked via a platform‑specific name like // `codex-x86_64-unknown-linux-musl`, but the help output should always use // the generic `codex` command name that users run. - bin_name = "codex" + bin_name = "codex", + override_usage = "codex [OPTIONS] [PROMPT]\n codex [OPTIONS] [ARGS]" )] struct MultitoolCli { #[clap(flatten)] From 2d9ee9dbe90b9cc67a9e272cbeb51ddacbceafab Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Sun, 19 Oct 2025 16:41:10 -0700 Subject: [PATCH 028/287] docs: align sandbox defaults, dedupe sections and improve getting started guide (#5357) Tightened the docs so the sandbox guide matches reality, noted the new tools.view_image toggle next to web search, and linked the README to the getting-started guide which now owns the familiar tips (backtrack, --cd, --add-dir, etc.). --- codex-rs/README.md | 39 +++++---------------------------------- codex-rs/core/README.md | 2 +- docs/config.md | 13 +++++++++++++ docs/getting-started.md | 38 +++++++++++++++++++++++++++----------- docs/sandbox.md | 20 +++++++++----------- 5 files changed, 55 insertions(+), 57 deletions(-) diff --git a/codex-rs/README.md b/codex-rs/README.md index 7f944d7a0c..29a4e96b89 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -13,6 +13,11 @@ codex You can also install via Homebrew (`brew install codex`) or download a platform-specific release directly from our [GitHub Releases](https://github.com/openai/codex/releases). +## Documentation quickstart + +- First run with Codex? Follow the walkthrough in [`docs/getting-started.md`](../docs/getting-started.md) for prompts, keyboard shortcuts, and session management. +- Already shipping with Codex and want deeper control? Jump to [`docs/advanced.md`](../docs/advanced.md) and the configuration reference at [`docs/config.md`](../docs/config.md). + ## What's new in the Rust CLI The Rust implementation is now the maintained Codex CLI and serves as the default experience. It includes a number of features that the legacy TypeScript CLI never supported. @@ -47,40 +52,6 @@ You can enable notifications by configuring a script that is run whenever the ag To run Codex non-interactively, run `codex exec PROMPT` (you can also pass the prompt via `stdin`) and Codex will work on your task until it decides that it is done and exits. Output is printed to the terminal directly. You can set the `RUST_LOG` environment variable to see more about what's going on. -### Use `@` for file search - -Typing `@` triggers a fuzzy-filename search over the workspace root. Use up/down to select among the results and Tab or Enter to replace the `@` with the selected path. You can use Esc to cancel the search. - -### Esc–Esc to edit a previous message - -When the chat composer is empty, press Esc to prime “backtrack” mode. Press Esc again to open a transcript preview highlighting the last user message; press Esc repeatedly to step to older user messages. Press Enter to confirm and Codex will fork the conversation from that point, trim the visible transcript accordingly, and pre‑fill the composer with the selected user message so you can edit and resubmit it. - -In the transcript preview, the footer shows an `Esc edit prev` hint while editing is active. - -### `--cd`/`-C` flag - -Sometimes it is not convenient to `cd` to the directory you want Codex to use as the "working root" before running Codex. Fortunately, `codex` supports a `--cd` option so you can specify whatever folder you want. You can confirm that Codex is honoring `--cd` by double-checking the **workdir** it reports in the TUI at the start of a new session. - -### `--add-dir` flag - -Need to work across multiple projects? Pass `--add-dir` one or more times to expose extra directories as writable roots for the current session while keeping the main working directory unchanged. For example: - -```shell -codex --cd apps/frontend --add-dir ../backend --add-dir ../shared -``` - -Codex can now inspect and edit files in each listed directory without leaving the primary workspace. - -### Shell completions - -Generate shell completion scripts via: - -```shell -codex completion bash -codex completion zsh -codex completion fish -``` - ### Experimenting with the Codex Sandbox To test to see what happens when a command is run under the sandbox provided by Codex, we provide the following subcommands in Codex CLI: diff --git a/codex-rs/core/README.md b/codex-rs/core/README.md index 8260fc1e79..5d4911b022 100644 --- a/codex-rs/core/README.md +++ b/codex-rs/core/README.md @@ -4,7 +4,7 @@ This crate implements the business logic for Codex. It is designed to be used by ## Dependencies -Note that `codex-core` makes some assumptions about certain helper utilities being available in the environment. Currently, this +Note that `codex-core` makes some assumptions about certain helper utilities being available in the environment. Currently, this support matrix is: ### macOS diff --git a/docs/config.md b/docs/config.md index e8d94a8439..94c3136585 100644 --- a/docs/config.md +++ b/docs/config.md @@ -310,6 +310,18 @@ This is reasonable to use if Codex is running in an environment that provides it Though using this option may also be necessary if you try to use Codex in environments where its native sandboxing mechanisms are unsupported, such as older Linux kernels or on Windows. +### tools.\* + +Use the optional `[tools]` table to toggle built-in tools that the agent may call. Both keys default to `false` (tools stay disabled) unless you opt in: + +```toml +[tools] +web_search = true # allow Codex to issue first-party web searches without prompting you +view_image = true # let Codex attach local images (paths in your workspace) to the model request +``` + +`web_search` is also recognized under the legacy name `web_search_request`. The `view_image` toggle is useful when you want to include screenshots or diagrams from your repo without pasting them manually. Codex still respects sandboxing: it can only attach files inside the workspace roots you allow. + ### approval_presets Codex provides three main Approval Presets: @@ -873,3 +885,4 @@ notifications = [ "agent-turn-complete", "approval-requested" ] | `experimental_use_exec_command_tool` | boolean | Use experimental exec command tool. | | `projects..trust_level` | string | Mark project/worktree as trusted (only `"trusted"` is recognized). | | `tools.web_search` | boolean | Enable web search tool (alias: `web_search_request`) (default: false). | +| `tools.view_image` | boolean | Enable the `view_image` tool so Codex can attach local image files from the workspace (default: false). | diff --git a/docs/getting-started.md b/docs/getting-started.md index 6df93b5817..3473cf5319 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,5 +1,11 @@ ## Getting started +Looking for something specific? Jump ahead: + +- [Tips & shortcuts](#tips--shortcuts) – hotkeys, resume flow, prompts +- [Non-interactive runs](./exec.md) – automate with `codex exec` +- Ready for deeper customization? Head to [`advanced.md`](./advanced.md) + ### CLI usage | Command | Purpose | Example | @@ -77,21 +83,26 @@ For more information on how to use AGENTS.md, see the [official AGENTS.md docume Typing `@` triggers a fuzzy-filename search over the workspace root. Use up/down to select among the results and Tab or Enter to replace the `@` with the selected path. You can use Esc to cancel the search. -#### Image input - -Paste images directly into the composer (Ctrl+V / Cmd+V) to attach them to your prompt. You can also attach files via the CLI using `-i/--image` (comma‑separated): - -```bash -codex -i screenshot.png "Explain this error" -codex --image img1.png,img2.jpg "Summarize these diagrams" -``` - #### Esc–Esc to edit a previous message When the chat composer is empty, press Esc to prime “backtrack” mode. Press Esc again to open a transcript preview highlighting the last user message; press Esc repeatedly to step to older user messages. Press Enter to confirm and Codex will fork the conversation from that point, trim the visible transcript accordingly, and pre‑fill the composer with the selected user message so you can edit and resubmit it. In the transcript preview, the footer shows an `Esc edit prev` hint while editing is active. +#### `--cd`/`-C` flag + +Sometimes it is not convenient to `cd` to the directory you want Codex to use as the "working root" before running Codex. Fortunately, `codex` supports a `--cd` option so you can specify whatever folder you want. You can confirm that Codex is honoring `--cd` by double-checking the **workdir** it reports in the TUI at the start of a new session. + +#### `--add-dir` flag + +Need to work across multiple projects in one run? Pass `--add-dir` one or more times to expose extra directories as writable roots for the current session while keeping the main working directory unchanged. For example: + +```shell +codex --cd apps/frontend --add-dir ../backend --add-dir ../shared +``` + +Codex can then inspect and edit files in each listed directory without leaving the primary workspace. + #### Shell completions Generate shell completion scripts via: @@ -102,6 +113,11 @@ codex completion zsh codex completion fish ``` -#### `--cd`/`-C` flag +#### Image input -Sometimes it is not convenient to `cd` to the directory you want Codex to use as the "working root" before running Codex. Fortunately, `codex` supports a `--cd` option so you can specify whatever folder you want. You can confirm that Codex is honoring `--cd` by double-checking the **workdir** it reports in the TUI at the start of a new session. +Paste images directly into the composer (Ctrl+V / Cmd+V) to attach them to your prompt. You can also attach files via the CLI using `-i/--image` (comma‑separated): + +```bash +codex -i screenshot.png "Explain this error" +codex --image img1.png,img2.jpg "Summarize these diagrams" +``` diff --git a/docs/sandbox.md b/docs/sandbox.md index 369dced017..d08f4d85d2 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -4,22 +4,20 @@ What Codex is allowed to do is governed by a combination of **sandbox modes** (w ### Approval policies -We've chosen a powerful default for how Codex works on your computer: `Auto`. Under this approval policy, Codex can read files, make edits, and run commands in the working directory automatically. However, Codex will need your approval to work outside the working directory or access network. +Codex starts conservatively. Until you explicitly tell it a workspace is trusted, the CLI defaults to **read-only sandboxing** with the `read-only` approval preset. Codex can inspect files and answer questions, but every edit or command requires approval. -When you just want to chat, or if you want to plan before diving in, you can switch to `Read Only` mode with the `/approvals` command. +When you mark a workspace as trusted (for example via the onboarding prompt or `/approvals` → “Trust this directory”), Codex upgrades the default preset to **Auto**: sandboxed writes inside the workspace with `AskForApproval::OnRequest`. Codex only interrupts you when it needs to leave the workspace or rerun something outside the sandbox. -If you need Codex to read files, make edits, and run commands with network access, without approval, you can use `Full Access`. Exercise caution before doing so. +If you want maximum guardrails for a trusted repo, switch back to Read Only from the `/approvals` picker. If you truly need hands-off automation, use `Full Access`—but be deliberate, because that skips both the sandbox and approvals. #### Defaults and recommendations -- Codex runs in a sandbox by default with strong guardrails: it prevents editing files outside the workspace and blocks network access unless enabled. -- On launch, Codex detects whether the folder is version-controlled and recommends: - - Version-controlled folders: `Auto` (workspace write + on-request approvals) - - Non-version-controlled folders: `Read Only` -- The workspace includes the current directory and temporary directories like `/tmp`. Use the `/status` command to see which directories are in the workspace. -- You can set these explicitly: - - `codex --sandbox workspace-write --ask-for-approval on-request` +- Every session starts in a sandbox. Until a repo is trusted, Codex enforces read-only access and will prompt before any write or command. +- Marking a repo as trusted switches the default preset to Auto (`workspace-write` + `ask-for-approval on-request`) so Codex can keep iterating locally without nagging you. +- The workspace always includes the current directory plus temporary directories like `/tmp`. Use `/status` to confirm the exact writable roots. +- You can override the defaults from the command line at any time: - `codex --sandbox read-only --ask-for-approval on-request` + - `codex --sandbox workspace-write --ask-for-approval on-request` ### Can I run without ANY approvals? @@ -32,7 +30,7 @@ Yes, you can disable all approval prompts with `--ask-for-approval never`. This | Safe read-only browsing | `--sandbox read-only --ask-for-approval on-request` | Codex can read files and answer questions. Codex requires approval to make edits, run commands, or access network. | | Read-only non-interactive (CI) | `--sandbox read-only --ask-for-approval never` | Reads only; never escalates | | Let it edit the repo, ask if risky | `--sandbox workspace-write --ask-for-approval on-request` | Codex can read files, make edits, and run commands in the workspace. Codex requires approval for actions outside the workspace or for network access. | -| Auto (preset) | `--full-auto` (equivalent to `--sandbox workspace-write` + `--ask-for-approval on-failure`) | Codex can read files, make edits, and run commands in the workspace. Codex requires approval when a sandboxed command fails or needs escalation. | +| Auto (preset; trusted repos) | `--full-auto` (equivalent to `--sandbox workspace-write` + `--ask-for-approval on-request`) | Codex runs sandboxed commands that can write inside the workspace without prompting. Escalates only when it must leave the sandbox. | | YOLO (not recommended) | `--dangerously-bypass-approvals-and-sandbox` (alias: `--yolo`) | No sandbox; no prompts | > Note: In `workspace-write`, network is disabled by default unless enabled in config (`[sandbox_workspace_write].network_access = true`). From 0170860ef2a55a964dbbfd7c7f8746ac32ffa024 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Sun, 19 Oct 2025 17:41:55 -0700 Subject: [PATCH 029/287] [MCP] Prefix MCP tools names with `mcp__` (#5309) This should make it more clear that specific tools come from MCP servers. #4806 requested that we add the server name but we already do that. Fixes #4806 --- codex-rs/.gitignore | 1 + codex-rs/core/src/mcp_connection_manager.rs | 14 +++++++------- codex-rs/core/tests/suite/rmcp_client.rs | 8 ++++---- codex-rs/tui/src/history_cell.rs | 2 +- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/codex-rs/.gitignore b/codex-rs/.gitignore index e996253768..e31566047d 100644 --- a/codex-rs/.gitignore +++ b/codex-rs/.gitignore @@ -1,4 +1,5 @@ /target/ +/target-*/ # Recommended value of CARGO_TARGET_DIR when using Docker as explained in .devcontainer/README.md. /target-amd64/ diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 99c2e626e5..9ea634428c 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -65,8 +65,8 @@ fn qualify_tools(tools: Vec) -> HashMap { let mut qualified_tools = HashMap::new(); for tool in tools { let mut qualified_name = format!( - "{}{}{}", - tool.server_name, MCP_TOOL_NAME_DELIMITER, tool.tool_name + "mcp{}{}{}{}", + MCP_TOOL_NAME_DELIMITER, tool.server_name, MCP_TOOL_NAME_DELIMITER, tool.tool_name ); if qualified_name.len() > MAX_TOOL_NAME_LENGTH { let mut hasher = Sha1::new(); @@ -741,8 +741,8 @@ mod tests { let qualified_tools = qualify_tools(tools); assert_eq!(qualified_tools.len(), 2); - assert!(qualified_tools.contains_key("server1__tool1")); - assert!(qualified_tools.contains_key("server1__tool2")); + assert!(qualified_tools.contains_key("mcp__server1__tool1")); + assert!(qualified_tools.contains_key("mcp__server1__tool2")); } #[test] @@ -756,7 +756,7 @@ mod tests { // Only the first tool should remain, the second is skipped assert_eq!(qualified_tools.len(), 1); - assert!(qualified_tools.contains_key("server1__duplicate_tool")); + assert!(qualified_tools.contains_key("mcp__server1__duplicate_tool")); } #[test] @@ -784,13 +784,13 @@ mod tests { assert_eq!(keys[0].len(), 64); assert_eq!( keys[0], - "my_server__extremely_lena02e507efc5a9de88637e436690364fd4219e4ef" + "mcp__my_server__extremel119a2b97664e41363932dc84de21e2ff1b93b3e9" ); assert_eq!(keys[1].len(), 64); assert_eq!( keys[1], - "my_server__yet_another_e1c3987bd9c50b826cbe1687966f79f0c602d19ca" + "mcp__my_server__yet_anot419a82a89325c1b477274a41f8c65ea5f3a7f341" ); } } diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 1a2815b0ee..ab702b5d91 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -44,7 +44,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { let call_id = "call-123"; let server_name = "rmcp"; - let tool_name = format!("{server_name}__echo"); + let tool_name = format!("mcp__{server_name}__echo"); mount_sse_once_match( &server, @@ -182,7 +182,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { let call_id = "call-1234"; let server_name = "rmcp_whitelist"; - let tool_name = format!("{server_name}__echo"); + let tool_name = format!("mcp__{server_name}__echo"); mount_sse_once_match( &server, @@ -317,7 +317,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { let call_id = "call-456"; let server_name = "rmcp_http"; - let tool_name = format!("{server_name}__echo"); + let tool_name = format!("mcp__{server_name}__echo"); mount_sse_once_match( &server, @@ -485,7 +485,7 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> { let call_id = "call-789"; let server_name = "rmcp_http_oauth"; - let tool_name = format!("{server_name}__echo"); + let tool_name = format!("mcp__{server_name}__echo"); mount_sse_once_match( &server, diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 0a3582577d..5df687b998 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1001,7 +1001,7 @@ pub(crate) fn new_mcp_tools_output( } for (server, cfg) in config.mcp_servers.iter() { - let prefix = format!("{server}__"); + let prefix = format!("mcp__{server}__"); let mut names: Vec = tools .keys() .filter(|k| k.starts_with(&prefix)) From d01f91ecec0803a35773c9903eda61a73a777b84 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sun, 19 Oct 2025 21:12:45 -0700 Subject: [PATCH 030/287] feat: experimental `codex stdio-to-uds` subcommand (#5350) --- codex-rs/Cargo.lock | 12 ++++ codex-rs/Cargo.toml | 5 +- codex-rs/cli/Cargo.toml | 9 +-- codex-rs/cli/src/main.rs | 16 +++++ codex-rs/stdio-to-uds/Cargo.toml | 26 ++++++++ codex-rs/stdio-to-uds/README.md | 20 ++++++ codex-rs/stdio-to-uds/src/lib.rs | 52 ++++++++++++++++ codex-rs/stdio-to-uds/src/main.rs | 19 ++++++ codex-rs/stdio-to-uds/tests/stdio_to_uds.rs | 68 +++++++++++++++++++++ 9 files changed, 222 insertions(+), 5 deletions(-) create mode 100644 codex-rs/stdio-to-uds/Cargo.toml create mode 100644 codex-rs/stdio-to-uds/README.md create mode 100644 codex-rs/stdio-to-uds/src/lib.rs create mode 100644 codex-rs/stdio-to-uds/src/main.rs create mode 100644 codex-rs/stdio-to-uds/tests/stdio_to_uds.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9d18361cf8..0ecada0fb3 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -968,6 +968,7 @@ dependencies = [ "codex-protocol-ts", "codex-responses-api-proxy", "codex-rmcp-client", + "codex-stdio-to-uds", "codex-tui", "ctor 0.5.0", "owo-colors", @@ -1397,6 +1398,17 @@ dependencies = [ "webbrowser", ] +[[package]] +name = "codex-stdio-to-uds" +version = "0.0.0" +dependencies = [ + "anyhow", + "assert_cmd", + "pretty_assertions", + "tempfile", + "uds_windows", +] + [[package]] name = "codex-tui" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 64eebb62a1..5286ef8702 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -29,6 +29,7 @@ members = [ "protocol-ts", "rmcp-client", "responses-api-proxy", + "stdio-to-uds", "otel", "tui", "git-apply", @@ -54,10 +55,10 @@ codex-app-server = { path = "app-server" } codex-app-server-protocol = { path = "app-server-protocol" } codex-apply-patch = { path = "apply-patch" } codex-arg0 = { path = "arg0" } +codex-async-utils = { path = "async-utils" } codex-chatgpt = { path = "chatgpt" } codex-common = { path = "common" } codex-core = { path = "core" } -codex-async-utils = { path = "async-utils" } codex-exec = { path = "exec" } codex-feedback = { path = "feedback" } codex-file-search = { path = "file-search" } @@ -73,6 +74,7 @@ codex-protocol = { path = "protocol" } codex-protocol-ts = { path = "protocol-ts" } codex-responses-api-proxy = { path = "responses-api-proxy" } codex-rmcp-client = { path = "rmcp-client" } +codex-stdio-to-uds = { path = "stdio-to-uds" } codex-tui = { path = "tui" } codex-utils-json-to-toml = { path = "utils/json-to-toml" } codex-utils-readiness = { path = "utils/readiness" } @@ -186,6 +188,7 @@ tree-sitter = "0.25.10" tree-sitter-bash = "0.25" tree-sitter-highlight = "0.25.10" ts-rs = "11" +uds_windows = "1.1.0" unicode-segmentation = "1.12.0" unicode-width = "0.2" url = "2" diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 453ab80771..d66678687b 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -19,8 +19,10 @@ anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } clap_complete = { workspace = true } codex-app-server = { workspace = true } +codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } codex-chatgpt = { workspace = true } +codex-cloud-tasks = { path = "../cloud-tasks" } codex-common = { workspace = true, features = ["cli"] } codex-core = { workspace = true } codex-exec = { workspace = true } @@ -28,12 +30,11 @@ codex-login = { workspace = true } codex-mcp-server = { workspace = true } codex-process-hardening = { workspace = true } codex-protocol = { workspace = true } -codex-app-server-protocol = { workspace = true } codex-protocol-ts = { workspace = true } codex-responses-api-proxy = { workspace = true } -codex-tui = { workspace = true } codex-rmcp-client = { workspace = true } -codex-cloud-tasks = { path = "../cloud-tasks" } +codex-stdio-to-uds = { workspace = true } +codex-tui = { workspace = true } ctor = { workspace = true } owo-colors = { workspace = true } serde_json = { workspace = true } @@ -47,8 +48,8 @@ tokio = { workspace = true, features = [ ] } [dev-dependencies] -assert_matches = { workspace = true } assert_cmd = { workspace = true } +assert_matches = { workspace = true } predicates = { workspace = true } pretty_assertions = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 30f7a91b14..3993503412 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -105,6 +105,10 @@ enum Subcommand { #[clap(hide = true)] ResponsesApiProxy(ResponsesApiProxyArgs), + /// Internal: relay stdio to a Unix domain socket. + #[clap(hide = true, name = "stdio-to-uds")] + StdioToUds(StdioToUdsCommand), + /// Inspect feature flags. Features(FeaturesCli), } @@ -206,6 +210,13 @@ struct GenerateTsCommand { prettier: Option, } +#[derive(Debug, Parser)] +struct StdioToUdsCommand { + /// Path to the Unix domain socket to connect to. + #[arg(value_name = "SOCKET_PATH")] + socket_path: PathBuf, +} + fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec { let AppExitInfo { token_usage, @@ -463,6 +474,11 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() tokio::task::spawn_blocking(move || codex_responses_api_proxy::run_main(args)) .await??; } + Some(Subcommand::StdioToUds(cmd)) => { + let socket_path = cmd.socket_path; + tokio::task::spawn_blocking(move || codex_stdio_to_uds::run(socket_path.as_path())) + .await??; + } Some(Subcommand::GenerateTs(gen_cli)) => { codex_protocol_ts::generate_ts(&gen_cli.out_dir, gen_cli.prettier.as_deref())?; } diff --git a/codex-rs/stdio-to-uds/Cargo.toml b/codex-rs/stdio-to-uds/Cargo.toml new file mode 100644 index 0000000000..4f7132883d --- /dev/null +++ b/codex-rs/stdio-to-uds/Cargo.toml @@ -0,0 +1,26 @@ +[package] +edition = "2024" +name = "codex-stdio-to-uds" +version = { workspace = true } + +[[bin]] +name = "codex-stdio-to-uds" +path = "src/main.rs" + +[lib] +name = "codex_stdio_to_uds" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } + +[target.'cfg(target_os = "windows")'.dependencies] +uds_windows = { workspace = true } + +[dev-dependencies] +assert_cmd = { workspace = true } +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/stdio-to-uds/README.md b/codex-rs/stdio-to-uds/README.md new file mode 100644 index 0000000000..96ac67960b --- /dev/null +++ b/codex-rs/stdio-to-uds/README.md @@ -0,0 +1,20 @@ +# codex-stdio-to-uds + +Traditionally, there are two transport mechanisms for an MCP server: stdio and HTTP. + +This crate helps enable a third, which is UNIX domain socket, because it has the advantages that: + +- The UDS can be attached to long-running process, like an HTTP server. +- The UDS can leverage UNIX file permissions to restrict access. + +To that end, this crate provides an adapter between a UDS and stdio. The idea is that someone could start an MCP server that communicates over `/tmp/mcp.sock`. Then the user could specify this on the fly like so: + +``` +codex --config mcp_servers.example={command="codex-stdio-to-uds",args=["/tmp/mcp.sock"]} +``` + +Unfortunately, the Rust standard library does not provide support for UNIX domain sockets on Windows today even though support was added in October 2018 in Windows 10: + +https://github.com/rust-lang/rust/issues/56533 + +As a workaround, this crate leverages https://crates.io/crates/uds_windows as a dependency on Windows. diff --git a/codex-rs/stdio-to-uds/src/lib.rs b/codex-rs/stdio-to-uds/src/lib.rs new file mode 100644 index 0000000000..1190688844 --- /dev/null +++ b/codex-rs/stdio-to-uds/src/lib.rs @@ -0,0 +1,52 @@ +#![deny(clippy::print_stdout)] + +use std::io; +use std::io::Write; +use std::net::Shutdown; +use std::path::Path; +use std::thread; + +use anyhow::Context; +use anyhow::anyhow; + +#[cfg(unix)] +use std::os::unix::net::UnixStream; + +#[cfg(windows)] +use uds_windows::UnixStream; + +/// Connects to the Unix Domain Socket at `socket_path` and relays data between +/// standard input/output and the socket. +pub fn run(socket_path: &Path) -> anyhow::Result<()> { + let mut stream = UnixStream::connect(socket_path) + .with_context(|| format!("failed to connect to socket at {}", socket_path.display()))?; + + let mut reader = stream + .try_clone() + .context("failed to clone socket for reading")?; + + let stdout_thread = thread::spawn(move || -> io::Result<()> { + let stdout = io::stdout(); + let mut handle = stdout.lock(); + io::copy(&mut reader, &mut handle)?; + handle.flush()?; + Ok(()) + }); + + let stdin = io::stdin(); + { + let mut handle = stdin.lock(); + io::copy(&mut handle, &mut stream).context("failed to copy data from stdin to socket")?; + } + + stream + .shutdown(Shutdown::Write) + .context("failed to shutdown socket writer")?; + + let stdout_result = stdout_thread + .join() + .map_err(|_| anyhow!("thread panicked while copying socket data to stdout"))?; + stdout_result.context("failed to copy data from socket to stdout")?; + + Ok(()) +} diff --git a/codex-rs/stdio-to-uds/src/main.rs b/codex-rs/stdio-to-uds/src/main.rs new file mode 100644 index 0000000000..69987c31be --- /dev/null +++ b/codex-rs/stdio-to-uds/src/main.rs @@ -0,0 +1,19 @@ +use std::env; +use std::path::PathBuf; +use std::process; + +fn main() -> anyhow::Result<()> { + let mut args = env::args_os().skip(1); + let Some(socket_path) = args.next() else { + eprintln!("Usage: codex-stdio-to-uds "); + process::exit(1); + }; + + if args.next().is_some() { + eprintln!("Expected exactly one argument: "); + process::exit(1); + } + + let socket_path = PathBuf::from(socket_path); + codex_stdio_to_uds::run(&socket_path) +} diff --git a/codex-rs/stdio-to-uds/tests/stdio_to_uds.rs b/codex-rs/stdio-to-uds/tests/stdio_to_uds.rs new file mode 100644 index 0000000000..11888a8134 --- /dev/null +++ b/codex-rs/stdio-to-uds/tests/stdio_to_uds.rs @@ -0,0 +1,68 @@ +use std::io::ErrorKind; +use std::io::Read; +use std::io::Write; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +use anyhow::Context; +use assert_cmd::Command; +use pretty_assertions::assert_eq; + +#[cfg(unix)] +use std::os::unix::net::UnixListener; + +#[cfg(windows)] +use uds_windows::UnixListener; + +#[test] +fn pipes_stdin_and_stdout_through_socket() -> anyhow::Result<()> { + let dir = tempfile::TempDir::new().context("failed to create temp dir")?; + let socket_path = dir.path().join("socket"); + let listener = match UnixListener::bind(&socket_path) { + Ok(listener) => listener, + Err(err) if err.kind() == ErrorKind::PermissionDenied => { + eprintln!("skipping test: failed to bind unix socket: {err}"); + return Ok(()); + } + Err(err) => { + return Err(err).context("failed to bind test unix socket"); + } + }; + + let (tx, rx) = mpsc::channel(); + let server_thread = thread::spawn(move || -> anyhow::Result<()> { + let (mut connection, _) = listener + .accept() + .context("failed to accept test connection")?; + let mut received = Vec::new(); + connection + .read_to_end(&mut received) + .context("failed to read data from client")?; + tx.send(received) + .map_err(|_| anyhow::anyhow!("failed to send received bytes to test thread"))?; + connection + .write_all(b"response") + .context("failed to write response to client")?; + Ok(()) + }); + + Command::cargo_bin("codex-stdio-to-uds")? + .arg(&socket_path) + .write_stdin("request") + .assert() + .success() + .stdout("response"); + + let received = rx + .recv_timeout(Duration::from_secs(1)) + .context("server did not receive data in time")?; + assert_eq!(received, b"request"); + + let server_result = server_thread + .join() + .map_err(|_| anyhow::anyhow!("server thread panicked"))?; + server_result.context("server failed")?; + + Ok(()) +} From d87f87e25b6711f0268cfd884fa28555c6c46093 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Mon, 20 Oct 2025 08:50:54 -0700 Subject: [PATCH 031/287] Add forced_chatgpt_workspace_id and forced_login_method configuration options (#5303) This PR adds support for configs to specify a forced login method (chatgpt or api) as well as a forced chatgpt account id. This lets enterprises uses [managed configs](https://developers.openai.com/codex/security#managed-configuration) to force all employees to use their company's workspace instead of their own or any other. When a workspace id is set, a query param is sent to the login flow which auto-selects the given workspace or errors if the user isn't a member of it. This PR is large but a large % of it is tests, wiring, and required formatting changes. API login with chatgpt forced CleanShot 2025-10-19 at 22 40 04 ChatGPT login with api forced CleanShot 2025-10-19 at 22 40 29 Onboarding with api forced CleanShot 2025-10-19 at 22 41 02 Onboarding with ChatGPT forced CleanShot 2025-10-19 at 22 41 27 Logging in with the wrong workspace CleanShot 2025-10-19 at 22 42 31 --- codex-rs/app-server-protocol/src/protocol.rs | 6 + .../app-server/src/codex_message_processor.rs | 30 +- codex-rs/app-server/tests/suite/auth.rs | 49 ++++ codex-rs/app-server/tests/suite/config.rs | 7 + codex-rs/app-server/tests/suite/login.rs | 95 ++++++ codex-rs/cli/src/login.rs | 32 +- codex-rs/core/src/auth.rs | 273 ++++++++++++++++-- codex-rs/core/src/config.rs | 39 +++ codex-rs/core/src/token_data.rs | 23 +- codex-rs/exec/src/lib.rs | 6 + codex-rs/login/src/device_code_auth.rs | 7 + codex-rs/login/src/server.rs | 98 ++++++- .../login/tests/suite/device_code_login.rs | 54 +++- .../login/tests/suite/login_server_e2e.rs | 80 ++++- codex-rs/protocol/src/config_types.rs | 8 + codex-rs/tui/src/lib.rs | 7 + codex-rs/tui/src/onboarding/auth.rs | 143 ++++++++- .../tui/src/onboarding/onboarding_screen.rs | 11 +- docs/config.md | 18 ++ 19 files changed, 920 insertions(+), 66 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol.rs b/codex-rs/app-server-protocol/src/protocol.rs index 916560c60b..63b11829cd 100644 --- a/codex-rs/app-server-protocol/src/protocol.rs +++ b/codex-rs/app-server-protocol/src/protocol.rs @@ -5,6 +5,7 @@ use crate::JSONRPCNotification; use crate::JSONRPCRequest; use crate::RequestId; use codex_protocol::ConversationId; +use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; @@ -473,6 +474,11 @@ pub struct UserSavedConfig { #[serde(skip_serializing_if = "Option::is_none")] pub sandbox_settings: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub forced_chatgpt_workspace_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub forced_login_method: Option, + /// Model-specific configuration #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 23f1a37891..40e6a45fa3 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -84,6 +84,7 @@ use codex_login::ServerOptions as LoginServerOptions; use codex_login::ShutdownHandle; use codex_login::run_login_server; use codex_protocol::ConversationId; +use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::InputMessageKind; @@ -243,6 +244,19 @@ impl CodexMessageProcessor { } async fn login_api_key(&mut self, request_id: RequestId, params: LoginApiKeyParams) { + if matches!( + self.config.forced_login_method, + Some(ForcedLoginMethod::Chatgpt) + ) { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "API key login is disabled. Use ChatGPT login instead.".to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + { let mut guard = self.active_login.lock().await; if let Some(active) = guard.take() { @@ -278,9 +292,23 @@ impl CodexMessageProcessor { async fn login_chatgpt(&mut self, request_id: RequestId) { let config = self.config.as_ref(); + if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "ChatGPT login is disabled. Use API key login instead.".to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + let opts = LoginServerOptions { open_browser: false, - ..LoginServerOptions::new(config.codex_home.clone(), CLIENT_ID.to_string()) + ..LoginServerOptions::new( + config.codex_home.clone(), + CLIENT_ID.to_string(), + config.forced_chatgpt_workspace_id.clone(), + ) }; enum LoginChatGptReply { diff --git a/codex-rs/app-server/tests/suite/auth.rs b/codex-rs/app-server/tests/suite/auth.rs index f45a27fda0..aa6cc1bb7d 100644 --- a/codex-rs/app-server/tests/suite/auth.rs +++ b/codex-rs/app-server/tests/suite/auth.rs @@ -5,6 +5,7 @@ use app_test_support::to_response; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetAuthStatusResponse; +use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::LoginApiKeyParams; use codex_app_server_protocol::LoginApiKeyResponse; @@ -57,6 +58,19 @@ sandbox_mode = "danger-full-access" ) } +fn create_config_toml_forced_login(codex_home: &Path, forced_method: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + let contents = format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "danger-full-access" +forced_login_method = "{forced_method}" +"# + ); + std::fs::write(config_toml, contents) +} + async fn login_with_api_key_via_request(mcp: &mut McpProcess, api_key: &str) { let request_id = mcp .send_login_api_key_request(LoginApiKeyParams { @@ -221,3 +235,38 @@ async fn get_auth_status_with_api_key_no_include_token() { assert_eq!(status.auth_method, Some(AuthMode::ApiKey)); assert!(status.auth_token.is_none(), "token must be omitted"); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn login_api_key_rejected_when_forced_chatgpt() { + let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}")); + create_config_toml_forced_login(codex_home.path(), "chatgpt") + .unwrap_or_else(|err| panic!("write config.toml: {err}")); + + let mut mcp = McpProcess::new(codex_home.path()) + .await + .expect("spawn mcp process"); + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) + .await + .expect("init timeout") + .expect("init failed"); + + let request_id = mcp + .send_login_api_key_request(LoginApiKeyParams { + api_key: "sk-test-key".to_string(), + }) + .await + .expect("send loginApiKey"); + + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await + .expect("loginApiKey error timeout") + .expect("loginApiKey error"); + + assert_eq!( + err.error.message, + "API key login is disabled. Use ChatGPT login instead." + ); +} diff --git a/codex-rs/app-server/tests/suite/config.rs b/codex-rs/app-server/tests/suite/config.rs index 577eeb388d..2809d68ca4 100644 --- a/codex-rs/app-server/tests/suite/config.rs +++ b/codex-rs/app-server/tests/suite/config.rs @@ -11,6 +11,7 @@ use codex_app_server_protocol::SandboxSettings; use codex_app_server_protocol::Tools; use codex_app_server_protocol::UserSavedConfig; use codex_core::protocol::AskForApproval; +use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; @@ -33,6 +34,8 @@ model_reasoning_summary = "detailed" model_reasoning_effort = "high" model_verbosity = "medium" profile = "test" +forced_chatgpt_workspace_id = "12345678-0000-0000-0000-000000000000" +forced_login_method = "chatgpt" [sandbox_workspace_write] writable_roots = ["/tmp"] @@ -92,6 +95,8 @@ async fn get_config_toml_parses_all_fields() { exclude_tmpdir_env_var: Some(true), exclude_slash_tmp: Some(true), }), + forced_chatgpt_workspace_id: Some("12345678-0000-0000-0000-000000000000".into()), + forced_login_method: Some(ForcedLoginMethod::Chatgpt), model: Some("gpt-5-codex".into()), model_reasoning_effort: Some(ReasoningEffort::High), model_reasoning_summary: Some(ReasoningSummary::Detailed), @@ -149,6 +154,8 @@ async fn get_config_toml_empty() { approval_policy: None, sandbox_mode: None, sandbox_settings: None, + forced_chatgpt_workspace_id: None, + forced_login_method: None, model: None, model_reasoning_effort: None, model_reasoning_summary: None, diff --git a/codex-rs/app-server/tests/suite/login.rs b/codex-rs/app-server/tests/suite/login.rs index 6dcbde1125..220769b7d6 100644 --- a/codex-rs/app-server/tests/suite/login.rs +++ b/codex-rs/app-server/tests/suite/login.rs @@ -7,6 +7,7 @@ use codex_app_server_protocol::CancelLoginChatGptParams; use codex_app_server_protocol::CancelLoginChatGptResponse; use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetAuthStatusResponse; +use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::LoginChatGptResponse; use codex_app_server_protocol::LogoutChatGptResponse; @@ -144,3 +145,97 @@ async fn login_and_cancel_chatgpt() { eprintln!("warning: did not observe login_chat_gpt_complete notification after cancel"); } } + +fn create_config_toml_forced_login(codex_home: &Path, forced_method: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + let contents = format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "danger-full-access" +forced_login_method = "{forced_method}" +"# + ); + std::fs::write(config_toml, contents) +} + +fn create_config_toml_forced_workspace( + codex_home: &Path, + workspace_id: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + let contents = format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "danger-full-access" +forced_chatgpt_workspace_id = "{workspace_id}" +"# + ); + std::fs::write(config_toml, contents) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn login_chatgpt_rejected_when_forced_api() { + let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}")); + create_config_toml_forced_login(codex_home.path(), "api") + .unwrap_or_else(|err| panic!("write config.toml: {err}")); + + let mut mcp = McpProcess::new(codex_home.path()) + .await + .expect("spawn mcp process"); + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) + .await + .expect("init timeout") + .expect("init failed"); + + let request_id = mcp + .send_login_chat_gpt_request() + .await + .expect("send loginChatGpt"); + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await + .expect("loginChatGpt error timeout") + .expect("loginChatGpt error"); + + assert_eq!( + err.error.message, + "ChatGPT login is disabled. Use API key login instead." + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn login_chatgpt_includes_forced_workspace_query_param() { + let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}")); + create_config_toml_forced_workspace(codex_home.path(), "ws-forced") + .unwrap_or_else(|err| panic!("write config.toml: {err}")); + + let mut mcp = McpProcess::new(codex_home.path()) + .await + .expect("spawn mcp process"); + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) + .await + .expect("init timeout") + .expect("init failed"); + + let request_id = mcp + .send_login_chat_gpt_request() + .await + .expect("send loginChatGpt"); + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await + .expect("loginChatGpt timeout") + .expect("loginChatGpt response"); + + let login: LoginChatGptResponse = to_response(resp).expect("deserialize login resp"); + assert!( + login.auth_url.contains("allowed_workspace_id=ws-forced"), + "auth URL should include forced workspace" + ); +} diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index 5e69ede69d..2e2f3f074d 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -9,12 +9,20 @@ use codex_core::config::ConfigOverrides; use codex_login::ServerOptions; use codex_login::run_device_code_login; use codex_login::run_login_server; +use codex_protocol::config_types::ForcedLoginMethod; use std::io::IsTerminal; use std::io::Read; use std::path::PathBuf; -pub async fn login_with_chatgpt(codex_home: PathBuf) -> std::io::Result<()> { - let opts = ServerOptions::new(codex_home, CLIENT_ID.to_string()); +pub async fn login_with_chatgpt( + codex_home: PathBuf, + forced_chatgpt_workspace_id: Option, +) -> std::io::Result<()> { + let opts = ServerOptions::new( + codex_home, + CLIENT_ID.to_string(), + forced_chatgpt_workspace_id, + ); let server = run_login_server(opts)?; eprintln!( @@ -28,7 +36,14 @@ pub async fn login_with_chatgpt(codex_home: PathBuf) -> std::io::Result<()> { pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides).await; - match login_with_chatgpt(config.codex_home).await { + if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { + eprintln!("ChatGPT login is disabled. Use API key login instead."); + std::process::exit(1); + } + + let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); + + match login_with_chatgpt(config.codex_home, forced_chatgpt_workspace_id).await { Ok(_) => { eprintln!("Successfully logged in"); std::process::exit(0); @@ -46,6 +61,11 @@ pub async fn run_login_with_api_key( ) -> ! { let config = load_config_or_exit(cli_config_overrides).await; + if matches!(config.forced_login_method, Some(ForcedLoginMethod::Chatgpt)) { + eprintln!("API key login is disabled. Use ChatGPT login instead."); + std::process::exit(1); + } + match login_with_api_key(&config.codex_home, &api_key) { Ok(_) => { eprintln!("Successfully logged in"); @@ -92,9 +112,15 @@ pub async fn run_login_with_device_code( client_id: Option, ) -> ! { let config = load_config_or_exit(cli_config_overrides).await; + if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { + eprintln!("ChatGPT login is disabled. Use API key login instead."); + std::process::exit(1); + } + let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); let mut opts = ServerOptions::new( config.codex_home, client_id.unwrap_or(CLIENT_ID.to_string()), + forced_chatgpt_workspace_id, ); if let Some(iss) = issuer_base_url { opts.issuer = iss; diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index 21cb4bf6d6..92074c66ee 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -2,6 +2,8 @@ use chrono::DateTime; use chrono::Utc; use serde::Deserialize; use serde::Serialize; +#[cfg(test)] +use serial_test::serial; use std::env; use std::fs::File; use std::fs::OpenOptions; @@ -16,7 +18,9 @@ use std::sync::Mutex; use std::time::Duration; use codex_app_server_protocol::AuthMode; +use codex_protocol::config_types::ForcedLoginMethod; +use crate::config::Config; use crate::token_data::PlanType; use crate::token_data::TokenData; use crate::token_data::parse_id_token; @@ -233,6 +237,74 @@ pub fn login_with_api_key(codex_home: &Path, api_key: &str) -> std::io::Result<( write_auth_json(&get_auth_file(codex_home), &auth_dot_json) } +pub async fn enforce_login_restrictions(config: &Config) -> std::io::Result<()> { + let Some(auth) = load_auth(&config.codex_home, true)? else { + return Ok(()); + }; + + if let Some(required_method) = config.forced_login_method { + let method_violation = match (required_method, auth.mode) { + (ForcedLoginMethod::Api, AuthMode::ApiKey) => None, + (ForcedLoginMethod::Chatgpt, AuthMode::ChatGPT) => None, + (ForcedLoginMethod::Api, AuthMode::ChatGPT) => Some( + "API key login is required, but ChatGPT is currently being used. Logging out." + .to_string(), + ), + (ForcedLoginMethod::Chatgpt, AuthMode::ApiKey) => Some( + "ChatGPT login is required, but an API key is currently being used. Logging out." + .to_string(), + ), + }; + + if let Some(message) = method_violation { + return logout_with_message(&config.codex_home, message); + } + } + + if let Some(expected_account_id) = config.forced_chatgpt_workspace_id.as_deref() { + if auth.mode != AuthMode::ChatGPT { + return Ok(()); + } + + let token_data = match auth.get_token_data().await { + Ok(data) => data, + Err(err) => { + return logout_with_message( + &config.codex_home, + format!( + "Failed to load ChatGPT credentials while enforcing workspace restrictions: {err}. Logging out." + ), + ); + } + }; + + // workspace is the external identifier for account id. + let chatgpt_account_id = token_data.id_token.chatgpt_account_id.as_deref(); + if chatgpt_account_id != Some(expected_account_id) { + let message = match chatgpt_account_id { + Some(actual) => format!( + "Login is restricted to workspace {expected_account_id}, but current credentials belong to {actual}. Logging out." + ), + None => format!( + "Login is restricted to workspace {expected_account_id}, but current credentials lack a workspace identifier. Logging out." + ), + }; + return logout_with_message(&config.codex_home, message); + } + } + + Ok(()) +} + +fn logout_with_message(codex_home: &Path, message: String) -> std::io::Result<()> { + match logout(codex_home) { + Ok(_) => Err(std::io::Error::other(message)), + Err(err) => Err(std::io::Error::other(format!( + "{message}. Failed to remove auth.json: {err}" + ))), + } +} + fn load_auth( codex_home: &Path, enable_codex_api_key_env: bool, @@ -249,9 +321,8 @@ fn load_auth( let client = crate::default_client::create_client(); let auth_dot_json = match try_read_auth_json(&auth_file) { Ok(auth) => auth, - Err(e) => { - return Err(e); - } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err), }; let AuthDotJson { @@ -403,17 +474,19 @@ struct CachedAuth { #[cfg(test)] mod tests { use super::*; + use crate::config::Config; + use crate::config::ConfigOverrides; + use crate::config::ConfigToml; use crate::token_data::IdTokenInfo; use crate::token_data::KnownPlan; use crate::token_data::PlanType; use base64::Engine; + use codex_protocol::config_types::ForcedLoginMethod; use pretty_assertions::assert_eq; use serde::Serialize; use serde_json::json; use tempfile::tempdir; - const LAST_REFRESH: &str = "2025-08-06T20:41:36.232376Z"; - #[tokio::test] async fn roundtrip_auth_dot_json() { let codex_home = tempdir().unwrap(); @@ -421,6 +494,7 @@ mod tests { AuthFileParams { openai_api_key: None, chatgpt_plan_type: "pro".to_string(), + chatgpt_account_id: None, }, codex_home.path(), ) @@ -460,6 +534,13 @@ mod tests { assert!(auth.tokens.is_none(), "tokens should be cleared"); } + #[test] + fn missing_auth_json_returns_none() { + let dir = tempdir().unwrap(); + let auth = CodexAuth::from_codex_home(dir.path()).expect("call should succeed"); + assert_eq!(auth, None); + } + #[tokio::test] async fn pro_account_with_no_api_key_uses_chatgpt_auth() { let codex_home = tempdir().unwrap(); @@ -467,6 +548,7 @@ mod tests { AuthFileParams { openai_api_key: None, chatgpt_plan_type: "pro".to_string(), + chatgpt_account_id: None, }, codex_home.path(), ) @@ -484,6 +566,10 @@ mod tests { let guard = auth_dot_json.lock().unwrap(); let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist"); + let last_refresh = auth_dot_json + .last_refresh + .expect("last_refresh should be recorded"); + assert_eq!( &AuthDotJson { openai_api_key: None, @@ -491,20 +577,17 @@ mod tests { id_token: IdTokenInfo { email: Some("user@example.com".to_string()), chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)), + chatgpt_account_id: None, raw_jwt: fake_jwt, }, access_token: "test-access-token".to_string(), refresh_token: "test-refresh-token".to_string(), account_id: None, }), - last_refresh: Some( - DateTime::parse_from_rfc3339(LAST_REFRESH) - .unwrap() - .with_timezone(&Utc) - ), + last_refresh: Some(last_refresh), }, auth_dot_json - ) + ); } #[tokio::test] @@ -543,6 +626,7 @@ mod tests { struct AuthFileParams { openai_api_key: Option, chatgpt_plan_type: String, + chatgpt_account_id: Option, } fn write_auth_file(params: AuthFileParams, codex_home: &Path) -> std::io::Result { @@ -557,15 +641,21 @@ mod tests { alg: "none", typ: "JWT", }; + let mut auth_payload = serde_json::json!({ + "chatgpt_plan_type": params.chatgpt_plan_type, + "chatgpt_user_id": "user-12345", + "user_id": "user-12345", + }); + + if let Some(chatgpt_account_id) = params.chatgpt_account_id { + let org_value = serde_json::Value::String(chatgpt_account_id); + auth_payload["chatgpt_account_id"] = org_value; + } + let payload = serde_json::json!({ "email": "user@example.com", "email_verified": true, - "https://api.openai.com/auth": { - "chatgpt_account_id": "bc3618e3-489d-4d49-9362-1561dc53ba53", - "chatgpt_plan_type": params.chatgpt_plan_type, - "chatgpt_user_id": "user-12345", - "user_id": "user-12345", - } + "https://api.openai.com/auth": auth_payload, }); let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b); let header_b64 = b64(&serde_json::to_vec(&header)?); @@ -580,12 +670,159 @@ mod tests { "access_token": "test-access-token", "refresh_token": "test-refresh-token" }, - "last_refresh": LAST_REFRESH, + "last_refresh": Utc::now(), }); let auth_json = serde_json::to_string_pretty(&auth_json_data)?; std::fs::write(auth_file, auth_json)?; Ok(fake_jwt) } + + fn build_config( + codex_home: &Path, + forced_login_method: Option, + forced_chatgpt_workspace_id: Option, + ) -> Config { + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + codex_home.to_path_buf(), + ) + .expect("config should load"); + config.forced_login_method = forced_login_method; + config.forced_chatgpt_workspace_id = forced_chatgpt_workspace_id; + config + } + + /// Use sparingly. + /// TODO (gpeal): replace this with an injectable env var provider. + #[cfg(test)] + struct EnvVarGuard { + key: &'static str, + original: Option, + } + + #[cfg(test)] + 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 } + } + } + + #[cfg(test)] + 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), + } + } + } + } + + #[tokio::test] + async fn enforce_login_restrictions_logs_out_for_method_mismatch() { + let codex_home = tempdir().unwrap(); + login_with_api_key(codex_home.path(), "sk-test").expect("seed api key"); + + let config = build_config(codex_home.path(), Some(ForcedLoginMethod::Chatgpt), None); + + let err = super::enforce_login_restrictions(&config) + .await + .expect_err("expected method mismatch to error"); + assert!(err.to_string().contains("ChatGPT login is required")); + assert!( + !codex_home.path().join("auth.json").exists(), + "auth.json should be removed on mismatch" + ); + } + + #[tokio::test] + async fn enforce_login_restrictions_logs_out_for_workspace_mismatch() { + let codex_home = tempdir().unwrap(); + let _jwt = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: "pro".to_string(), + chatgpt_account_id: Some("org_another_org".to_string()), + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let config = build_config(codex_home.path(), None, Some("org_mine".to_string())); + + let err = super::enforce_login_restrictions(&config) + .await + .expect_err("expected workspace mismatch to error"); + assert!(err.to_string().contains("workspace org_mine")); + assert!( + !codex_home.path().join("auth.json").exists(), + "auth.json should be removed on mismatch" + ); + } + + #[tokio::test] + async fn enforce_login_restrictions_allows_matching_workspace() { + let codex_home = tempdir().unwrap(); + let _jwt = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: "pro".to_string(), + chatgpt_account_id: Some("org_mine".to_string()), + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let config = build_config(codex_home.path(), None, Some("org_mine".to_string())); + + super::enforce_login_restrictions(&config) + .await + .expect("matching workspace should succeed"); + assert!( + codex_home.path().join("auth.json").exists(), + "auth.json should remain when restrictions pass" + ); + } + + #[tokio::test] + async fn enforce_login_restrictions_allows_api_key_if_login_method_not_set_but_forced_chatgpt_workspace_id_is_set() + { + let codex_home = tempdir().unwrap(); + login_with_api_key(codex_home.path(), "sk-test").expect("seed api key"); + + let config = build_config(codex_home.path(), None, Some("org_mine".to_string())); + + super::enforce_login_restrictions(&config) + .await + .expect("matching workspace should succeed"); + assert!( + codex_home.path().join("auth.json").exists(), + "auth.json should remain when restrictions pass" + ); + } + + #[tokio::test] + #[serial(codex_api_key)] + async fn enforce_login_restrictions_blocks_env_api_key_when_chatgpt_required() { + let _guard = EnvVarGuard::set(CODEX_API_KEY_ENV_VAR, "sk-env"); + let codex_home = tempdir().unwrap(); + + let config = build_config(codex_home.path(), Some(ForcedLoginMethod::Chatgpt), None); + + let err = super::enforce_login_restrictions(&config) + .await + .expect_err("environment API key should not satisfy forced ChatGPT login"); + assert!( + err.to_string() + .contains("ChatGPT login is required, but an API key is currently being used.") + ); + } } /// Central manager providing a single source of truth for auth.json derived diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index efc5ca3dd4..0283e360d4 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -36,6 +36,7 @@ use crate::protocol::SandboxPolicy; use anyhow::Context; use codex_app_server_protocol::Tools; use codex_app_server_protocol::UserSavedConfig; +use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; @@ -209,6 +210,12 @@ pub struct Config { /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: String, + /// When set, restricts ChatGPT login to a specific workspace identifier. + pub forced_chatgpt_workspace_id: Option, + + /// When set, restricts the login mechanism users may use. + pub forced_login_method: Option, + /// Include an experimental plan tool that the model can use to update its current plan and status of each step. pub include_plan_tool: bool, @@ -844,6 +851,14 @@ pub struct ConfigToml { /// System instructions. pub instructions: Option, + /// When set, restricts ChatGPT login to a specific workspace identifier. + #[serde(default)] + pub forced_chatgpt_workspace_id: Option, + + /// When set, restricts the login mechanism users may use. + #[serde(default)] + pub forced_login_method: Option, + /// Definition for MCP servers that Codex can reach out to for tool calls. #[serde(default)] pub mcp_servers: HashMap, @@ -950,6 +965,8 @@ impl From for UserSavedConfig { approval_policy: config_toml.approval_policy, sandbox_mode: config_toml.sandbox_mode, sandbox_settings: config_toml.sandbox_workspace_write.map(From::from), + forced_chatgpt_workspace_id: config_toml.forced_chatgpt_workspace_id, + forced_login_method: config_toml.forced_login_method, model: config_toml.model, model_reasoning_effort: config_toml.model_reasoning_effort, model_reasoning_summary: config_toml.model_reasoning_summary, @@ -1250,6 +1267,18 @@ impl Config { let use_experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec); let use_experimental_use_rmcp_client = features.enabled(Feature::RmcpClient); + let forced_chatgpt_workspace_id = + cfg.forced_chatgpt_workspace_id.as_ref().and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }); + + let forced_login_method = cfg.forced_login_method; + let model = model .or(config_profile.model) .or(cfg.model) @@ -1358,6 +1387,8 @@ impl Config { .chatgpt_base_url .or(cfg.chatgpt_base_url) .unwrap_or("https://chatgpt.com/backend-api/".to_string()), + forced_chatgpt_workspace_id, + forced_login_method, include_plan_tool: include_plan_tool_flag, include_apply_patch_tool: include_apply_patch_tool_flag, tools_web_search_request, @@ -2800,6 +2831,8 @@ model_verbosity = "high" model_verbosity: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, + forced_chatgpt_workspace_id: None, + forced_login_method: None, include_plan_tool: false, include_apply_patch_tool: false, tools_web_search_request: false, @@ -2867,6 +2900,8 @@ model_verbosity = "high" model_verbosity: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, + forced_chatgpt_workspace_id: None, + forced_login_method: None, include_plan_tool: false, include_apply_patch_tool: false, tools_web_search_request: false, @@ -2949,6 +2984,8 @@ model_verbosity = "high" model_verbosity: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, + forced_chatgpt_workspace_id: None, + forced_login_method: None, include_plan_tool: false, include_apply_patch_tool: false, tools_web_search_request: false, @@ -3017,6 +3054,8 @@ model_verbosity = "high" model_verbosity: Some(Verbosity::High), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, + forced_chatgpt_workspace_id: None, + forced_login_method: None, include_plan_tool: false, include_apply_patch_tool: false, tools_web_search_request: false, diff --git a/codex-rs/core/src/token_data.rs b/codex-rs/core/src/token_data.rs index 7185a54fee..0a7694e9f3 100644 --- a/codex-rs/core/src/token_data.rs +++ b/codex-rs/core/src/token_data.rs @@ -28,6 +28,8 @@ pub struct IdTokenInfo { /// (e.g., "free", "plus", "pro", "business", "enterprise", "edu"). /// (Note: values may vary by backend.) pub(crate) chatgpt_plan_type: Option, + /// Organization/workspace identifier associated with the token, if present. + pub chatgpt_account_id: Option, pub raw_jwt: String, } @@ -71,6 +73,8 @@ struct IdClaims { struct AuthClaims { #[serde(default)] chatgpt_plan_type: Option, + #[serde(default)] + chatgpt_account_id: Option, } #[derive(Debug, Error)] @@ -94,11 +98,20 @@ pub fn parse_id_token(id_token: &str) -> Result { let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64)?; let claims: IdClaims = serde_json::from_slice(&payload_bytes)?; - Ok(IdTokenInfo { - email: claims.email, - chatgpt_plan_type: claims.auth.and_then(|a| a.chatgpt_plan_type), - raw_jwt: id_token.to_string(), - }) + match claims.auth { + Some(auth) => Ok(IdTokenInfo { + email: claims.email, + raw_jwt: id_token.to_string(), + chatgpt_plan_type: auth.chatgpt_plan_type, + chatgpt_account_id: auth.chatgpt_account_id, + }), + None => Ok(IdTokenInfo { + email: claims.email, + raw_jwt: id_token.to_string(), + chatgpt_plan_type: None, + chatgpt_account_id: None, + }), + } } fn deserialize_id_token<'de, D>(deserializer: D) -> Result diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index efd2ff54e7..953c997685 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -15,6 +15,7 @@ use codex_core::AuthManager; use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID; use codex_core::ConversationManager; use codex_core::NewConversation; +use codex_core::auth::enforce_login_restrictions; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::features::Feature; @@ -195,6 +196,11 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any let config = Config::load_with_cli_overrides(cli_kv_overrides, overrides).await?; let approve_all_enabled = config.features.enabled(Feature::ApproveAll); + if let Err(err) = enforce_login_restrictions(&config).await { + eprintln!("{err}"); + std::process::exit(1); + } + let otel = codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION")); #[allow(clippy::print_stderr)] diff --git a/codex-rs/login/src/device_code_auth.rs b/codex-rs/login/src/device_code_auth.rs index 35828994c9..f1999a8303 100644 --- a/codex-rs/login/src/device_code_auth.rs +++ b/codex-rs/login/src/device_code_auth.rs @@ -186,6 +186,13 @@ pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> { .await .map_err(|err| std::io::Error::other(format!("device code exchange failed: {err}")))?; + if let Err(message) = crate::server::ensure_workspace_allowed( + opts.forced_chatgpt_workspace_id.as_deref(), + &tokens.id_token, + ) { + return Err(io::Error::new(io::ErrorKind::PermissionDenied, message)); + } + crate::server::persist_tokens_async( &opts.codex_home, None, diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 7808246a58..e0f508e8d4 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -38,10 +38,15 @@ pub struct ServerOptions { pub port: u16, pub open_browser: bool, pub force_state: Option, + pub forced_chatgpt_workspace_id: Option, } impl ServerOptions { - pub fn new(codex_home: PathBuf, client_id: String) -> Self { + pub fn new( + codex_home: PathBuf, + client_id: String, + forced_chatgpt_workspace_id: Option, + ) -> Self { Self { codex_home, client_id, @@ -49,6 +54,7 @@ impl ServerOptions { port: DEFAULT_PORT, open_browser: true, force_state: None, + forced_chatgpt_workspace_id, } } } @@ -104,7 +110,14 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result { let server = Arc::new(server); let redirect_uri = format!("http://localhost:{actual_port}/auth/callback"); - let auth_url = build_authorize_url(&opts.issuer, &opts.client_id, &redirect_uri, &pkce, &state); + let auth_url = build_authorize_url( + &opts.issuer, + &opts.client_id, + &redirect_uri, + &pkce, + &state, + opts.forced_chatgpt_workspace_id.as_deref(), + ); if opts.open_browser { let _ = webbrowser::open(&auth_url); @@ -240,6 +253,13 @@ async fn process_request( .await { Ok(tokens) => { + if let Err(message) = ensure_workspace_allowed( + opts.forced_chatgpt_workspace_id.as_deref(), + &tokens.id_token, + ) { + eprintln!("Workspace restriction error: {message}"); + return login_error_response(&message); + } // Obtain API key via token-exchange and persist let api_key = obtain_api_key(&opts.issuer, &opts.client_id, &tokens.id_token) .await @@ -358,22 +378,35 @@ fn build_authorize_url( redirect_uri: &str, pkce: &PkceCodes, state: &str, + forced_chatgpt_workspace_id: Option<&str>, ) -> String { - let query = vec![ - ("response_type", "code"), - ("client_id", client_id), - ("redirect_uri", redirect_uri), - ("scope", "openid profile email offline_access"), - ("code_challenge", &pkce.code_challenge), - ("code_challenge_method", "S256"), - ("id_token_add_organizations", "true"), - ("codex_cli_simplified_flow", "true"), - ("state", state), - ("originator", originator().value.as_str()), + let mut query = vec![ + ("response_type".to_string(), "code".to_string()), + ("client_id".to_string(), client_id.to_string()), + ("redirect_uri".to_string(), redirect_uri.to_string()), + ( + "scope".to_string(), + "openid profile email offline_access".to_string(), + ), + ( + "code_challenge".to_string(), + pkce.code_challenge.to_string(), + ), + ("code_challenge_method".to_string(), "S256".to_string()), + ("id_token_add_organizations".to_string(), "true".to_string()), + ("codex_cli_simplified_flow".to_string(), "true".to_string()), + ("state".to_string(), state.to_string()), + ( + "originator".to_string(), + originator().value.as_str().to_string(), + ), ]; + if let Some(workspace_id) = forced_chatgpt_workspace_id { + query.push(("allowed_workspace_id".to_string(), workspace_id.to_string())); + } let qs = query .into_iter() - .map(|(k, v)| format!("{}={}", k, urlencoding::encode(v))) + .map(|(k, v)| format!("{k}={}", urlencoding::encode(&v))) .collect::>() .join("&"); format!("{issuer}/oauth/authorize?{qs}") @@ -616,6 +649,43 @@ fn jwt_auth_claims(jwt: &str) -> serde_json::Map { serde_json::Map::new() } +pub(crate) fn ensure_workspace_allowed( + expected: Option<&str>, + id_token: &str, +) -> Result<(), String> { + let Some(expected) = expected else { + return Ok(()); + }; + + let claims = jwt_auth_claims(id_token); + let Some(actual) = claims.get("chatgpt_account_id").and_then(JsonValue::as_str) else { + return Err("Login is restricted to a specific workspace, but the token did not include an chatgpt_account_id claim.".to_string()); + }; + + if actual == expected { + Ok(()) + } else { + Err(format!("Login is restricted to workspace id {expected}.")) + } +} + +// Respond to the oauth server with an error so the code becomes unusable by anybody else. +fn login_error_response(message: &str) -> HandledRequest { + let mut headers = Vec::new(); + if let Ok(header) = Header::from_bytes(&b"Content-Type"[..], &b"text/plain; charset=utf-8"[..]) + { + headers.push(header); + } + HandledRequest::ResponseAndExit { + headers, + body: message.as_bytes().to_vec(), + result: Err(io::Error::new( + io::ErrorKind::PermissionDenied, + message.to_string(), + )), + } +} + pub(crate) async fn obtain_api_key( issuer: &str, client_id: &str, diff --git a/codex-rs/login/tests/suite/device_code_login.rs b/codex-rs/login/tests/suite/device_code_login.rs index 4bd5e2e36b..416921bfc5 100644 --- a/codex-rs/login/tests/suite/device_code_login.rs +++ b/codex-rs/login/tests/suite/device_code_login.rs @@ -97,7 +97,11 @@ async fn mock_oauth_token_single(server: &MockServer, jwt: String) { } fn server_opts(codex_home: &tempfile::TempDir, issuer: String) -> ServerOptions { - let mut opts = ServerOptions::new(codex_home.path().to_path_buf(), "client-id".to_string()); + let mut opts = ServerOptions::new( + codex_home.path().to_path_buf(), + "client-id".to_string(), + None, + ); opts.issuer = issuer; opts.open_browser = false; opts @@ -139,6 +143,42 @@ async fn device_code_login_integration_succeeds() { assert_eq!(tokens.account_id.as_deref(), Some("acct_321")); } +#[tokio::test] +async fn device_code_login_rejects_workspace_mismatch() { + skip_if_no_network!(); + + let codex_home = tempdir().unwrap(); + let mock_server = MockServer::start().await; + + mock_usercode_success(&mock_server).await; + + mock_poll_token_two_step(&mock_server, Arc::new(AtomicUsize::new(0)), 404).await; + + let jwt = make_jwt(json!({ + "https://api.openai.com/auth": { + "chatgpt_account_id": "acct_321", + "organization_id": "org-actual" + } + })); + + mock_oauth_token_single(&mock_server, jwt).await; + + let issuer = mock_server.uri(); + let mut opts = server_opts(&codex_home, issuer); + opts.forced_chatgpt_workspace_id = Some("org-required".to_string()); + + let err = run_device_code_login(opts) + .await + .expect_err("device code login should fail when workspace mismatches"); + assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); + + let auth_path = get_auth_file(codex_home.path()); + assert!( + !auth_path.exists(), + "auth.json should not be created when workspace validation fails" + ); +} + #[tokio::test] async fn device_code_login_integration_handles_usercode_http_failure() { skip_if_no_network!(); @@ -183,7 +223,11 @@ async fn device_code_login_integration_persists_without_api_key_on_exchange_fail let issuer = mock_server.uri(); - let mut opts = ServerOptions::new(codex_home.path().to_path_buf(), "client-id".to_string()); + let mut opts = ServerOptions::new( + codex_home.path().to_path_buf(), + "client-id".to_string(), + None, + ); opts.issuer = issuer; opts.open_browser = false; @@ -226,7 +270,11 @@ async fn device_code_login_integration_handles_error_payload() { let issuer = mock_server.uri(); - let mut opts = ServerOptions::new(codex_home.path().to_path_buf(), "client-id".to_string()); + let mut opts = ServerOptions::new( + codex_home.path().to_path_buf(), + "client-id".to_string(), + None, + ); opts.issuer = issuer; opts.open_browser = false; diff --git a/codex-rs/login/tests/suite/login_server_e2e.rs b/codex-rs/login/tests/suite/login_server_e2e.rs index a6e0de26a6..3a5f869b5a 100644 --- a/codex-rs/login/tests/suite/login_server_e2e.rs +++ b/codex-rs/login/tests/suite/login_server_e2e.rs @@ -14,11 +14,12 @@ use tempfile::tempdir; // See spawn.rs for details -fn start_mock_issuer() -> (SocketAddr, thread::JoinHandle<()>) { +fn start_mock_issuer(chatgpt_account_id: &str) -> (SocketAddr, thread::JoinHandle<()>) { // Bind to a random available port let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); let addr = listener.local_addr().unwrap(); let server = tiny_http::Server::from_listener(listener, None).unwrap(); + let chatgpt_account_id = chatgpt_account_id.to_string(); let handle = thread::spawn(move || { while let Ok(mut req) = server.recv() { @@ -41,7 +42,7 @@ fn start_mock_issuer() -> (SocketAddr, thread::JoinHandle<()>) { "email": "user@example.com", "https://api.openai.com/auth": { "chatgpt_plan_type": "pro", - "chatgpt_account_id": "acc-123" + "chatgpt_account_id": chatgpt_account_id, } }); let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b); @@ -80,7 +81,8 @@ fn start_mock_issuer() -> (SocketAddr, thread::JoinHandle<()>) { async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { skip_if_no_network!(Ok(())); - let (issuer_addr, issuer_handle) = start_mock_issuer(); + let chatgpt_account_id = "12345678-0000-0000-0000-000000000000"; + let (issuer_addr, issuer_handle) = start_mock_issuer(chatgpt_account_id); let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); let tmp = tempdir()?; @@ -113,8 +115,15 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { port: 0, open_browser: false, force_state: Some(state), + forced_chatgpt_workspace_id: Some(chatgpt_account_id.to_string()), }; let server = run_login_server(opts)?; + assert!( + server + .auth_url + .contains(format!("allowed_workspace_id={chatgpt_account_id}").as_str()), + "auth URL should include forced workspace parameter" + ); let login_port = server.actual_port; // Simulate browser callback, and follow redirect to /success @@ -138,7 +147,7 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { assert_eq!(json["OPENAI_API_KEY"], "access-123"); assert_eq!(json["tokens"]["access_token"], "access-123"); assert_eq!(json["tokens"]["refresh_token"], "refresh-123"); - assert_eq!(json["tokens"]["account_id"], "acc-123"); + assert_eq!(json["tokens"]["account_id"], chatgpt_account_id); // Stop mock issuer drop(issuer_handle); @@ -149,7 +158,7 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { async fn creates_missing_codex_home_dir() -> Result<()> { skip_if_no_network!(Ok(())); - let (issuer_addr, _issuer_handle) = start_mock_issuer(); + let (issuer_addr, _issuer_handle) = start_mock_issuer("org-123"); let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); let tmp = tempdir()?; @@ -166,6 +175,7 @@ async fn creates_missing_codex_home_dir() -> Result<()> { port: 0, open_browser: false, force_state: Some(state), + forced_chatgpt_workspace_id: None, }; let server = run_login_server(opts)?; let login_port = server.actual_port; @@ -185,11 +195,67 @@ async fn creates_missing_codex_home_dir() -> Result<()> { Ok(()) } +#[tokio::test] +async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> { + skip_if_no_network!(Ok(())); + + let (issuer_addr, _issuer_handle) = start_mock_issuer("org-actual"); + let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); + + let tmp = tempdir()?; + let codex_home = tmp.path().to_path_buf(); + let state = "state-mismatch".to_string(); + + let opts = ServerOptions { + codex_home: codex_home.clone(), + client_id: codex_login::CLIENT_ID.to_string(), + issuer, + port: 0, + open_browser: false, + force_state: Some(state.clone()), + forced_chatgpt_workspace_id: Some("org-required".to_string()), + }; + let server = run_login_server(opts)?; + assert!( + server + .auth_url + .contains("allowed_workspace_id=org-required"), + "auth URL should include forced workspace parameter" + ); + let login_port = server.actual_port; + + let client = reqwest::Client::new(); + let url = format!("http://127.0.0.1:{login_port}/auth/callback?code=abc&state={state}"); + let resp = client.get(&url).send().await?; + assert!(resp.status().is_success()); + let body = resp.text().await?; + assert!( + body.contains("Login is restricted to workspace id org-required"), + "error body should mention workspace restriction" + ); + + let result = server.block_until_done().await; + assert!( + result.is_err(), + "login should fail due to workspace mismatch" + ); + let err = result.unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); + + let auth_path = codex_home.join("auth.json"); + assert!( + !auth_path.exists(), + "auth.json should not be written when the workspace mismatches" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { skip_if_no_network!(Ok(())); - let (issuer_addr, _issuer_handle) = start_mock_issuer(); + let (issuer_addr, _issuer_handle) = start_mock_issuer("org-123"); let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); let first_tmp = tempdir()?; @@ -202,6 +268,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { port: 0, open_browser: false, force_state: Some("cancel_state".to_string()), + forced_chatgpt_workspace_id: None, }; let first_server = run_login_server(first_opts)?; @@ -220,6 +287,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { port: login_port, open_browser: false, force_state: Some("cancel_state_2".to_string()), + forced_chatgpt_workspace_id: None, }; let second_server = run_login_server(second_opts)?; diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 633c845dd2..d701ecf41e 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -59,3 +59,11 @@ pub enum SandboxMode { #[serde(rename = "danger-full-access")] DangerFullAccess, } + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, TS)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum ForcedLoginMethod { + Chatgpt, + Api, +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 1753f311ac..12bcb2ea57 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -11,6 +11,7 @@ use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID; use codex_core::CodexAuth; use codex_core::INTERACTIVE_SESSION_SOURCES; use codex_core::RolloutRecorder; +use codex_core::auth::enforce_login_restrictions; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::find_conversation_path_by_id_str; @@ -193,6 +194,12 @@ pub async fn run_main( let config = load_config_or_exit(cli_kv_overrides.clone(), overrides.clone()).await; + #[allow(clippy::print_stderr)] + if let Err(err) = enforce_login_restrictions(&config).await { + eprintln!("{err}"); + std::process::exit(1); + } + let active_profile = config.active_profile.clone(); let log_dir = codex_core::config::log_dir(&config)?; std::fs::create_dir_all(&log_dir)?; diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index cf481e6712..05687bb1bd 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -28,6 +28,7 @@ use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; use codex_app_server_protocol::AuthMode; +use codex_protocol::config_types::ForcedLoginMethod; use std::sync::RwLock; use crate::LoginStatus; @@ -50,6 +51,8 @@ pub(crate) enum SignInState { ApiKeyConfigured, } +const API_KEY_DISABLED_MESSAGE: &str = "API key login is disabled."; + #[derive(Clone, Default)] pub(crate) struct ApiKeyInputState { value: String, @@ -79,25 +82,41 @@ impl KeyboardHandler for AuthModeWidget { match key_event.code { KeyCode::Up | KeyCode::Char('k') => { - self.highlighted_mode = AuthMode::ChatGPT; + if self.is_chatgpt_login_allowed() { + self.highlighted_mode = AuthMode::ChatGPT; + } } KeyCode::Down | KeyCode::Char('j') => { - self.highlighted_mode = AuthMode::ApiKey; + if self.is_api_login_allowed() { + self.highlighted_mode = AuthMode::ApiKey; + } } KeyCode::Char('1') => { - self.start_chatgpt_login(); + if self.is_chatgpt_login_allowed() { + self.start_chatgpt_login(); + } + } + KeyCode::Char('2') => { + if self.is_api_login_allowed() { + self.start_api_key_entry(); + } else { + self.disallow_api_login(); + } } - KeyCode::Char('2') => self.start_api_key_entry(), KeyCode::Enter => { let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() }; match sign_in_state { SignInState::PickMode => match self.highlighted_mode { - AuthMode::ChatGPT => { + AuthMode::ChatGPT if self.is_chatgpt_login_allowed() => { self.start_chatgpt_login(); } - AuthMode::ApiKey => { + AuthMode::ApiKey if self.is_api_login_allowed() => { self.start_api_key_entry(); } + AuthMode::ChatGPT => {} + AuthMode::ApiKey => { + self.disallow_api_login(); + } }, SignInState::ChatGptSuccessMessage => { *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; @@ -131,9 +150,26 @@ pub(crate) struct AuthModeWidget { pub codex_home: PathBuf, pub login_status: LoginStatus, pub auth_manager: Arc, + pub forced_chatgpt_workspace_id: Option, + pub forced_login_method: Option, } impl AuthModeWidget { + fn is_api_login_allowed(&self) -> bool { + !matches!(self.forced_login_method, Some(ForcedLoginMethod::Chatgpt)) + } + + fn is_chatgpt_login_allowed(&self) -> bool { + !matches!(self.forced_login_method, Some(ForcedLoginMethod::Api)) + } + + fn disallow_api_login(&mut self) { + self.highlighted_mode = AuthMode::ChatGPT; + self.error = Some(API_KEY_DISABLED_MESSAGE.to_string()); + *self.sign_in_state.write().unwrap() = SignInState::PickMode; + self.request_frame.schedule_frame(); + } + fn render_pick_mode(&self, area: Rect, buf: &mut Buffer) { let mut lines: Vec = vec![ Line::from(vec![ @@ -176,20 +212,34 @@ impl AuthModeWidget { vec![line1, line2] }; + let chatgpt_description = if self.is_chatgpt_login_allowed() { + "Usage included with Plus, Pro, and Team plans" + } else { + "ChatGPT login is disabled" + }; lines.extend(create_mode_item( 0, AuthMode::ChatGPT, "Sign in with ChatGPT", - "Usage included with Plus, Pro, and Team plans", - )); - lines.push("".into()); - lines.extend(create_mode_item( - 1, - AuthMode::ApiKey, - "Provide your own API key", - "Pay for what you use", + chatgpt_description, )); lines.push("".into()); + if self.is_api_login_allowed() { + lines.extend(create_mode_item( + 1, + AuthMode::ApiKey, + "Provide your own API key", + "Pay for what you use", + )); + lines.push("".into()); + } else { + lines.push( + " API key login is disabled by this workspace. Sign in with ChatGPT to continue." + .dim() + .into(), + ); + lines.push("".into()); + } lines.push( // AE: Following styles.md, this should probably be Cyan because it's a user input tip. // But leaving this for a future cleanup. @@ -428,6 +478,10 @@ impl AuthModeWidget { } fn start_api_key_entry(&mut self) { + if !self.is_api_login_allowed() { + self.disallow_api_login(); + return; + } self.error = None; let prefill_from_env = read_openai_api_key_from_env(); let mut guard = self.sign_in_state.write().unwrap(); @@ -454,6 +508,10 @@ impl AuthModeWidget { } fn save_api_key(&mut self, api_key: String) { + if !self.is_api_login_allowed() { + self.disallow_api_login(); + return; + } match login_with_api_key(&self.codex_home, &api_key) { Ok(()) => { self.error = None; @@ -491,7 +549,11 @@ impl AuthModeWidget { } self.error = None; - let opts = ServerOptions::new(self.codex_home.clone(), CLIENT_ID.to_string()); + let opts = ServerOptions::new( + self.codex_home.clone(), + CLIENT_ID.to_string(), + self.forced_chatgpt_workspace_id.clone(), + ); match run_login_server(opts) { Ok(child) => { let sign_in_state = self.sign_in_state.clone(); @@ -571,3 +633,54 @@ impl WidgetRef for AuthModeWidget { } } } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + fn widget_forced_chatgpt() -> (AuthModeWidget, TempDir) { + let codex_home = TempDir::new().unwrap(); + let codex_home_path = codex_home.path().to_path_buf(); + let widget = AuthModeWidget { + request_frame: FrameRequester::test_dummy(), + highlighted_mode: AuthMode::ChatGPT, + error: None, + sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)), + codex_home: codex_home_path.clone(), + login_status: LoginStatus::NotAuthenticated, + auth_manager: AuthManager::shared(codex_home_path, false), + forced_chatgpt_workspace_id: None, + forced_login_method: Some(ForcedLoginMethod::Chatgpt), + }; + (widget, codex_home) + } + + #[test] + fn api_key_flow_disabled_when_chatgpt_forced() { + let (mut widget, _tmp) = widget_forced_chatgpt(); + + widget.start_api_key_entry(); + + assert_eq!(widget.error.as_deref(), Some(API_KEY_DISABLED_MESSAGE)); + assert!(matches!( + &*widget.sign_in_state.read().unwrap(), + SignInState::PickMode + )); + } + + #[test] + fn saving_api_key_is_blocked_when_chatgpt_forced() { + let (mut widget, _tmp) = widget_forced_chatgpt(); + + widget.save_api_key("sk-test".to_string()); + + assert_eq!(widget.error.as_deref(), Some(API_KEY_DISABLED_MESSAGE)); + assert!(matches!( + &*widget.sign_in_state.read().unwrap(), + SignInState::PickMode + )); + assert_eq!(widget.login_status, LoginStatus::NotAuthenticated); + } +} diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 69225d97f1..369e6ddbb0 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -12,6 +12,7 @@ use ratatui::widgets::Clear; use ratatui::widgets::WidgetRef; use codex_app_server_protocol::AuthMode; +use codex_protocol::config_types::ForcedLoginMethod; use crate::LoginStatus; use crate::onboarding::auth::AuthModeWidget; @@ -83,6 +84,8 @@ impl OnboardingScreen { config, } = args; let cwd = config.cwd.clone(); + let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); + let forced_login_method = config.forced_login_method; let codex_home = config.codex_home; let mut steps: Vec = Vec::new(); if show_windows_wsl_screen { @@ -93,14 +96,20 @@ impl OnboardingScreen { tui.frame_requester(), ))); if show_login_screen { + let highlighted_mode = match forced_login_method { + Some(ForcedLoginMethod::Api) => AuthMode::ApiKey, + _ => AuthMode::ChatGPT, + }; steps.push(Step::Auth(AuthModeWidget { request_frame: tui.frame_requester(), - highlighted_mode: AuthMode::ChatGPT, + highlighted_mode, error: None, sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)), codex_home: codex_home.clone(), login_status, auth_manager, + forced_chatgpt_workspace_id, + forced_login_method, })) } let is_git_repo = get_git_repo_root(&cwd).is_some(); diff --git a/docs/config.md b/docs/config.md index 94c3136585..8d495214c3 100644 --- a/docs/config.md +++ b/docs/config.md @@ -831,6 +831,22 @@ notifications = [ "agent-turn-complete", "approval-requested" ] > [!NOTE] > `tui.notifications` is built‑in and limited to the TUI session. For programmatic or cross‑environment notifications—or to integrate with OS‑specific notifiers—use the top‑level `notify` option to run an external program that receives event JSON. The two settings are independent and can be used together. +## Forcing a login method + +To force users on a given machine to use a specific login method or workspace, use a combination of [managed configurations](https://developers.openai.com/codex/security#managed-configuration) as well as either or both of the following fields: + +```toml +# Force the user to log in with ChatGPT or via an api key. +forced_login_method = "chatgpt" or "api" +# When logging in with ChatGPT, only the specified workspace ID will be presented during the login +# flow and the id will be validated during the oauth callback as well as every time Codex starts. +forced_chatgpt_workspace_id = "00000000-0000-0000-0000-000000000000" +``` + +If the active credentials don't match the config, the user will be logged out and Codex will exit. + +If `forced_chatgpt_workspace_id` is set but `forced_login_method` is not set, API key login will still work. + ## Config reference | Key | Type / Values | Notes | @@ -885,4 +901,6 @@ notifications = [ "agent-turn-complete", "approval-requested" ] | `experimental_use_exec_command_tool` | boolean | Use experimental exec command tool. | | `projects..trust_level` | string | Mark project/worktree as trusted (only `"trusted"` is recognized). | | `tools.web_search` | boolean | Enable web search tool (alias: `web_search_request`) (default: false). | +| `forced_login_method` | `chatgpt` \| `api` | Only allow Codex to be used with ChatGPT or API keys. | +| `forced_chatgpt_workspace_id` | string (uuid) | Only allow Codex to be used with the specified ChatGPT workspace. | | `tools.view_image` | boolean | Enable the `view_image` tool so Codex can attach local image files from the workspace (default: false). | From 540abfa05ec3fff002e58596f0030fc0661b70e3 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 20 Oct 2025 09:11:43 -0700 Subject: [PATCH 032/287] Expand approvals integration coverage (#5358) Improve approval coverage --- codex-rs/core/tests/suite/approvals.rs | 1175 ++++++++++++++++++++++++ codex-rs/core/tests/suite/mod.rs | 2 + 2 files changed, 1177 insertions(+) create mode 100644 codex-rs/core/tests/suite/approvals.rs diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs new file mode 100644 index 0000000000..0ce1183edd --- /dev/null +++ b/codex-rs/core/tests/suite/approvals.rs @@ -0,0 +1,1175 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use anyhow::Result; +use codex_core::model_family::find_family_for_model; +use codex_core::protocol::ApplyPatchApprovalRequestEvent; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::EventMsg; +use codex_core::protocol::ExecApprovalRequestEvent; +use codex_core::protocol::InputItem; +use codex_core::protocol::Op; +use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::ReviewDecision; +use core_test_support::responses::ev_apply_patch_function_call; +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::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::test_codex; +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::env; +use std::fs; +use std::path::PathBuf; +use std::time::Duration; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +#[derive(Clone, Copy)] +enum TargetPath { + Workspace(&'static str), + OutsideWorkspace(&'static str), +} + +impl TargetPath { + fn resolve_for_patch(self, test: &TestCodex) -> (PathBuf, String) { + match self { + TargetPath::Workspace(name) => { + let path = test.cwd.path().join(name); + (path, name.to_string()) + } + TargetPath::OutsideWorkspace(name) => { + let path = env::current_dir() + .expect("current dir should be available") + .join(name); + (path.clone(), path.display().to_string()) + } + } + } +} + +#[derive(Clone)] +enum ActionKind { + WriteFile { + target: TargetPath, + content: &'static str, + }, + FetchUrl { + endpoint: &'static str, + response_body: &'static str, + }, + RunCommand { + command: &'static [&'static str], + }, + ApplyPatchFunction { + target: TargetPath, + content: &'static str, + }, + ApplyPatchShell { + target: TargetPath, + content: &'static str, + }, +} + +impl ActionKind { + async fn prepare( + &self, + test: &TestCodex, + server: &MockServer, + call_id: &str, + with_escalated_permissions: bool, + ) -> Result<(Value, Option>)> { + match self { + ActionKind::WriteFile { target, content } => { + let (path, _) = target.resolve_for_patch(test); + let _ = fs::remove_file(&path); + let command = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + format!( + "printf {content:?} > {path:?} && cat {path:?}", + content = content, + path = path + ), + ]; + let event = shell_event(call_id, &command, 1_000, with_escalated_permissions)?; + Ok((event, Some(command))) + } + ActionKind::FetchUrl { + endpoint, + response_body, + } => { + Mock::given(method("GET")) + .and(path(*endpoint)) + .respond_with( + ResponseTemplate::new(200).set_body_string(response_body.to_string()), + ) + .mount(server) + .await; + + let url = format!("{}{}", server.uri(), endpoint); + let script = format!( + "import sys\nimport urllib.request\nurl = {url:?}\ntry:\n data = urllib.request.urlopen(url, timeout=2).read().decode()\n print('OK:' + data.strip())\nexcept Exception as exc:\n print('ERR:' + exc.__class__.__name__)\n sys.exit(1)", + ); + + let command = vec!["python3".to_string(), "-c".to_string(), script]; + let event = shell_event(call_id, &command, 1_000, with_escalated_permissions)?; + Ok((event, Some(command))) + } + ActionKind::RunCommand { command } => { + let command: Vec = command + .iter() + .map(std::string::ToString::to_string) + .collect(); + let event = shell_event(call_id, &command, 1_000, with_escalated_permissions)?; + Ok((event, Some(command))) + } + ActionKind::ApplyPatchFunction { target, content } => { + let (path, patch_path) = target.resolve_for_patch(test); + let _ = fs::remove_file(&path); + let patch = build_add_file_patch(&patch_path, content); + Ok((ev_apply_patch_function_call(call_id, &patch), None)) + } + ActionKind::ApplyPatchShell { target, content } => { + let (path, patch_path) = target.resolve_for_patch(test); + let _ = fs::remove_file(&path); + let patch = build_add_file_patch(&patch_path, content); + let command = shell_apply_patch_command(&patch); + let event = shell_event(call_id, &command, 5_000, with_escalated_permissions)?; + Ok((event, Some(command))) + } + } + } +} + +fn build_add_file_patch(patch_path: &str, content: &str) -> String { + format!("*** Begin Patch\n*** Add File: {patch_path}\n+{content}\n*** End Patch\n") +} + +fn shell_apply_patch_command(patch: &str) -> Vec { + let mut script = String::from("apply_patch <<'PATCH'\n"); + script.push_str(patch); + if !patch.ends_with('\n') { + script.push('\n'); + } + script.push_str("PATCH\n"); + vec!["bash".to_string(), "-lc".to_string(), script] +} + +fn shell_event( + call_id: &str, + command: &[String], + timeout_ms: u64, + with_escalated_permissions: bool, +) -> Result { + let mut args = json!({ + "command": command, + "timeout_ms": timeout_ms, + }); + if with_escalated_permissions { + args["with_escalated_permissions"] = json!(true); + } + let args_str = serde_json::to_string(&args)?; + Ok(ev_function_call(call_id, "shell", &args_str)) +} + +#[derive(Clone)] +enum Expectation { + FileCreated { + target: TargetPath, + content: &'static str, + }, + PatchApplied { + target: TargetPath, + content: &'static str, + }, + FileNotCreated { + target: TargetPath, + message_contains: &'static [&'static str], + }, + NetworkSuccess { + body_contains: &'static str, + }, + NetworkFailure { + expect_tag: &'static str, + }, + CommandSuccess { + stdout_contains: &'static str, + }, +} + +impl Expectation { + fn verify(&self, test: &TestCodex, result: &CommandResult) -> Result<()> { + match self { + Expectation::FileCreated { target, content } => { + let (path, _) = target.resolve_for_patch(test); + assert_eq!( + result.exit_code, + Some(0), + "expected successful exit for {:?}", + path + ); + assert!( + result.stdout.contains(content), + "stdout missing {content:?}: {}", + result.stdout + ); + let file_contents = fs::read_to_string(&path)?; + assert!( + file_contents.contains(content), + "file contents missing {content:?}: {file_contents}" + ); + let _ = fs::remove_file(path); + } + Expectation::PatchApplied { target, content } => { + let (path, _) = target.resolve_for_patch(test); + match result.exit_code { + Some(0) | None => { + if result.exit_code.is_none() { + assert!( + result.stdout.contains("Success."), + "patch output missing success indicator: {}", + result.stdout + ); + } + } + Some(code) => panic!( + "expected successful patch exit for {:?}, got {code} with stdout {}", + path, result.stdout + ), + } + let file_contents = fs::read_to_string(&path)?; + assert!( + file_contents.contains(content), + "patched file missing {content:?}: {file_contents}" + ); + let _ = fs::remove_file(path); + } + Expectation::FileNotCreated { + target, + message_contains, + } => { + let (path, _) = target.resolve_for_patch(test); + assert_ne!( + result.exit_code, + Some(0), + "expected non-zero exit for {path:?}" + ); + for needle in *message_contains { + assert!( + result.stdout.contains(needle), + "stdout missing {needle:?}: {}", + result.stdout + ); + } + assert!( + !path.exists(), + "command should not create {path:?}, but file exists" + ); + } + Expectation::NetworkSuccess { body_contains } => { + assert_eq!( + result.exit_code, + Some(0), + "expected successful network exit: {}", + result.stdout + ); + assert!( + result.stdout.contains("OK:"), + "stdout missing OK prefix: {}", + result.stdout + ); + assert!( + result.stdout.contains(body_contains), + "stdout missing body text {body_contains:?}: {}", + result.stdout + ); + } + Expectation::NetworkFailure { expect_tag } => { + assert_ne!( + result.exit_code, + Some(0), + "expected non-zero exit for network failure: {}", + result.stdout + ); + assert!( + result.stdout.contains("ERR:"), + "stdout missing ERR prefix: {}", + result.stdout + ); + assert!( + result.stdout.contains(expect_tag), + "stdout missing expected tag {expect_tag:?}: {}", + result.stdout + ); + } + Expectation::CommandSuccess { stdout_contains } => { + assert_eq!( + result.exit_code, + Some(0), + "expected successful trusted command exit: {}", + result.stdout + ); + assert!( + result.stdout.contains(stdout_contains), + "trusted command stdout missing {stdout_contains:?}: {}", + result.stdout + ); + } + } + Ok(()) + } +} + +#[derive(Clone)] +enum Outcome { + Auto, + ExecApproval { + decision: ReviewDecision, + expected_reason: Option<&'static str>, + }, + PatchApproval { + decision: ReviewDecision, + expected_reason: Option<&'static str>, + }, +} + +#[derive(Clone)] +struct ScenarioSpec { + name: &'static str, + approval_policy: AskForApproval, + sandbox_policy: SandboxPolicy, + action: ActionKind, + with_escalated_permissions: bool, + requires_apply_patch_tool: bool, + model_override: Option<&'static str>, + outcome: Outcome, + expectation: Expectation, +} + +struct CommandResult { + exit_code: Option, + stdout: String, +} + +async fn submit_turn( + test: &TestCodex, + prompt: &str, + approval_policy: AskForApproval, + sandbox_policy: SandboxPolicy, +) -> Result<()> { + let session_model = test.session_configured.model.clone(); + + test.codex + .submit(Op::UserTurn { + items: vec![InputItem::Text { + text: prompt.into(), + }], + final_output_json_schema: None, + cwd: test.cwd.path().to_path_buf(), + approval_policy, + sandbox_policy, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + Ok(()) +} + +fn parse_result(item: &Value) -> CommandResult { + let output_str = item + .get("output") + .and_then(Value::as_str) + .expect("shell output payload"); + match serde_json::from_str::(output_str) { + Ok(parsed) => { + let exit_code = parsed["metadata"]["exit_code"].as_i64(); + let stdout = parsed["output"].as_str().unwrap_or_default().to_string(); + CommandResult { exit_code, stdout } + } + Err(_) => CommandResult { + exit_code: None, + stdout: output_str.to_string(), + }, + } +} + +async fn expect_exec_approval( + test: &TestCodex, + expected_command: &[String], +) -> ExecApprovalRequestEvent { + let event = wait_for_event_with_timeout( + &test.codex, + |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TaskComplete(_) + ) + }, + Duration::from_secs(5), + ) + .await; + + match event { + EventMsg::ExecApprovalRequest(approval) => { + assert_eq!(approval.command, expected_command); + approval + } + EventMsg::TaskComplete(_) => panic!("expected approval request before completion"), + other => panic!("unexpected event: {other:?}"), + } +} + +async fn expect_patch_approval( + test: &TestCodex, + expected_call_id: &str, +) -> ApplyPatchApprovalRequestEvent { + let event = wait_for_event_with_timeout( + &test.codex, + |event| { + matches!( + event, + EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::TaskComplete(_) + ) + }, + Duration::from_secs(5), + ) + .await; + + match event { + EventMsg::ApplyPatchApprovalRequest(approval) => { + assert_eq!(approval.call_id, expected_call_id); + approval + } + EventMsg::TaskComplete(_) => panic!("expected patch approval request before completion"), + other => panic!("unexpected event: {other:?}"), + } +} + +async fn wait_for_completion_without_approval(test: &TestCodex) { + let event = wait_for_event_with_timeout( + &test.codex, + |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TaskComplete(_) + ) + }, + Duration::from_secs(5), + ) + .await; + + match event { + EventMsg::TaskComplete(_) => {} + EventMsg::ExecApprovalRequest(event) => { + panic!("unexpected approval request: {:?}", event.command) + } + other => panic!("unexpected event: {other:?}"), + } +} + +async fn wait_for_completion(test: &TestCodex) { + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TaskComplete(_)) + }) + .await; +} + +fn scenarios() -> Vec { + use AskForApproval::*; + + let workspace_write = |network_access| SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + + vec![ + ScenarioSpec { + name: "danger_full_access_on_request_allows_outside_write", + approval_policy: OnRequest, + sandbox_policy: SandboxPolicy::DangerFullAccess, + action: ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("dfa_on_request.txt"), + content: "danger-on-request", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::FileCreated { + target: TargetPath::OutsideWorkspace("dfa_on_request.txt"), + content: "danger-on-request", + }, + }, + ScenarioSpec { + name: "danger_full_access_on_request_allows_network", + approval_policy: OnRequest, + sandbox_policy: SandboxPolicy::DangerFullAccess, + action: ActionKind::FetchUrl { + endpoint: "/dfa/network", + response_body: "danger-network-ok", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::NetworkSuccess { + body_contains: "danger-network-ok", + }, + }, + ScenarioSpec { + name: "trusted_command_unless_trusted_runs_without_prompt", + approval_policy: UnlessTrusted, + sandbox_policy: SandboxPolicy::DangerFullAccess, + action: ActionKind::RunCommand { + command: &["echo", "trusted-unless"], + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::CommandSuccess { + stdout_contains: "trusted-unless", + }, + }, + ScenarioSpec { + name: "danger_full_access_on_failure_allows_outside_write", + approval_policy: OnFailure, + sandbox_policy: SandboxPolicy::DangerFullAccess, + action: ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("dfa_on_failure.txt"), + content: "danger-on-failure", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::FileCreated { + target: TargetPath::OutsideWorkspace("dfa_on_failure.txt"), + content: "danger-on-failure", + }, + }, + ScenarioSpec { + name: "danger_full_access_unless_trusted_requests_approval", + approval_policy: UnlessTrusted, + sandbox_policy: SandboxPolicy::DangerFullAccess, + action: ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("dfa_unless_trusted.txt"), + content: "danger-unless-trusted", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Approved, + expected_reason: None, + }, + expectation: Expectation::FileCreated { + target: TargetPath::OutsideWorkspace("dfa_unless_trusted.txt"), + content: "danger-unless-trusted", + }, + }, + ScenarioSpec { + name: "danger_full_access_never_allows_outside_write", + approval_policy: Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + action: ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("dfa_never.txt"), + content: "danger-never", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::FileCreated { + target: TargetPath::OutsideWorkspace("dfa_never.txt"), + content: "danger-never", + }, + }, + ScenarioSpec { + name: "read_only_on_request_requires_approval", + approval_policy: OnRequest, + sandbox_policy: SandboxPolicy::ReadOnly, + action: ActionKind::WriteFile { + target: TargetPath::Workspace("ro_on_request.txt"), + content: "read-only-approval", + }, + with_escalated_permissions: true, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Approved, + expected_reason: None, + }, + expectation: Expectation::FileCreated { + target: TargetPath::Workspace("ro_on_request.txt"), + content: "read-only-approval", + }, + }, + ScenarioSpec { + name: "trusted_command_on_request_read_only_runs_without_prompt", + approval_policy: OnRequest, + sandbox_policy: SandboxPolicy::ReadOnly, + action: ActionKind::RunCommand { + command: &["echo", "trusted-read-only"], + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::CommandSuccess { + stdout_contains: "trusted-read-only", + }, + }, + ScenarioSpec { + name: "read_only_on_request_blocks_network", + approval_policy: OnRequest, + sandbox_policy: SandboxPolicy::ReadOnly, + action: ActionKind::FetchUrl { + endpoint: "/ro/network-blocked", + response_body: "should-not-see", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::NetworkFailure { expect_tag: "ERR:" }, + }, + ScenarioSpec { + name: "read_only_on_request_denied_blocks_execution", + approval_policy: OnRequest, + sandbox_policy: SandboxPolicy::ReadOnly, + action: ActionKind::WriteFile { + target: TargetPath::Workspace("ro_on_request_denied.txt"), + content: "should-not-write", + }, + with_escalated_permissions: true, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Denied, + expected_reason: None, + }, + expectation: Expectation::FileNotCreated { + target: TargetPath::Workspace("ro_on_request_denied.txt"), + message_contains: &["exec command rejected by user"], + }, + }, + #[cfg(not(target_os = "linux"))] // TODO (pakrym): figure out why linux behaves differently + ScenarioSpec { + name: "read_only_on_failure_escalates_after_sandbox_error", + approval_policy: OnFailure, + sandbox_policy: SandboxPolicy::ReadOnly, + action: ActionKind::WriteFile { + target: TargetPath::Workspace("ro_on_failure.txt"), + content: "read-only-on-failure", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Approved, + expected_reason: Some("command failed; retry without sandbox?"), + }, + expectation: Expectation::FileCreated { + target: TargetPath::Workspace("ro_on_failure.txt"), + content: "read-only-on-failure", + }, + }, + ScenarioSpec { + name: "read_only_on_request_network_escalates_when_approved", + approval_policy: OnRequest, + sandbox_policy: SandboxPolicy::ReadOnly, + action: ActionKind::FetchUrl { + endpoint: "/ro/network-approved", + response_body: "read-only-network-ok", + }, + with_escalated_permissions: true, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Approved, + expected_reason: None, + }, + expectation: Expectation::NetworkSuccess { + body_contains: "read-only-network-ok", + }, + }, + ScenarioSpec { + name: "apply_patch_shell_requires_patch_approval", + approval_policy: UnlessTrusted, + sandbox_policy: workspace_write(false), + action: ActionKind::ApplyPatchShell { + target: TargetPath::Workspace("apply_patch_shell.txt"), + content: "shell-apply-patch", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: true, + model_override: None, + outcome: Outcome::PatchApproval { + decision: ReviewDecision::Approved, + expected_reason: None, + }, + expectation: Expectation::PatchApplied { + target: TargetPath::Workspace("apply_patch_shell.txt"), + content: "shell-apply-patch", + }, + }, + ScenarioSpec { + name: "apply_patch_function_auto_inside_workspace", + approval_policy: OnRequest, + sandbox_policy: SandboxPolicy::DangerFullAccess, + action: ActionKind::ApplyPatchFunction { + target: TargetPath::Workspace("apply_patch_function.txt"), + content: "function-apply-patch", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: true, + model_override: Some("gpt-5-codex"), + outcome: Outcome::Auto, + expectation: Expectation::PatchApplied { + target: TargetPath::Workspace("apply_patch_function.txt"), + content: "function-apply-patch", + }, + }, + ScenarioSpec { + name: "apply_patch_function_danger_allows_outside_workspace", + approval_policy: OnRequest, + sandbox_policy: SandboxPolicy::DangerFullAccess, + action: ActionKind::ApplyPatchFunction { + target: TargetPath::OutsideWorkspace("apply_patch_function_danger.txt"), + content: "function-patch-danger", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: true, + model_override: Some("gpt-5-codex"), + outcome: Outcome::Auto, + expectation: Expectation::PatchApplied { + target: TargetPath::OutsideWorkspace("apply_patch_function_danger.txt"), + content: "function-patch-danger", + }, + }, + ScenarioSpec { + name: "apply_patch_function_outside_requires_patch_approval", + approval_policy: OnRequest, + sandbox_policy: workspace_write(false), + action: ActionKind::ApplyPatchFunction { + target: TargetPath::OutsideWorkspace("apply_patch_function_outside.txt"), + content: "function-patch-outside", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: true, + model_override: Some("gpt-5-codex"), + outcome: Outcome::PatchApproval { + decision: ReviewDecision::Approved, + expected_reason: None, + }, + expectation: Expectation::PatchApplied { + target: TargetPath::OutsideWorkspace("apply_patch_function_outside.txt"), + content: "function-patch-outside", + }, + }, + ScenarioSpec { + name: "apply_patch_function_outside_denied_blocks_patch", + approval_policy: OnRequest, + sandbox_policy: workspace_write(false), + action: ActionKind::ApplyPatchFunction { + target: TargetPath::OutsideWorkspace("apply_patch_function_outside_denied.txt"), + content: "function-patch-outside-denied", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: true, + model_override: Some("gpt-5-codex"), + outcome: Outcome::PatchApproval { + decision: ReviewDecision::Denied, + expected_reason: None, + }, + expectation: Expectation::FileNotCreated { + target: TargetPath::OutsideWorkspace("apply_patch_function_outside_denied.txt"), + message_contains: &["patch rejected by user"], + }, + }, + ScenarioSpec { + name: "apply_patch_shell_outside_requires_patch_approval", + approval_policy: OnRequest, + sandbox_policy: workspace_write(false), + action: ActionKind::ApplyPatchShell { + target: TargetPath::OutsideWorkspace("apply_patch_shell_outside.txt"), + content: "shell-patch-outside", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: true, + model_override: None, + outcome: Outcome::PatchApproval { + decision: ReviewDecision::Approved, + expected_reason: None, + }, + expectation: Expectation::PatchApplied { + target: TargetPath::OutsideWorkspace("apply_patch_shell_outside.txt"), + content: "shell-patch-outside", + }, + }, + ScenarioSpec { + name: "apply_patch_function_unless_trusted_requires_patch_approval", + approval_policy: UnlessTrusted, + sandbox_policy: workspace_write(false), + action: ActionKind::ApplyPatchFunction { + target: TargetPath::Workspace("apply_patch_function_unless_trusted.txt"), + content: "function-patch-unless-trusted", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: true, + model_override: Some("gpt-5-codex"), + outcome: Outcome::PatchApproval { + decision: ReviewDecision::Approved, + expected_reason: None, + }, + expectation: Expectation::PatchApplied { + target: TargetPath::Workspace("apply_patch_function_unless_trusted.txt"), + content: "function-patch-unless-trusted", + }, + }, + ScenarioSpec { + name: "apply_patch_function_never_rejects_outside_workspace", + approval_policy: Never, + sandbox_policy: workspace_write(false), + action: ActionKind::ApplyPatchFunction { + target: TargetPath::OutsideWorkspace("apply_patch_function_never.txt"), + content: "function-patch-never", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: true, + model_override: Some("gpt-5-codex"), + outcome: Outcome::Auto, + expectation: Expectation::FileNotCreated { + target: TargetPath::OutsideWorkspace("apply_patch_function_never.txt"), + message_contains: &[ + "patch rejected: writing outside of the project; rejected by user approval settings", + ], + }, + }, + ScenarioSpec { + name: "read_only_unless_trusted_requires_approval", + approval_policy: UnlessTrusted, + sandbox_policy: SandboxPolicy::ReadOnly, + action: ActionKind::WriteFile { + target: TargetPath::Workspace("ro_unless_trusted.txt"), + content: "read-only-unless-trusted", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Approved, + expected_reason: None, + }, + expectation: Expectation::FileCreated { + target: TargetPath::Workspace("ro_unless_trusted.txt"), + content: "read-only-unless-trusted", + }, + }, + ScenarioSpec { + name: "read_only_never_reports_sandbox_failure", + approval_policy: Never, + sandbox_policy: SandboxPolicy::ReadOnly, + action: ActionKind::WriteFile { + target: TargetPath::Workspace("ro_never.txt"), + content: "read-only-never", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::FileNotCreated { + target: TargetPath::Workspace("ro_never.txt"), + message_contains: if cfg!(target_os = "linux") { + &["Permission denied"] + } else { + &["failed in sandbox"] + }, + }, + }, + ScenarioSpec { + name: "trusted_command_never_runs_without_prompt", + approval_policy: Never, + sandbox_policy: SandboxPolicy::ReadOnly, + action: ActionKind::RunCommand { + command: &["echo", "trusted-never"], + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::CommandSuccess { + stdout_contains: "trusted-never", + }, + }, + ScenarioSpec { + name: "workspace_write_on_request_allows_workspace_write", + approval_policy: OnRequest, + sandbox_policy: workspace_write(false), + action: ActionKind::WriteFile { + target: TargetPath::Workspace("ww_on_request.txt"), + content: "workspace-on-request", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::FileCreated { + target: TargetPath::Workspace("ww_on_request.txt"), + content: "workspace-on-request", + }, + }, + ScenarioSpec { + name: "workspace_write_network_disabled_blocks_network", + approval_policy: OnRequest, + sandbox_policy: workspace_write(false), + action: ActionKind::FetchUrl { + endpoint: "/ww/network-blocked", + response_body: "workspace-network-blocked", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::NetworkFailure { expect_tag: "ERR:" }, + }, + ScenarioSpec { + name: "workspace_write_on_request_requires_approval_outside_workspace", + approval_policy: OnRequest, + sandbox_policy: workspace_write(false), + action: ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("ww_on_request_outside.txt"), + content: "workspace-on-request-outside", + }, + with_escalated_permissions: true, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Approved, + expected_reason: None, + }, + expectation: Expectation::FileCreated { + target: TargetPath::OutsideWorkspace("ww_on_request_outside.txt"), + content: "workspace-on-request-outside", + }, + }, + ScenarioSpec { + name: "workspace_write_network_enabled_allows_network", + approval_policy: OnRequest, + sandbox_policy: workspace_write(true), + action: ActionKind::FetchUrl { + endpoint: "/ww/network-ok", + response_body: "workspace-network-ok", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::NetworkSuccess { + body_contains: "workspace-network-ok", + }, + }, + #[cfg(not(target_os = "linux"))] // TODO (pakrym): figure out why linux behaves differently + ScenarioSpec { + name: "workspace_write_on_failure_escalates_outside_workspace", + approval_policy: OnFailure, + sandbox_policy: workspace_write(false), + action: ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("ww_on_failure.txt"), + content: "workspace-on-failure", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Approved, + expected_reason: Some("command failed; retry without sandbox?"), + }, + expectation: Expectation::FileCreated { + target: TargetPath::OutsideWorkspace("ww_on_failure.txt"), + content: "workspace-on-failure", + }, + }, + ScenarioSpec { + name: "workspace_write_unless_trusted_requires_approval_outside_workspace", + approval_policy: UnlessTrusted, + sandbox_policy: workspace_write(false), + action: ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("ww_unless_trusted.txt"), + content: "workspace-unless-trusted", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Approved, + expected_reason: None, + }, + expectation: Expectation::FileCreated { + target: TargetPath::OutsideWorkspace("ww_unless_trusted.txt"), + content: "workspace-unless-trusted", + }, + }, + ScenarioSpec { + name: "workspace_write_never_blocks_outside_workspace", + approval_policy: Never, + sandbox_policy: workspace_write(false), + action: ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("ww_never.txt"), + content: "workspace-never", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::FileNotCreated { + target: TargetPath::OutsideWorkspace("ww_never.txt"), + message_contains: if cfg!(target_os = "linux") { + &["Permission denied"] + } else { + &["failed in sandbox"] + }, + }, + }, + ] +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn approval_matrix_covers_all_modes() -> Result<()> { + skip_if_no_network!(Ok(())); + + for scenario in scenarios() { + run_scenario(&scenario).await?; + } + + Ok(()) +} + +async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { + eprintln!("running approval scenario: {}", scenario.name); + let server = start_mock_server().await; + let approval_policy = scenario.approval_policy; + let sandbox_policy = scenario.sandbox_policy.clone(); + let requires_apply_patch_tool = scenario.requires_apply_patch_tool; + let model_override = scenario.model_override; + + let mut builder = test_codex().with_config(move |config| { + config.approval_policy = approval_policy; + config.sandbox_policy = sandbox_policy.clone(); + let model = model_override.unwrap_or("gpt-5"); + config.model = model.to_string(); + config.model_family = + find_family_for_model(model).expect("model should map to a known family"); + if requires_apply_patch_tool { + config.include_apply_patch_tool = true; + } + }); + let test = builder.build(&server).await?; + + let call_id = scenario.name; + let (event, expected_command) = scenario + .action + .prepare(&test, &server, call_id, scenario.with_escalated_permissions) + .await?; + + let _ = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + event, + ev_completed("resp-1"), + ]), + ) + .await; + let results_mock = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ) + .await; + + submit_turn( + &test, + scenario.name, + scenario.approval_policy, + scenario.sandbox_policy.clone(), + ) + .await?; + + match &scenario.outcome { + Outcome::Auto => { + wait_for_completion_without_approval(&test).await; + } + Outcome::ExecApproval { + decision, + expected_reason, + } => { + let command = expected_command + .as_ref() + .expect("exec approval requires shell command"); + let approval = expect_exec_approval(&test, command).await; + if let Some(expected_reason) = expected_reason { + assert_eq!( + approval.reason.as_deref(), + Some(*expected_reason), + "unexpected approval reason for {}", + scenario.name + ); + } + test.codex + .submit(Op::ExecApproval { + id: "0".into(), + decision: *decision, + }) + .await?; + wait_for_completion(&test).await; + } + Outcome::PatchApproval { + decision, + expected_reason, + } => { + let approval = expect_patch_approval(&test, call_id).await; + if let Some(expected_reason) = expected_reason { + assert_eq!( + approval.reason.as_deref(), + Some(*expected_reason), + "unexpected patch approval reason for {}", + scenario.name + ); + } + test.codex + .submit(Op::PatchApproval { + id: "0".into(), + decision: *decision, + }) + .await?; + wait_for_completion(&test).await; + } + } + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + scenario.expectation.verify(&test, &result)?; + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 6008811dea..1a08c6df78 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -2,6 +2,8 @@ #[cfg(not(target_os = "windows"))] mod abort_tasks; +#[cfg(not(target_os = "windows"))] +mod approvals; mod cli_stream; mod client; mod compact; From 3282e86a60b39d79c9c31ee0e62aa6f75cbb6c04 Mon Sep 17 00:00:00 2001 From: needs <624097+needs@users.noreply.github.com> Date: Mon, 20 Oct 2025 18:54:59 +0200 Subject: [PATCH 033/287] feat: add images support to the Codex Typescript SDK (#5281) Extend `run` and `runStreamed` input to be either a `string` or structured input. A structured input is an array of text parts and/or image paths, which will then be fed to the CLI through the `--image` argument. Text parts are combined with double newlines. For instance: ```ts const turn = await thread.run([ { type: "text", text: "Describe these screenshots" }, { type: "local_image", path: "./ui.png" }, { type: "local_image", path: "./diagram.jpg" }, { type: "text", text: "Thanks!" }, ]); ``` Ends up launching the CLI with: ``` codex exec --image foo.png --image bar.png "Describe these screenshots\n\nThanks!" ``` The complete `Input` type for both function now is: ```ts export type UserInput = | { type: "text"; text: string; } | { type: "local_image"; path: string; }; export type Input = string | UserInput[]; ``` This brings the Codex SDK closer to feature parity with the CLI. Adresses #5280 . --- sdk/typescript/README.md | 14 +++++- sdk/typescript/src/exec.ts | 7 +++ sdk/typescript/src/index.ts | 2 +- sdk/typescript/src/thread.ts | 38 +++++++++++++--- sdk/typescript/tests/run.test.ts | 76 ++++++++++++++++++++++++++++++++ 5 files changed, 130 insertions(+), 7 deletions(-) diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 1aeffce673..df36892337 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -83,6 +83,18 @@ const turn = await thread.run("Summarize repository status", { console.log(turn.finalResponse); ``` +### Attaching images + +Provide structured input entries when you need to include images alongside text. Text entries are concatenated into the final prompt while image entries are passed to the Codex CLI via `--image`. + +```typescript +const turn = await thread.run([ + { type: "text", text: "Describe these screenshots" }, + { type: "local_image", path: "./ui.png" }, + { type: "local_image", path: "./diagram.jpg" }, +]); +``` + ### Resuming an existing thread Threads are persisted in `~/.codex/sessions`. If you lose the in-memory `Thread` object, reconstruct it with `resumeThread()` and keep going. @@ -95,7 +107,7 @@ await thread.run("Implement the fix"); ### Working directory controls -Codex runs in the current working directory by default. To avoid unrecoverable errors, Codex requires the working directory to be a Git repository. You can skip the Git repository check by passing the `skipGitRepoCheck` option when creating a thread. +Codex runs in the current working directory by default. To avoid unrecoverable errors, Codex requires the working directory to be a Git repository. You can skip the Git repository check by passing the `skipGitRepoCheck` option when creating a thread. ```typescript const thread = codex.startThread({ diff --git a/sdk/typescript/src/exec.ts b/sdk/typescript/src/exec.ts index b1cf4c414a..45ae5fc646 100644 --- a/sdk/typescript/src/exec.ts +++ b/sdk/typescript/src/exec.ts @@ -11,6 +11,7 @@ export type CodexExecArgs = { baseUrl?: string; apiKey?: string; threadId?: string | null; + images?: string[]; // --model model?: string; // --sandbox @@ -55,6 +56,12 @@ export class CodexExec { commandArgs.push("--output-schema", args.outputSchemaFile); } + if (args.images?.length) { + for (const image of args.images) { + commandArgs.push("--image", image); + } + } + if (args.threadId) { commandArgs.push("resume", args.threadId); } diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 6e1ace9d14..8e6a71df8c 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -24,7 +24,7 @@ export type { } from "./items"; export { Thread } from "./thread"; -export type { RunResult, RunStreamedResult, Input } from "./thread"; +export type { RunResult, RunStreamedResult, Input, UserInput } from "./thread"; export { Codex } from "./codex"; diff --git a/sdk/typescript/src/thread.ts b/sdk/typescript/src/thread.ts index 0caf52b486..2306df25b6 100644 --- a/sdk/typescript/src/thread.ts +++ b/sdk/typescript/src/thread.ts @@ -25,7 +25,17 @@ export type StreamedTurn = { export type RunStreamedResult = StreamedTurn; /** An input to send to the agent. */ -export type Input = string; +export type UserInput = + | { + type: "text"; + text: string; + } + | { + type: "local_image"; + path: string; + }; + +export type Input = string | UserInput[]; /** Respesent a thread of conversation with the agent. One thread can have multiple consecutive turns. */ export class Thread { @@ -53,21 +63,23 @@ export class Thread { } /** Provides the input to the agent and streams events as they are produced during the turn. */ - async runStreamed(input: string, turnOptions: TurnOptions = {}): Promise { + async runStreamed(input: Input, turnOptions: TurnOptions = {}): Promise { return { events: this.runStreamedInternal(input, turnOptions) }; } private async *runStreamedInternal( - input: string, + input: Input, turnOptions: TurnOptions = {}, ): AsyncGenerator { const { schemaPath, cleanup } = await createOutputSchemaFile(turnOptions.outputSchema); const options = this._threadOptions; + const { prompt, images } = normalizeInput(input); const generator = this._exec.run({ - input, + input: prompt, baseUrl: this._options.baseUrl, apiKey: this._options.apiKey, threadId: this._id, + images, model: options?.model, sandboxMode: options?.sandboxMode, workingDirectory: options?.workingDirectory, @@ -93,7 +105,7 @@ export class Thread { } /** Provides the input to the agent and returns the completed turn. */ - async run(input: string, turnOptions: TurnOptions = {}): Promise { + async run(input: Input, turnOptions: TurnOptions = {}): Promise { const generator = this.runStreamedInternal(input, turnOptions); const items: ThreadItem[] = []; let finalResponse: string = ""; @@ -118,3 +130,19 @@ export class Thread { return { items, finalResponse, usage }; } } + +function normalizeInput(input: Input): { prompt: string; images: string[] } { + if (typeof input === "string") { + return { prompt: input, images: [] }; + } + const promptParts: string[] = []; + const images: string[] = []; + for (const item of input) { + if (item.type === "text") { + promptParts.push(item.text); + } else if (item.type === "local_image") { + images.push(item.path); + } + } + return { prompt: promptParts.join("\n\n"), images }; +} diff --git a/sdk/typescript/tests/run.test.ts b/sdk/typescript/tests/run.test.ts index de7ef5555d..f495b1e6ec 100644 --- a/sdk/typescript/tests/run.test.ts +++ b/sdk/typescript/tests/run.test.ts @@ -279,6 +279,82 @@ describe("Codex", () => { await close(); } }); + it("combines structured text input segments", async () => { + const { url, close, requests } = await startResponsesTestProxy({ + statusCode: 200, + responseBodies: [ + sse( + responseStarted("response_1"), + assistantMessage("Combined input applied", "item_1"), + responseCompleted("response_1"), + ), + ], + }); + + try { + const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); + + const thread = client.startThread(); + await thread.run([ + { type: "text", text: "Describe file changes" }, + { type: "text", text: "Focus on impacted tests" }, + ]); + + const payload = requests[0]; + expect(payload).toBeDefined(); + const lastUser = payload!.json.input.at(-1); + expect(lastUser?.content?.[0]?.text).toBe("Describe file changes\n\nFocus on impacted tests"); + } finally { + await close(); + } + }); + it("forwards images to exec", async () => { + const { url, close } = await startResponsesTestProxy({ + statusCode: 200, + responseBodies: [ + sse( + responseStarted("response_1"), + assistantMessage("Images applied", "item_1"), + responseCompleted("response_1"), + ), + ], + }); + + const { args: spawnArgs, restore } = codexExecSpy(); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codex-images-")); + const imagesDirectoryEntries: [string, string] = [ + path.join(tempDir, "first.png"), + path.join(tempDir, "second.jpg"), + ]; + imagesDirectoryEntries.forEach((image, index) => { + fs.writeFileSync(image, `image-${index}`); + }); + + try { + const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); + + const thread = client.startThread(); + await thread.run([ + { type: "text", text: "describe the images" }, + { type: "local_image", path: imagesDirectoryEntries[0] }, + { type: "local_image", path: imagesDirectoryEntries[1] }, + ]); + + const commandArgs = spawnArgs[0]; + expect(commandArgs).toBeDefined(); + const forwardedImages: string[] = []; + for (let i = 0; i < commandArgs!.length; i += 1) { + if (commandArgs![i] === "--image") { + forwardedImages.push(commandArgs![i + 1] ?? ""); + } + } + expect(forwardedImages).toEqual(imagesDirectoryEntries); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + restore(); + await close(); + } + }); it("runs in provided working directory", async () => { const { url, close } = await startResponsesTestProxy({ statusCode: 200, From 0e8d937a3f3b8fb756f8ce0b6492f30fa9a4a6e7 Mon Sep 17 00:00:00 2001 From: hxreborn <32096750+hxreborn@users.noreply.github.com> Date: Mon, 20 Oct 2025 19:24:39 +0200 Subject: [PATCH 034/287] Strip zsh -lc wrapper from TUI command headers (#5374) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends shell wrapper stripping in TUI to handle `zsh -lc` in addition to `bash -lc`. Currently, Linux users (and macOS users with zsh profiles) see cluttered command headers like `• Ran zsh -lc "echo hello"` instead of `• Ran echo hello`. This happens because `codex-rs/tui/src/exec_command.rs` only checks for literal `"bash"`, ignoring `zsh` and absolute paths like `/usr/bin/zsh`. **Changes:** - Added `is_login_shell_with_lc` helper that extracts shell basename and matches against `bash` or `zsh` - Updated pattern matching to use the helper instead of hardcoded check - Added test coverage for zsh and absolute paths (`/usr/bin/zsh`, `/bin/bash`) **Testing:** ```bash cd codex-rs cargo test strip_bash_lc_and_escape -p codex-tui ``` All 4 test cases pass (bash, zsh, and absolute paths for both). Closes #4201 --- .../tui/src/bottom_pane/approval_overlay.rs | 9 +++---- codex-rs/tui/src/exec_command.rs | 26 ++++++++++++++++++- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index ce0e3c9c95..e8ecc084c4 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -485,11 +485,10 @@ mod tests { }) .collect(); let expected = vec![ - "✔ You approved codex to".to_string(), - " run /bin/zsh -lc 'git add".to_string(), - " tui/src/render/mod.rs tui/".to_string(), - " src/render/renderable.rs'".to_string(), - " this time".to_string(), + "✔ You approved codex to run".to_string(), + " git add tui/src/render/".to_string(), + " mod.rs tui/src/render/".to_string(), + " renderable.rs this time".to_string(), ]; assert_eq!(rendered, expected); } diff --git a/codex-rs/tui/src/exec_command.rs b/codex-rs/tui/src/exec_command.rs index 5c9a0d039a..70ec4e84c7 100644 --- a/codex-rs/tui/src/exec_command.rs +++ b/codex-rs/tui/src/exec_command.rs @@ -8,9 +8,17 @@ pub(crate) fn escape_command(command: &[String]) -> String { try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" ")) } +fn is_login_shell_with_lc(shell: &str) -> bool { + let shell_name = std::path::Path::new(shell) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(shell); + matches!(shell_name, "bash" | "zsh") +} + pub(crate) fn strip_bash_lc_and_escape(command: &[String]) -> String { match command { - [first, second, third] if first == "bash" && second == "-lc" => third.clone(), + [first, second, third] if is_login_shell_with_lc(first) && second == "-lc" => third.clone(), _ => escape_command(command), } } @@ -46,8 +54,24 @@ mod tests { #[test] fn test_strip_bash_lc_and_escape() { + // Test bash let args = vec!["bash".into(), "-lc".into(), "echo hello".into()]; let cmdline = strip_bash_lc_and_escape(&args); assert_eq!(cmdline, "echo hello"); + + // Test zsh + let args = vec!["zsh".into(), "-lc".into(), "echo hello".into()]; + let cmdline = strip_bash_lc_and_escape(&args); + assert_eq!(cmdline, "echo hello"); + + // Test absolute path to zsh + let args = vec!["/usr/bin/zsh".into(), "-lc".into(), "echo hello".into()]; + let cmdline = strip_bash_lc_and_escape(&args); + assert_eq!(cmdline, "echo hello"); + + // Test absolute path to bash + let args = vec!["/bin/bash".into(), "-lc".into(), "echo hello".into()]; + let cmdline = strip_bash_lc_and_escape(&args); + assert_eq!(cmdline, "echo hello"); } } From 73a1787eb8dea258929422780b3dfb6d38955aab Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Mon, 20 Oct 2025 10:43:40 -0700 Subject: [PATCH 035/287] Update Homebrew install instructions to use cask (#5377) ## Summary - update the README install snippets to use `brew install --cask codex` - mirror the same change in the Rust CLI README Address #5317 https://chatgpt.com/codex/tasks/task_i_68f65682543083269254cd64d290df28 --------- Co-authored-by: pakrym-oai --- README.md | 4 ++-- codex-rs/README.md | 2 +- docs/contributing.md | 2 +- docs/release_management.md | 12 +++++------- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 3a78f96573..722048b30b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

npm i -g @openai/codex
or brew install codex

+

npm i -g @openai/codex
or brew install --cask codex

Codex CLI is a coding agent from OpenAI that runs locally on your computer.
@@ -24,7 +24,7 @@ npm install -g @openai/codex Alternatively, if you use Homebrew: ```shell -brew install codex +brew install --cask codex ``` Then simply run `codex` to get started: diff --git a/codex-rs/README.md b/codex-rs/README.md index 29a4e96b89..e7dfadb789 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -11,7 +11,7 @@ npm i -g @openai/codex codex ``` -You can also install via Homebrew (`brew install codex`) or download a platform-specific release directly from our [GitHub Releases](https://github.com/openai/codex/releases). +You can also install via Homebrew (`brew install --cask codex`) or download a platform-specific release directly from our [GitHub Releases](https://github.com/openai/codex/releases). ## Documentation quickstart diff --git a/docs/contributing.md b/docs/contributing.md index eb67f1ce59..155f9f1ebe 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -87,7 +87,7 @@ This will push the commit using the tag `rust-v${VERSION}`, which in turn kicks If everything looks good in the generated GitHub Release, uncheck the **pre-release** box so it is the latest release. -Create a PR to update [`Formula/c/codex.rb`](https://github.com/Homebrew/homebrew-core/blob/main/Formula/c/codex.rb) on Homebrew. +Create a PR to update [`Cask/c/codex.rb`](https://github.com/Homebrew/homebrew-cask/blob/main/Formula/c/codex.rb) on Homebrew. ### Security & responsible AI diff --git a/docs/release_management.md b/docs/release_management.md index d2b3264554..05148b1c93 100644 --- a/docs/release_management.md +++ b/docs/release_management.md @@ -4,7 +4,7 @@ Currently, we made Codex binaries available in three places: - GitHub Releases https://github.com/openai/codex/releases/ - `@openai/codex` on npm: https://www.npmjs.com/package/@openai/codex -- `codex` on Homebrew: https://formulae.brew.sh/formula/codex +- `codex` on Homebrew: https://formulae.brew.sh/cask/codex # Cutting a Release @@ -34,14 +34,12 @@ The GitHub Action is responsible for publishing to npm. ## Publishing to Homebrew -For Homebrew, we are properly set up with their automation system, so every few hours or so it will check our GitHub repo to see if there is a new release. When it finds one, it will put up a PR to create the equivalent Homebrew release, which entails building Codex CLI from source on various versions of macOS. +For Homebrew, we ship Codex as a cask. Homebrew's automation system checks our GitHub repo every few hours for a new release and will open a PR to update the cask with the latest binary. Inevitably, you just have to refresh this page periodically to see if the release has been picked up by their automation system: -https://github.com/Homebrew/homebrew-core/pulls?q=%3Apr+codex +https://github.com/Homebrew/homebrew-cask/pulls?q=%3Apr+codex -Once everything builds, a Homebrew admin has to approve the PR. Again, the whole process takes several hours and we don't have total control over it, but it seems to work pretty well. +For reference, our Homebrew cask lives at: -For reference, our Homebrew formula lives at: - -https://github.com/Homebrew/homebrew-core/blob/main/Formula/c/codex.rb +https://github.com/Homebrew/homebrew-cask/blob/main/Casks/c/codex.rb From cda6db6ccf133f2650c6441fbec3f80ab14a381d Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 20 Oct 2025 11:05:55 -0700 Subject: [PATCH 036/287] Always enable plan tool in exec (#5380) Fixes: https://github.com/openai/codex/issues/5359 --- codex-rs/exec/src/cli.rs | 4 ++-- codex-rs/exec/src/lib.rs | 6 +++++- docs/advanced.md | 21 ++++++++++----------- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index d264eb61b0..67c072e021 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -68,8 +68,8 @@ pub struct Cli { pub json: bool, /// Whether to include the plan tool in the conversation. - #[arg(long = "include-plan-tool", default_value_t = false)] - pub include_plan_tool: bool, + #[arg(long = "include-plan-tool")] + pub include_plan_tool: Option, /// Specifies file where the last message from the agent should be written. #[arg(long = "output-last-message", short = 'o', value_name = "FILE")] diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 953c997685..afd9d44a73 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -74,6 +74,10 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any config_overrides, } = cli; + if include_plan_tool.is_some() { + eprintln!("include-plan-tool is deprecated. Plan tool is now enabled by default."); + } + // Determine the prompt source (parent or subcommand) and read from stdin if needed. let prompt_arg = match &command { // Allow prompt before the subcommand by falling back to the parent-level prompt @@ -177,7 +181,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any model_provider, codex_linux_sandbox_exe, base_instructions: None, - include_plan_tool: Some(include_plan_tool), + include_plan_tool: Some(include_plan_tool.unwrap_or(true)), include_apply_patch_tool: None, include_view_image_tool: None, show_raw_agent_reasoning: oss.then_some(true), diff --git a/docs/advanced.md b/docs/advanced.md index 4bf05a8032..ebcd6e34fd 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -40,17 +40,16 @@ Send a `tools/list` request and you will see that there are two tools available: **`codex`** - Run a Codex session. Accepts configuration parameters matching the Codex Config struct. The `codex` tool takes the following properties: -| Property | Type | Description | -| ----------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **`prompt`** (required) | string | The initial user prompt to start the Codex conversation. | -| `approval-policy` | string | Approval policy for shell commands generated by the model: `untrusted`, `on-failure`, `never`. | -| `base-instructions` | string | The set of instructions to use instead of the default ones. | -| `config` | object | Individual [config settings](https://github.com/openai/codex/blob/main/docs/config.md#config) that will override what is in `$CODEX_HOME/config.toml`. | -| `cwd` | string | Working directory for the session. If relative, resolved against the server process's current directory. | -| `include-plan-tool` | boolean | Whether to include the plan tool in the conversation. | -| `model` | string | Optional override for the model name (e.g. `o3`, `o4-mini`). | -| `profile` | string | Configuration profile from `config.toml` to specify default options. | -| `sandbox` | string | Sandbox mode: `read-only`, `workspace-write`, or `danger-full-access`. | +| Property | Type | Description | +| ----------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | --- | +| **`prompt`** (required) | string | The initial user prompt to start the Codex conversation. | +| `approval-policy` | string | Approval policy for shell commands generated by the model: `untrusted`, `on-failure`, `never`. | +| `base-instructions` | string | The set of instructions to use instead of the default ones. | +| `config` | object | Individual [config settings](https://github.com/openai/codex/blob/main/docs/config.md#config) that will override what is in `$CODEX_HOME/config.toml`. | +| `cwd` | string | Working directory for the session. If relative, resolved against the server process's current directory. | | +| `model` | string | Optional override for the model name (e.g. `o3`, `o4-mini`). | +| `profile` | string | Configuration profile from `config.toml` to specify default options. | +| `sandbox` | string | Sandbox mode: `read-only`, `workspace-write`, or `danger-full-access`. | **`codex-reply`** - Continue a Codex session by providing the conversation id and prompt. The `codex-reply` tool takes the following properties: From 049a61bcfcc2d7f4d174630beec03506ef2cf3b3 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 20 Oct 2025 11:29:49 -0700 Subject: [PATCH 037/287] Auto compact at ~90% (#5292) Users now hit a window exceeded limit and they usually don't know what to do. This starts auto compact at ~90% of the window. --- AGENTS.md | 1 + codex-rs/core/src/client.rs | 20 ++-- codex-rs/core/src/codex.rs | 2 +- codex-rs/core/src/config.rs | 16 ++-- codex-rs/core/src/model_family.rs | 8 ++ codex-rs/core/src/openai_model_info.rs | 32 ++++--- codex-rs/core/src/state/session.rs | 4 +- codex-rs/core/tests/common/responses.rs | 2 +- codex-rs/core/tests/suite/client.rs | 13 ++- codex-rs/core/tests/suite/compact.rs | 96 +++++++++++++++++++ codex-rs/exec/src/exec_events.rs | 6 +- codex-rs/otel/src/otel_event_manager.rs | 14 +-- codex-rs/protocol/src/num_format.rs | 19 ++-- codex-rs/protocol/src/protocol.rs | 55 ++++++----- codex-rs/tui/src/bottom_pane/chat_composer.rs | 4 +- codex-rs/tui/src/bottom_pane/footer.rs | 6 +- codex-rs/tui/src/bottom_pane/mod.rs | 4 +- codex-rs/tui/src/chatwidget.rs | 18 ++-- codex-rs/tui/src/status/card.rs | 12 +-- codex-rs/tui/src/status/helpers.rs | 12 ++- codex-rs/tui/src/status/rate_limits.rs | 2 +- 21 files changed, 236 insertions(+), 110 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 81b8d10ac7..de6ef8b9da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ In the codex-rs folder where the rust code lives: - Always collapse if statements per https://rust-lang.github.io/rust-clippy/master/index.html#collapsible_if - Always inline format! args when possible per https://rust-lang.github.io/rust-clippy/master/index.html#uninlined_format_args - Use method references over closures when possible per https://rust-lang.github.io/rust-clippy/master/index.html#redundant_closure_for_method_calls +- Do not use unsigned integer even if the number cannot be negative. - When writing tests, prefer comparing the equality of entire objects over fields one by one. - When making a change that adds or changes an API, ensure that the documentation in the `docs/` folder is up to date if applicable. diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index b215106d2b..6865e67f93 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -112,10 +112,12 @@ impl ModelClient { } } - pub fn get_model_context_window(&self) -> Option { + pub fn get_model_context_window(&self) -> Option { + let pct = self.config.model_family.effective_context_window_percent; self.config .model_context_window .or_else(|| get_model_info(&self.config.model_family).map(|info| info.context_window)) + .map(|w| w.saturating_mul(pct) / 100) } pub fn get_auto_compact_token_limit(&self) -> Option { @@ -544,11 +546,11 @@ struct ResponseCompleted { #[derive(Debug, Deserialize)] struct ResponseCompletedUsage { - input_tokens: u64, + input_tokens: i64, input_tokens_details: Option, - output_tokens: u64, + output_tokens: i64, output_tokens_details: Option, - total_tokens: u64, + total_tokens: i64, } impl From for TokenUsage { @@ -571,12 +573,12 @@ impl From for TokenUsage { #[derive(Debug, Deserialize)] struct ResponseCompletedInputTokensDetails { - cached_tokens: u64, + cached_tokens: i64, } #[derive(Debug, Deserialize)] struct ResponseCompletedOutputTokensDetails { - reasoning_tokens: u64, + reasoning_tokens: i64, } fn attach_item_ids(payload_json: &mut Value, original_items: &[ResponseItem]) { @@ -633,7 +635,7 @@ fn parse_rate_limit_window( let used_percent: Option = parse_header_f64(headers, used_percent_header); used_percent.and_then(|used_percent| { - let window_minutes = parse_header_u64(headers, window_minutes_header); + let window_minutes = parse_header_i64(headers, window_minutes_header); let resets_at = parse_header_str(headers, resets_header) .map(str::trim) .filter(|value| !value.is_empty()) @@ -658,8 +660,8 @@ fn parse_header_f64(headers: &HeaderMap, name: &str) -> Option { .filter(|v| v.is_finite()) } -fn parse_header_u64(headers: &HeaderMap, name: &str) -> Option { - parse_header_str(headers, name)?.parse::().ok() +fn parse_header_i64(headers: &HeaderMap, name: &str) -> Option { + parse_header_str(headers, name)?.parse::().ok() } fn parse_header_str<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 2ce0997075..b7554f0e14 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1778,7 +1778,7 @@ pub(crate) async fn run_task( .as_ref() .map(TokenUsage::tokens_in_context_window); let token_limit_reached = total_usage_tokens - .map(|tokens| (tokens as i64) >= limit) + .map(|tokens| tokens >= limit) .unwrap_or(false); let mut items_to_record_in_conversation_history = Vec::::new(); let mut responses = Vec::::new(); diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 0283e360d4..93d0ad4ba0 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -85,10 +85,10 @@ pub struct Config { pub model_family: ModelFamily, /// Size of the context window for the model, in tokens. - pub model_context_window: Option, + pub model_context_window: Option, /// Maximum number of output tokens. - pub model_max_output_tokens: Option, + pub model_max_output_tokens: Option, /// Token usage threshold triggering auto-compaction of conversation history. pub model_auto_compact_token_limit: Option, @@ -824,10 +824,10 @@ pub struct ConfigToml { pub model_provider: Option, /// Size of the context window for the model, in tokens. - pub model_context_window: Option, + pub model_context_window: Option, /// Maximum number of output tokens. - pub model_max_output_tokens: Option, + pub model_max_output_tokens: Option, /// Token usage threshold triggering auto-compaction of conversation history. pub model_auto_compact_token_limit: Option, @@ -2805,7 +2805,7 @@ model_verbosity = "high" model_family: find_family_for_model("o3").expect("known model slug"), model_context_window: Some(200_000), model_max_output_tokens: Some(100_000), - model_auto_compact_token_limit: None, + model_auto_compact_token_limit: Some(180_000), model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), approval_policy: AskForApproval::Never, @@ -2874,7 +2874,7 @@ model_verbosity = "high" model_family: find_family_for_model("gpt-3.5-turbo").expect("known model slug"), model_context_window: Some(16_385), model_max_output_tokens: Some(4_096), - model_auto_compact_token_limit: None, + model_auto_compact_token_limit: Some(14_746), model_provider_id: "openai-chat-completions".to_string(), model_provider: fixture.openai_chat_completions_provider.clone(), approval_policy: AskForApproval::UnlessTrusted, @@ -2958,7 +2958,7 @@ model_verbosity = "high" model_family: find_family_for_model("o3").expect("known model slug"), model_context_window: Some(200_000), model_max_output_tokens: Some(100_000), - model_auto_compact_token_limit: None, + model_auto_compact_token_limit: Some(180_000), model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), approval_policy: AskForApproval::OnFailure, @@ -3028,7 +3028,7 @@ model_verbosity = "high" model_family: find_family_for_model("gpt-5").expect("known model slug"), model_context_window: Some(272_000), model_max_output_tokens: Some(128_000), - model_auto_compact_token_limit: None, + model_auto_compact_token_limit: Some(244_800), model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), approval_policy: AskForApproval::OnFailure, diff --git a/codex-rs/core/src/model_family.rs b/codex-rs/core/src/model_family.rs index 80b3f27986..623cc26ef9 100644 --- a/codex-rs/core/src/model_family.rs +++ b/codex-rs/core/src/model_family.rs @@ -48,6 +48,12 @@ pub struct ModelFamily { /// Names of beta tools that should be exposed to this model family. pub experimental_supported_tools: Vec, + + /// Percentage of the context window considered usable for inputs, after + /// reserving headroom for system prompts, tool overhead, and model output. + /// This is applied when computing the effective context window seen by + /// consumers. + pub effective_context_window_percent: i64, } macro_rules! model_family { @@ -66,6 +72,7 @@ macro_rules! model_family { apply_patch_tool_type: None, base_instructions: BASE_INSTRUCTIONS.to_string(), experimental_supported_tools: Vec::new(), + effective_context_window_percent: 95, }; // apply overrides $( @@ -175,5 +182,6 @@ pub fn derive_default_model_family(model: &str) -> ModelFamily { apply_patch_tool_type: None, base_instructions: BASE_INSTRUCTIONS.to_string(), experimental_supported_tools: Vec::new(), + effective_context_window_percent: 95, } } diff --git a/codex-rs/core/src/openai_model_info.rs b/codex-rs/core/src/openai_model_info.rs index d1d2305aaa..e0d0bfd1b2 100644 --- a/codex-rs/core/src/openai_model_info.rs +++ b/codex-rs/core/src/openai_model_info.rs @@ -1,5 +1,9 @@ use crate::model_family::ModelFamily; +// Shared constants for commonly used window/token sizes. +pub(crate) const CONTEXT_WINDOW_272K: i64 = 272_000; +pub(crate) const MAX_OUTPUT_TOKENS_128K: i64 = 128_000; + /// Metadata about a model, particularly OpenAI models. /// We may want to consider including details like the pricing for /// input tokens, output tokens, etc., though users will need to be able to @@ -8,10 +12,10 @@ use crate::model_family::ModelFamily; #[derive(Debug)] pub(crate) struct ModelInfo { /// Size of the context window in tokens. This is the maximum size of the input context. - pub(crate) context_window: u64, + pub(crate) context_window: i64, /// Maximum number of output tokens that can be generated for the model. - pub(crate) max_output_tokens: u64, + pub(crate) max_output_tokens: i64, /// Token threshold where we should automatically compact conversation history. This considers /// input tokens + output tokens of this turn. @@ -19,13 +23,17 @@ pub(crate) struct ModelInfo { } impl ModelInfo { - const fn new(context_window: u64, max_output_tokens: u64) -> Self { + const fn new(context_window: i64, max_output_tokens: i64) -> Self { Self { context_window, max_output_tokens, - auto_compact_token_limit: None, + auto_compact_token_limit: Some(Self::default_auto_compact_limit(context_window)), } } + + const fn default_auto_compact_limit(context_window: i64) -> i64 { + (context_window * 9) / 10 + } } pub(crate) fn get_model_info(model_family: &ModelFamily) -> Option { @@ -62,15 +70,17 @@ pub(crate) fn get_model_info(model_family: &ModelFamily) -> Option { // https://platform.openai.com/docs/models/gpt-3.5-turbo "gpt-3.5-turbo" => Some(ModelInfo::new(16_385, 4_096)), - _ if slug.starts_with("gpt-5-codex") => Some(ModelInfo { - context_window: 272_000, - max_output_tokens: 128_000, - auto_compact_token_limit: Some(350_000), - }), + _ if slug.starts_with("gpt-5-codex") => { + Some(ModelInfo::new(CONTEXT_WINDOW_272K, MAX_OUTPUT_TOKENS_128K)) + } - _ if slug.starts_with("gpt-5") => Some(ModelInfo::new(272_000, 128_000)), + _ if slug.starts_with("gpt-5") => { + Some(ModelInfo::new(CONTEXT_WINDOW_272K, MAX_OUTPUT_TOKENS_128K)) + } - _ if slug.starts_with("codex-") => Some(ModelInfo::new(272_000, 128_000)), + _ if slug.starts_with("codex-") => { + Some(ModelInfo::new(CONTEXT_WINDOW_272K, MAX_OUTPUT_TOKENS_128K)) + } _ => None, } diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index fa7543ff53..eaa3bcb92a 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -48,7 +48,7 @@ impl SessionState { pub(crate) fn update_token_info_from_usage( &mut self, usage: &TokenUsage, - model_context_window: Option, + model_context_window: Option, ) { self.token_info = TokenUsageInfo::new_or_append( &self.token_info, @@ -67,7 +67,7 @@ impl SessionState { (self.token_info.clone(), self.latest_rate_limits.clone()) } - pub(crate) fn set_token_usage_full(&mut self, context_window: u64) { + 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), None => { diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 98b3eca1f4..a8a777ae4f 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -138,7 +138,7 @@ pub fn ev_response_created(id: &str) -> Value { }) } -pub fn ev_completed_with_tokens(id: &str, total_tokens: u64) -> Value { +pub fn ev_completed_with_tokens(id: &str, total_tokens: i64) -> Value { serde_json::json!({ "type": "response.completed", "response": { diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 817f2c2a08..d84d8c03da 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -858,8 +858,8 @@ async fn token_count_includes_rate_limits_snapshot() { "reasoning_output_tokens": 0, "total_tokens": 123 }, - // Default model is gpt-5-codex in tests → 272000 context window - "model_context_window": 272000 + // Default model is gpt-5-codex in tests → 95% usable context window + "model_context_window": 258400 }, "rate_limits": { "primary": { @@ -985,6 +985,8 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res skip_if_no_network!(Ok(())); let server = MockServer::start().await; + const EFFECTIVE_CONTEXT_WINDOW: i64 = (272_000 * 95) / 100; + responses::mount_sse_once_match( &server, body_string_contains("trigger context window"), @@ -1056,8 +1058,11 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res .info .expect("token usage info present when context window is exceeded"); - assert_eq!(info.model_context_window, Some(272_000)); - assert_eq!(info.total_token_usage.total_tokens, 272_000); + assert_eq!(info.model_context_window, Some(EFFECTIVE_CONTEXT_WINDOW)); + assert_eq!( + info.total_token_usage.total_tokens, + EFFECTIVE_CONTEXT_WINDOW + ); let error_event = wait_for_event(&codex, |ev| matches!(ev, EventMsg::Error(_))).await; let expected_context_window_message = CodexErr::ContextWindowExceeded.to_string(); diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 82750e8503..bd760b8749 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -19,6 +19,7 @@ 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::mount_sse_once; use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; @@ -43,6 +44,7 @@ const CONTEXT_LIMIT_MESSAGE: &str = "Your input exceeds the context window of this model. Please adjust your input and try again."; const DUMMY_FUNCTION_NAME: &str = "unsupported_tool"; const DUMMY_CALL_ID: &str = "call-multi-auto"; +const FUNCTION_CALL_LIMIT_MSG: &str = "function call limit push"; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn summarize_context_three_requests_and_instructions() { @@ -860,3 +862,97 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ "second auto compact request should include the summarization prompt" ); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auto_compact_triggers_after_function_call_over_95_percent_usage() { + skip_if_no_network!(); + + let server = start_mock_server().await; + + let context_window = 100; + let limit = context_window * 90 / 100; + let over_limit_tokens = context_window * 95 / 100 + 1; + + let first_turn = sse(vec![ + ev_function_call(DUMMY_CALL_ID, DUMMY_FUNCTION_NAME, "{}"), + ev_completed_with_tokens("r1", 50), + ]); + let function_call_follow_up = sse(vec![ + ev_assistant_message("m2", FINAL_REPLY), + ev_completed_with_tokens("r2", over_limit_tokens), + ]); + let auto_compact_turn = sse(vec![ + ev_assistant_message("m3", AUTO_SUMMARY_TEXT), + ev_completed_with_tokens("r3", 10), + ]); + let post_auto_compact_turn = sse(vec![ev_completed_with_tokens("r4", 10)]); + + // Mount responses in order and keep mocks only for the ones we assert on. + let first_turn_mock = mount_sse_once(&server, first_turn).await; + let follow_up_mock = mount_sse_once(&server, function_call_follow_up).await; + let auto_compact_mock = mount_sse_once(&server, auto_compact_turn).await; + // We don't assert on the post-compact request, so no need to keep its mock. + mount_sse_once(&server, post_auto_compact_turn).await; + + let model_provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + + let home = TempDir::new().unwrap(); + let mut config = load_default_config_for_test(&home); + config.model_provider = model_provider; + config.model_context_window = Some(context_window); + config.model_auto_compact_token_limit = Some(limit); + + let codex = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")) + .new_conversation(config) + .await + .unwrap() + .conversation; + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: FUNCTION_CALL_LIMIT_MSG.into(), + }], + }) + .await + .unwrap(); + + wait_for_event(&codex, |msg| matches!(msg, EventMsg::TaskComplete(_))).await; + + // Assert first request captured expected user message that triggers function call. + let first_request = first_turn_mock.single_request().input(); + assert!( + first_request.iter().any(|item| { + item.get("type").and_then(|value| value.as_str()) == Some("message") + && item + .get("content") + .and_then(|content| content.as_array()) + .and_then(|entries| entries.first()) + .and_then(|entry| entry.get("text")) + .and_then(|value| value.as_str()) + == Some(FUNCTION_CALL_LIMIT_MSG) + }), + "first request should include the user message that triggers the function call" + ); + + let function_call_output = follow_up_mock + .single_request() + .function_call_output(DUMMY_CALL_ID); + let output_text = function_call_output + .get("output") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + assert!( + output_text.contains(DUMMY_FUNCTION_NAME), + "function call output should be sent before auto compact" + ); + + let auto_compact_body = auto_compact_mock.single_request().body_json().to_string(); + assert!( + auto_compact_body.contains("You have exceeded the maximum number of tokens"), + "auto compact request should include the summarization prompt after exceeding 95% (limit {limit})" + ); +} diff --git a/codex-rs/exec/src/exec_events.rs b/codex-rs/exec/src/exec_events.rs index 606cf9187a..032b912f86 100644 --- a/codex-rs/exec/src/exec_events.rs +++ b/codex-rs/exec/src/exec_events.rs @@ -57,11 +57,11 @@ pub struct TurnFailedEvent { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS, Default)] pub struct Usage { /// The number of input tokens used during the turn. - pub input_tokens: u64, + pub input_tokens: i64, /// The number of cached input tokens used during the turn. - pub cached_input_tokens: u64, + pub cached_input_tokens: i64, /// The number of output tokens used during the turn. - pub output_tokens: u64, + pub output_tokens: i64, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] diff --git a/codex-rs/otel/src/otel_event_manager.rs b/codex-rs/otel/src/otel_event_manager.rs index c02ea19fdf..666d83f822 100644 --- a/codex-rs/otel/src/otel_event_manager.rs +++ b/codex-rs/otel/src/otel_event_manager.rs @@ -86,8 +86,8 @@ impl OtelEventManager { provider_name: &str, reasoning_effort: Option, reasoning_summary: ReasoningSummary, - context_window: Option, - max_output_tokens: Option, + context_window: Option, + max_output_tokens: Option, auto_compact_token_limit: Option, approval_policy: AskForApproval, sandbox_policy: SandboxPolicy, @@ -281,11 +281,11 @@ impl OtelEventManager { pub fn sse_event_completed( &self, - input_token_count: u64, - output_token_count: u64, - cached_token_count: Option, - reasoning_token_count: Option, - tool_token_count: u64, + input_token_count: i64, + output_token_count: i64, + cached_token_count: Option, + reasoning_token_count: Option, + tool_token_count: i64, ) { tracing::event!( tracing::Level::INFO, diff --git a/codex-rs/protocol/src/num_format.rs b/codex-rs/protocol/src/num_format.rs index 72a4ee722d..2c64939b7a 100644 --- a/codex-rs/protocol/src/num_format.rs +++ b/codex-rs/protocol/src/num_format.rs @@ -22,27 +22,28 @@ fn formatter() -> &'static DecimalFormatter { FORMATTER.get_or_init(|| make_local_formatter().unwrap_or_else(make_en_us_formatter)) } -/// Format a u64 with locale-aware digit separators (e.g. "12345" -> "12,345" +/// Format an i64 with locale-aware digit separators (e.g. "12345" -> "12,345" /// for en-US). -pub fn format_with_separators(n: u64) -> String { +pub fn format_with_separators(n: i64) -> String { formatter().format(&Decimal::from(n)).to_string() } -fn format_si_suffix_with_formatter(n: u64, formatter: &DecimalFormatter) -> String { +fn format_si_suffix_with_formatter(n: i64, formatter: &DecimalFormatter) -> String { + let n = n.max(0); if n < 1000 { return formatter.format(&Decimal::from(n)).to_string(); } // Format `n / scale` with the requested number of fractional digits. - let format_scaled = |n: u64, scale: u64, frac_digits: u32| -> String { + let format_scaled = |n: i64, scale: i64, frac_digits: u32| -> String { let value = n as f64 / scale as f64; - let scaled: u64 = (value * 10f64.powi(frac_digits as i32)).round() as u64; + let scaled: i64 = (value * 10f64.powi(frac_digits as i32)).round() as i64; let mut dec = Decimal::from(scaled); dec.multiply_pow10(-(frac_digits as i16)); formatter.format(&dec).to_string() }; - const UNITS: [(u64, &str); 3] = [(1_000, "K"), (1_000_000, "M"), (1_000_000_000, "G")]; + const UNITS: [(i64, &str); 3] = [(1_000, "K"), (1_000_000, "M"), (1_000_000_000, "G")]; let f = n as f64; for &(scale, suffix) in &UNITS { if (100.0 * f / scale as f64).round() < 1000.0 { @@ -57,7 +58,7 @@ fn format_si_suffix_with_formatter(n: u64, formatter: &DecimalFormatter) -> Stri // Above 1000G, keep whole‑G precision. format!( "{}G", - format_with_separators(((n as f64) / 1e9).round() as u64) + format_with_separators(((n as f64) / 1e9).round() as i64) ) } @@ -67,7 +68,7 @@ fn format_si_suffix_with_formatter(n: u64, formatter: &DecimalFormatter) -> Stri /// - 999 -> "999" /// - 1200 -> "1.20K" /// - 123456789 -> "123M" -pub fn format_si_suffix(n: u64) -> String { +pub fn format_si_suffix(n: i64) -> String { format_si_suffix_with_formatter(n, formatter()) } @@ -78,7 +79,7 @@ mod tests { #[test] fn kmg() { let formatter = make_en_us_formatter(); - let fmt = |n: u64| format_si_suffix_with_formatter(n, &formatter); + let fmt = |n: i64| format_si_suffix_with_formatter(n, &formatter); assert_eq!(fmt(0), "0"); assert_eq!(fmt(999), "999"); assert_eq!(fmt(1_000), "1.00K"); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5429e4d402..495768d90d 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -545,21 +545,21 @@ pub struct TaskCompleteEvent { #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct TaskStartedEvent { - pub model_context_window: Option, + pub model_context_window: Option, } #[derive(Debug, Clone, Deserialize, Serialize, Default, TS)] pub struct TokenUsage { #[ts(type = "number")] - pub input_tokens: u64, + pub input_tokens: i64, #[ts(type = "number")] - pub cached_input_tokens: u64, + pub cached_input_tokens: i64, #[ts(type = "number")] - pub output_tokens: u64, + pub output_tokens: i64, #[ts(type = "number")] - pub reasoning_output_tokens: u64, + pub reasoning_output_tokens: i64, #[ts(type = "number")] - pub total_tokens: u64, + pub total_tokens: i64, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] @@ -567,14 +567,14 @@ pub struct TokenUsageInfo { pub total_token_usage: TokenUsage, pub last_token_usage: TokenUsage, #[ts(type = "number | null")] - pub model_context_window: Option, + pub model_context_window: Option, } impl TokenUsageInfo { pub fn new_or_append( info: &Option, last: &Option, - model_context_window: Option, + model_context_window: Option, ) -> Option { if info.is_none() && last.is_none() { return None; @@ -599,9 +599,9 @@ impl TokenUsageInfo { self.last_token_usage = last.clone(); } - pub fn fill_to_context_window(&mut self, context_window: u64) { + pub fn fill_to_context_window(&mut self, context_window: i64) { let previous_total = self.total_token_usage.total_tokens; - let delta = context_window.saturating_sub(previous_total); + let delta = (context_window - previous_total).max(0); self.model_context_window = Some(context_window); self.total_token_usage = TokenUsage { @@ -614,7 +614,7 @@ impl TokenUsageInfo { }; } - pub fn full_context_window(context_window: u64) -> Self { + pub fn full_context_window(context_window: i64) -> Self { let mut info = Self { total_token_usage: TokenUsage::default(), last_token_usage: TokenUsage::default(), @@ -643,40 +643,39 @@ pub struct RateLimitWindow { pub used_percent: f64, /// Rolling window duration, in minutes. #[ts(type = "number | null")] - pub window_minutes: Option, + pub window_minutes: Option, /// Timestamp (RFC3339) when the window resets. #[ts(type = "string | null")] pub resets_at: Option, } // Includes prompts, tools and space to call compact. -const BASELINE_TOKENS: u64 = 12000; +const BASELINE_TOKENS: i64 = 12000; impl TokenUsage { pub fn is_zero(&self) -> bool { self.total_tokens == 0 } - pub fn cached_input(&self) -> u64 { - self.cached_input_tokens + pub fn cached_input(&self) -> i64 { + self.cached_input_tokens.max(0) } - pub fn non_cached_input(&self) -> u64 { - self.input_tokens.saturating_sub(self.cached_input()) + pub fn non_cached_input(&self) -> i64 { + (self.input_tokens - self.cached_input()).max(0) } /// Primary count for display as a single absolute value: non-cached input + output. - pub fn blended_total(&self) -> u64 { - self.non_cached_input() + self.output_tokens + pub fn blended_total(&self) -> i64 { + (self.non_cached_input() + self.output_tokens.max(0)).max(0) } /// For estimating what % of the model's context window is used, we need to account /// for reasoning output tokens from prior turns being dropped from the context window. /// We approximate this here by subtracting reasoning output tokens from the total. /// This will be off for the current turn and pending function calls. - pub fn tokens_in_context_window(&self) -> u64 { - self.total_tokens - .saturating_sub(self.reasoning_output_tokens) + pub fn tokens_in_context_window(&self) -> i64 { + (self.total_tokens - self.reasoning_output_tokens).max(0) } /// Estimate the remaining user-controllable percentage of the model's context window. @@ -689,17 +688,17 @@ impl TokenUsage { /// This normalizes both the numerator and denominator by subtracting the /// baseline, so immediately after the first prompt the UI shows 100% left /// and trends toward 0% as the user fills the effective window. - pub fn percent_of_context_window_remaining(&self, context_window: u64) -> u8 { + pub fn percent_of_context_window_remaining(&self, context_window: i64) -> i64 { if context_window <= BASELINE_TOKENS { return 0; } let effective_window = context_window - BASELINE_TOKENS; - let used = self - .tokens_in_context_window() - .saturating_sub(BASELINE_TOKENS); - let remaining = effective_window.saturating_sub(used); - ((remaining as f32 / effective_window as f32) * 100.0).clamp(0.0, 100.0) as u8 + let used = (self.tokens_in_context_window() - BASELINE_TOKENS).max(0); + let remaining = (effective_window - used).max(0); + ((remaining as f64 / effective_window as f64) * 100.0) + .clamp(0.0, 100.0) + .round() as i64 } /// In-place element-wise sum of token counts. diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 56a6e44de3..a7793205f5 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -108,7 +108,7 @@ pub(crate) struct ChatComposer { custom_prompts: Vec, footer_mode: FooterMode, footer_hint_override: Option>, - context_window_percent: Option, + context_window_percent: Option, } /// Popup state – at most one can be visible at any time. @@ -1511,7 +1511,7 @@ impl ChatComposer { self.is_task_running = running; } - pub(crate) fn set_context_window_percent(&mut self, percent: Option) { + pub(crate) fn set_context_window_percent(&mut self, percent: Option) { if self.context_window_percent != percent { self.context_window_percent = percent; } diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 6e92a0ce68..79d7c60fa7 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -17,7 +17,7 @@ pub(crate) struct FooterProps { pub(crate) esc_backtrack_hint: bool, pub(crate) use_shift_enter_hint: bool, pub(crate) is_task_running: bool, - pub(crate) context_window_percent: Option, + pub(crate) context_window_percent: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -221,8 +221,8 @@ fn build_columns(entries: Vec>) -> Vec> { .collect() } -fn context_window_line(percent: Option) -> Line<'static> { - let percent = percent.unwrap_or(100); +fn context_window_line(percent: Option) -> Line<'static> { + let percent = percent.unwrap_or(100).clamp(0, 100); Line::from(vec![Span::from(format!("{percent}% context left")).dim()]) } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 8d9e84f2aa..69405cd823 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -70,7 +70,7 @@ pub(crate) struct BottomPane { status: Option, /// Queued user messages to show under the status indicator. queued_user_messages: Vec, - context_window_percent: Option, + context_window_percent: Option, } pub(crate) struct BottomPaneParams { @@ -357,7 +357,7 @@ impl BottomPane { } } - pub(crate) fn set_context_window_percent(&mut self, percent: Option) { + pub(crate) fn set_context_window_percent(&mut self, percent: Option) { if self.context_window_percent == percent { return; } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9f308cd00f..8dc6454e4b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -142,9 +142,9 @@ impl RateLimitWarningState { fn take_warnings( &mut self, secondary_used_percent: Option, - secondary_window_minutes: Option, + secondary_window_minutes: Option, primary_used_percent: Option, - primary_window_minutes: Option, + primary_window_minutes: Option, ) -> Vec { let reached_secondary_cap = matches!(secondary_used_percent, Some(percent) if percent == 100.0); @@ -195,12 +195,14 @@ impl RateLimitWarningState { } } -pub(crate) fn get_limits_duration(windows_minutes: u64) -> String { - const MINUTES_PER_HOUR: u64 = 60; - const MINUTES_PER_DAY: u64 = 24 * MINUTES_PER_HOUR; - const MINUTES_PER_WEEK: u64 = 7 * MINUTES_PER_DAY; - const MINUTES_PER_MONTH: u64 = 30 * MINUTES_PER_DAY; - const ROUNDING_BIAS_MINUTES: u64 = 3; +pub(crate) fn get_limits_duration(windows_minutes: i64) -> String { + const MINUTES_PER_HOUR: i64 = 60; + const MINUTES_PER_DAY: i64 = 24 * MINUTES_PER_HOUR; + const MINUTES_PER_WEEK: i64 = 7 * MINUTES_PER_DAY; + const MINUTES_PER_MONTH: i64 = 30 * MINUTES_PER_DAY; + const ROUNDING_BIAS_MINUTES: i64 = 3; + + let windows_minutes = windows_minutes.max(0); if windows_minutes <= MINUTES_PER_DAY.saturating_add(ROUNDING_BIAS_MINUTES) { let adjusted = windows_minutes.saturating_add(ROUNDING_BIAS_MINUTES); diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 4832713bf9..a077a92661 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -31,16 +31,16 @@ use super::rate_limits::render_status_limit_progress_bar; #[derive(Debug, Clone)] struct StatusContextWindowData { - percent_remaining: u8, - tokens_in_context: u64, - window: u64, + percent_remaining: i64, + tokens_in_context: i64, + window: i64, } #[derive(Debug, Clone)] pub(crate) struct StatusTokenUsageData { - total: u64, - input: u64, - output: u64, + total: i64, + input: i64, + output: i64, context_window: Option, } diff --git a/codex-rs/tui/src/status/helpers.rs b/codex-rs/tui/src/status/helpers.rs index 1889d7f4f4..59362e2e2b 100644 --- a/codex-rs/tui/src/status/helpers.rs +++ b/codex-rs/tui/src/status/helpers.rs @@ -103,7 +103,8 @@ pub(crate) fn compose_account_display(config: &Config) -> Option String { +pub(crate) fn format_tokens_compact(value: i64) -> String { + let value = value.max(0); if value == 0 { return "0".to_string(); } @@ -111,14 +112,15 @@ pub(crate) fn format_tokens_compact(value: u64) -> String { return value.to_string(); } + let value_f64 = value as f64; let (scaled, suffix) = if value >= 1_000_000_000_000 { - (value as f64 / 1_000_000_000_000.0, "T") + (value_f64 / 1_000_000_000_000.0, "T") } else if value >= 1_000_000_000 { - (value as f64 / 1_000_000_000.0, "B") + (value_f64 / 1_000_000_000.0, "B") } else if value >= 1_000_000 { - (value as f64 / 1_000_000.0, "M") + (value_f64 / 1_000_000.0, "M") } else { - (value as f64 / 1_000.0, "K") + (value_f64 / 1_000.0, "K") }; let decimals = if scaled < 10.0 { diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index f6a9c06aab..f0c0c97310 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -27,7 +27,7 @@ pub(crate) enum StatusRateLimitData { pub(crate) struct RateLimitWindowDisplay { pub used_percent: f64, pub resets_at: Option, - pub window_minutes: Option, + pub window_minutes: Option, } impl RateLimitWindowDisplay { From 846960ae3dd2946d9ba7d394c2f86101a5810977 Mon Sep 17 00:00:00 2001 From: Rasmus Rygaard Date: Mon, 20 Oct 2025 11:45:11 -0700 Subject: [PATCH 038/287] Generate JSON schema for app-server protocol (#5063) Add annotations and an export script that let us generate app-server protocol types as typescript and JSONSchema. The script itself is a bit hacky because we need to manually label some of the types. Unfortunately it seems that enum variants don't get good names by default and end up with something like `EventMsg1`, `EventMsg2`, etc. I'm not an expert in this by any means, but since this is only run manually and we already need to enumerate the types required to describe the protocol, it didn't seem that much worse. An ideal solution here would be to have some kind of root that we could generate schemas for in one go, but I'm not sure if that's compatible with how we generate the protocol today. --- codex-rs/Cargo.lock | 4 + codex-rs/app-server-protocol/Cargo.toml | 3 + .../app-server-protocol/src/bin/export.rs | 22 + codex-rs/app-server-protocol/src/export.rs | 404 ++++++++++++++++++ .../app-server-protocol/src/jsonrpc_lite.rs | 15 +- codex-rs/app-server-protocol/src/lib.rs | 4 + codex-rs/app-server-protocol/src/protocol.rs | 148 ++++--- codex-rs/mcp-types/Cargo.toml | 1 + codex-rs/mcp-types/generate_mcp_types.py | 5 +- codex-rs/mcp-types/src/lib.rs | 249 +++++------ codex-rs/protocol/Cargo.toml | 1 + codex-rs/protocol/src/config_types.rs | 28 +- codex-rs/protocol/src/conversation_id.rs | 13 + codex-rs/protocol/src/custom_prompts.rs | 3 +- codex-rs/protocol/src/message_history.rs | 3 +- codex-rs/protocol/src/models.rs | 23 +- codex-rs/protocol/src/parse_command.rs | 3 +- codex-rs/protocol/src/plan_tool.rs | 7 +- codex-rs/protocol/src/protocol.rs | 159 ++++--- 19 files changed, 807 insertions(+), 288 deletions(-) create mode 100644 codex-rs/app-server-protocol/src/bin/export.rs create mode 100644 codex-rs/app-server-protocol/src/export.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 0ecada0fb3..c700a2dd1c 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -861,9 +861,11 @@ name = "codex-app-server-protocol" version = "0.0.0" dependencies = [ "anyhow", + "clap", "codex-protocol", "paste", "pretty_assertions", + "schemars 0.8.22", "serde", "serde_json", "strum_macros 0.27.2", @@ -1332,6 +1334,7 @@ dependencies = [ "icu_locale_core", "mcp-types", "mime_guess", + "schemars 0.8.22", "serde", "serde_json", "serde_with", @@ -3579,6 +3582,7 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" name = "mcp-types" version = "0.0.0" dependencies = [ + "schemars 0.8.22", "serde", "serde_json", "ts-rs", diff --git a/codex-rs/app-server-protocol/Cargo.toml b/codex-rs/app-server-protocol/Cargo.toml index b18028fbe4..58b0b9d607 100644 --- a/codex-rs/app-server-protocol/Cargo.toml +++ b/codex-rs/app-server-protocol/Cargo.toml @@ -11,8 +11,11 @@ path = "src/lib.rs" workspace = true [dependencies] +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive"] } codex-protocol = { workspace = true } paste = { workspace = true } +schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } strum_macros = { workspace = true } diff --git a/codex-rs/app-server-protocol/src/bin/export.rs b/codex-rs/app-server-protocol/src/bin/export.rs new file mode 100644 index 0000000000..d029ecbffa --- /dev/null +++ b/codex-rs/app-server-protocol/src/bin/export.rs @@ -0,0 +1,22 @@ +use anyhow::Result; +use clap::Parser; +use std::path::PathBuf; + +#[derive(Parser, Debug)] +#[command( + about = "Generate TypeScript bindings and JSON Schemas for the Codex app-server protocol" +)] +struct Args { + /// Output directory where generated files will be written + #[arg(short = 'o', long = "out", value_name = "DIR")] + out_dir: PathBuf, + + /// Optional Prettier executable path to format generated TypeScript files + #[arg(short = 'p', long = "prettier", value_name = "PRETTIER_BIN")] + prettier: Option, +} + +fn main() -> Result<()> { + let args = Args::parse(); + codex_app_server_protocol::generate_types(&args.out_dir, args.prettier.as_deref()) +} diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs new file mode 100644 index 0000000000..21ba3facc4 --- /dev/null +++ b/codex-rs/app-server-protocol/src/export.rs @@ -0,0 +1,404 @@ +use crate::ClientNotification; +use crate::ClientRequest; +use crate::ServerNotification; +use crate::ServerRequest; +use crate::export_client_response_schemas; +use crate::export_client_responses; +use crate::export_server_response_schemas; +use crate::export_server_responses; +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use schemars::JsonSchema; +use schemars::schema::RootSchema; +use schemars::schema_for; +use serde::Serialize; +use serde_json::Map; +use serde_json::Value; +use std::collections::BTreeMap; +use std::ffi::OsStr; +use std::fs; +use std::io::Read; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +use ts_rs::TS; + +const HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n"; + +macro_rules! for_each_schema_type { + ($macro:ident) => { + $macro!(crate::RequestId); + $macro!(crate::JSONRPCMessage); + $macro!(crate::JSONRPCRequest); + $macro!(crate::JSONRPCNotification); + $macro!(crate::JSONRPCResponse); + $macro!(crate::JSONRPCError); + $macro!(crate::JSONRPCErrorError); + $macro!(crate::AddConversationListenerParams); + $macro!(crate::AddConversationSubscriptionResponse); + $macro!(crate::ApplyPatchApprovalParams); + $macro!(crate::ApplyPatchApprovalResponse); + $macro!(crate::ArchiveConversationParams); + $macro!(crate::ArchiveConversationResponse); + $macro!(crate::AuthMode); + $macro!(crate::AuthStatusChangeNotification); + $macro!(crate::CancelLoginChatGptParams); + $macro!(crate::CancelLoginChatGptResponse); + $macro!(crate::ClientInfo); + $macro!(crate::ClientNotification); + $macro!(crate::ClientRequest); + $macro!(crate::ConversationSummary); + $macro!(crate::ExecCommandApprovalParams); + $macro!(crate::ExecCommandApprovalResponse); + $macro!(crate::ExecOneOffCommandParams); + $macro!(crate::ExecOneOffCommandResponse); + $macro!(crate::FuzzyFileSearchParams); + $macro!(crate::FuzzyFileSearchResponse); + $macro!(crate::FuzzyFileSearchResult); + $macro!(crate::GetAuthStatusParams); + $macro!(crate::GetAuthStatusResponse); + $macro!(crate::GetUserAgentResponse); + $macro!(crate::GetUserSavedConfigResponse); + $macro!(crate::GitDiffToRemoteParams); + $macro!(crate::GitDiffToRemoteResponse); + $macro!(crate::GitSha); + $macro!(crate::InitializeParams); + $macro!(crate::InitializeResponse); + $macro!(crate::InputItem); + $macro!(crate::InterruptConversationParams); + $macro!(crate::InterruptConversationResponse); + $macro!(crate::ListConversationsParams); + $macro!(crate::ListConversationsResponse); + $macro!(crate::LoginApiKeyParams); + $macro!(crate::LoginApiKeyResponse); + $macro!(crate::LoginChatGptCompleteNotification); + $macro!(crate::LoginChatGptResponse); + $macro!(crate::LogoutChatGptParams); + $macro!(crate::LogoutChatGptResponse); + $macro!(crate::NewConversationParams); + $macro!(crate::NewConversationResponse); + $macro!(crate::Profile); + $macro!(crate::RemoveConversationListenerParams); + $macro!(crate::RemoveConversationSubscriptionResponse); + $macro!(crate::ResumeConversationParams); + $macro!(crate::ResumeConversationResponse); + $macro!(crate::SandboxSettings); + $macro!(crate::SendUserMessageParams); + $macro!(crate::SendUserMessageResponse); + $macro!(crate::SendUserTurnParams); + $macro!(crate::SendUserTurnResponse); + $macro!(crate::ServerNotification); + $macro!(crate::ServerRequest); + $macro!(crate::SessionConfiguredNotification); + $macro!(crate::SetDefaultModelParams); + $macro!(crate::SetDefaultModelResponse); + $macro!(crate::Tools); + $macro!(crate::UserInfoResponse); + $macro!(crate::UserSavedConfig); + $macro!(codex_protocol::protocol::EventMsg); + $macro!(codex_protocol::protocol::FileChange); + $macro!(codex_protocol::parse_command::ParsedCommand); + $macro!(codex_protocol::protocol::SandboxPolicy); + }; +} + +pub fn generate_types(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { + generate_ts(out_dir, prettier)?; + generate_json(out_dir)?; + Ok(()) +} + +pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { + ensure_dir(out_dir)?; + + ClientRequest::export_all_to(out_dir)?; + export_client_responses(out_dir)?; + ClientNotification::export_all_to(out_dir)?; + + ServerRequest::export_all_to(out_dir)?; + export_server_responses(out_dir)?; + ServerNotification::export_all_to(out_dir)?; + + generate_index_ts(out_dir)?; + + let ts_files = ts_files_in(out_dir)?; + for file in &ts_files { + prepend_header_if_missing(file)?; + } + + if let Some(prettier_bin) = prettier + && !ts_files.is_empty() + { + let status = Command::new(prettier_bin) + .arg("--write") + .args(ts_files.iter().map(|p| p.as_os_str())) + .status() + .with_context(|| format!("Failed to invoke Prettier at {}", prettier_bin.display()))?; + if !status.success() { + return Err(anyhow!("Prettier failed with status {status}")); + } + } + + Ok(()) +} + +pub fn generate_json(out_dir: &Path) -> Result<()> { + ensure_dir(out_dir)?; + let mut bundle: BTreeMap = BTreeMap::new(); + + macro_rules! add_schema { + ($ty:path) => {{ + let name = type_basename(stringify!($ty)); + let schema = write_json_schema_with_return::<$ty>(out_dir, &name)?; + bundle.insert(name, schema); + }}; + } + + for_each_schema_type!(add_schema); + + export_client_response_schemas(out_dir)?; + export_server_response_schemas(out_dir)?; + + let mut definitions = Map::new(); + + const SPECIAL_DEFINITIONS: &[&str] = &[ + "ClientNotification", + "ClientRequest", + "EventMsg", + "FileChange", + "InputItem", + "ParsedCommand", + "SandboxPolicy", + "ServerNotification", + "ServerRequest", + ]; + + for (name, schema) in bundle { + let mut schema_value = serde_json::to_value(schema)?; + if let Value::Object(ref mut obj) = schema_value { + if let Some(defs) = obj.remove("definitions") + && let Value::Object(defs_obj) = defs + { + for (def_name, def_schema) in defs_obj { + if !SPECIAL_DEFINITIONS.contains(&def_name.as_str()) { + definitions.insert(def_name, def_schema); + } + } + } + + if let Some(Value::Array(one_of)) = obj.get_mut("oneOf") { + for variant in one_of.iter_mut() { + if let Some(variant_name) = variant_definition_name(&name, variant) + && let Value::Object(variant_obj) = variant + { + variant_obj.insert("title".into(), Value::String(variant_name)); + } + } + } + } + definitions.insert(name, schema_value); + } + + let mut root = Map::new(); + root.insert( + "$schema".to_string(), + Value::String("http://json-schema.org/draft-07/schema#".into()), + ); + root.insert( + "title".to_string(), + Value::String("CodexAppServerProtocol".into()), + ); + root.insert("type".to_string(), Value::String("object".into())); + root.insert("definitions".to_string(), Value::Object(definitions)); + + write_pretty_json( + out_dir.join("codex_app_server_protocol.schemas.json"), + &Value::Object(root), + )?; + + Ok(()) +} + +fn write_json_schema_with_return(out_dir: &Path, name: &str) -> Result +where + T: JsonSchema, +{ + let file_stem = name.trim(); + let schema = schema_for!(T); + write_pretty_json(out_dir.join(format!("{file_stem}.json")), &schema) + .with_context(|| format!("Failed to write JSON schema for {file_stem}"))?; + Ok(schema) +} + +pub(crate) fn write_json_schema(out_dir: &Path, name: &str) -> Result<()> +where + T: JsonSchema, +{ + write_json_schema_with_return::(out_dir, name).map(|_| ()) +} + +fn write_pretty_json(path: PathBuf, value: &impl Serialize) -> Result<()> { + let json = serde_json::to_vec_pretty(value) + .with_context(|| format!("Failed to serialize JSON schema to {}", path.display()))?; + fs::write(&path, json).with_context(|| format!("Failed to write {}", path.display()))?; + Ok(()) +} +fn type_basename(type_path: &str) -> String { + type_path + .rsplit_once("::") + .map(|(_, name)| name) + .unwrap_or(type_path) + .trim() + .to_string() +} + +fn variant_definition_name(base: &str, variant: &Value) -> Option { + if let Some(props) = variant.get("properties").and_then(Value::as_object) { + if let Some(method_literal) = literal_from_property(props, "method") { + let pascal = to_pascal_case(method_literal); + return Some(match base { + "ClientRequest" | "ServerRequest" => format!("{pascal}Request"), + "ClientNotification" | "ServerNotification" => format!("{pascal}Notification"), + _ => format!("{pascal}{base}"), + }); + } + + if let Some(type_literal) = literal_from_property(props, "type") { + let pascal = to_pascal_case(type_literal); + return Some(match base { + "EventMsg" => format!("{pascal}EventMsg"), + _ => format!("{pascal}{base}"), + }); + } + + if let Some(mode_literal) = literal_from_property(props, "mode") { + let pascal = to_pascal_case(mode_literal); + return Some(match base { + "SandboxPolicy" => format!("{pascal}SandboxPolicy"), + _ => format!("{pascal}{base}"), + }); + } + + if props.len() == 1 + && let Some(key) = props.keys().next() + { + let pascal = to_pascal_case(key); + return Some(format!("{pascal}{base}")); + } + } + + if let Some(required) = variant.get("required").and_then(Value::as_array) + && required.len() == 1 + && let Some(key) = required[0].as_str() + { + let pascal = to_pascal_case(key); + return Some(format!("{pascal}{base}")); + } + + None +} + +fn literal_from_property<'a>(props: &'a Map, key: &str) -> Option<&'a str> { + props + .get(key) + .and_then(|value| value.get("enum")) + .and_then(Value::as_array) + .and_then(|arr| arr.first()) + .and_then(Value::as_str) +} + +fn to_pascal_case(input: &str) -> String { + let mut result = String::new(); + let mut capitalize_next = true; + + for c in input.chars() { + if c == '_' || c == '-' { + capitalize_next = true; + continue; + } + + if capitalize_next { + result.extend(c.to_uppercase()); + capitalize_next = false; + } else { + result.push(c); + } + } + + result +} + +fn ensure_dir(dir: &Path) -> Result<()> { + fs::create_dir_all(dir) + .with_context(|| format!("Failed to create output directory {}", dir.display())) +} + +fn prepend_header_if_missing(path: &Path) -> Result<()> { + let mut content = String::new(); + { + let mut f = fs::File::open(path) + .with_context(|| format!("Failed to open {} for reading", path.display()))?; + f.read_to_string(&mut content) + .with_context(|| format!("Failed to read {}", path.display()))?; + } + + if content.starts_with(HEADER) { + return Ok(()); + } + + let mut f = fs::File::create(path) + .with_context(|| format!("Failed to open {} for writing", path.display()))?; + f.write_all(HEADER.as_bytes()) + .with_context(|| format!("Failed to write header to {}", path.display()))?; + f.write_all(content.as_bytes()) + .with_context(|| format!("Failed to write content to {}", path.display()))?; + Ok(()) +} + +fn ts_files_in(dir: &Path) -> Result> { + let mut files = Vec::new(); + for entry in + fs::read_dir(dir).with_context(|| format!("Failed to read dir {}", dir.display()))? + { + let entry = entry?; + let path = entry.path(); + if path.is_file() && path.extension() == Some(OsStr::new("ts")) { + files.push(path); + } + } + files.sort(); + Ok(files) +} + +fn generate_index_ts(out_dir: &Path) -> Result { + let mut entries: Vec = Vec::new(); + let mut stems: Vec = ts_files_in(out_dir)? + .into_iter() + .filter_map(|p| { + let stem = p.file_stem()?.to_string_lossy().into_owned(); + if stem == "index" { None } else { Some(stem) } + }) + .collect(); + stems.sort(); + stems.dedup(); + + for name in stems { + entries.push(format!("export type {{ {name} }} from \"./{name}\";\n")); + } + + let mut content = + String::with_capacity(HEADER.len() + entries.iter().map(String::len).sum::()); + content.push_str(HEADER); + for line in &entries { + content.push_str(line); + } + + let index_path = out_dir.join("index.ts"); + let mut f = fs::File::create(&index_path) + .with_context(|| format!("Failed to create {}", index_path.display()))?; + f.write_all(content.as_bytes()) + .with_context(|| format!("Failed to write {}", index_path.display()))?; + Ok(index_path) +} diff --git a/codex-rs/app-server-protocol/src/jsonrpc_lite.rs b/codex-rs/app-server-protocol/src/jsonrpc_lite.rs index 9d6d6da06f..c0b88b0215 100644 --- a/codex-rs/app-server-protocol/src/jsonrpc_lite.rs +++ b/codex-rs/app-server-protocol/src/jsonrpc_lite.rs @@ -1,13 +1,14 @@ //! We do not do true JSON-RPC 2.0, as we neither send nor expect the //! "jsonrpc": "2.0" field. +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use ts_rs::TS; pub const JSONRPC_VERSION: &str = "2.0"; -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Hash, Eq, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Hash, Eq, JsonSchema, TS)] #[serde(untagged)] pub enum RequestId { String(String), @@ -18,7 +19,7 @@ pub enum RequestId { pub type Result = serde_json::Value; /// Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum JSONRPCMessage { Request(JSONRPCRequest), @@ -28,7 +29,7 @@ pub enum JSONRPCMessage { } /// A request that expects a response. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct JSONRPCRequest { pub id: RequestId, pub method: String, @@ -37,7 +38,7 @@ pub struct JSONRPCRequest { } /// A notification which does not expect a response. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct JSONRPCNotification { pub method: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -45,20 +46,20 @@ pub struct JSONRPCNotification { } /// A successful (non-error) response to a request. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct JSONRPCResponse { pub id: RequestId, pub result: Result, } /// A response to a request that indicates an error occurred. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct JSONRPCError { pub error: JSONRPCErrorError, pub id: RequestId, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct JSONRPCErrorError { pub code: i64, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/codex-rs/app-server-protocol/src/lib.rs b/codex-rs/app-server-protocol/src/lib.rs index 517702bbd8..cc2310bc85 100644 --- a/codex-rs/app-server-protocol/src/lib.rs +++ b/codex-rs/app-server-protocol/src/lib.rs @@ -1,5 +1,9 @@ +mod export; mod jsonrpc_lite; mod protocol; +pub use export::generate_json; +pub use export::generate_ts; +pub use export::generate_types; pub use jsonrpc_lite::*; pub use protocol::*; diff --git a/codex-rs/app-server-protocol/src/protocol.rs b/codex-rs/app-server-protocol/src/protocol.rs index 63b11829cd..9c10d23360 100644 --- a/codex-rs/app-server-protocol/src/protocol.rs +++ b/codex-rs/app-server-protocol/src/protocol.rs @@ -18,13 +18,14 @@ use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::TurnAbortReason; use paste::paste; +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use strum_macros::Display; use ts_rs::TS; use uuid::Uuid; -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, TS)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, TS)] #[ts(type = "string")] pub struct GitSha(pub String); @@ -34,7 +35,7 @@ impl GitSha { } } -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Display, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)] #[serde(rename_all = "lowercase")] pub enum AuthMode { ApiKey, @@ -56,7 +57,7 @@ macro_rules! client_request_definitions { ),* $(,)? ) => { /// Request from the client to the server. - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(tag = "method", rename_all = "camelCase")] pub enum ClientRequest { $( @@ -78,6 +79,15 @@ macro_rules! client_request_definitions { )* Ok(()) } + + pub fn export_client_response_schemas( + out_dir: &::std::path::Path, + ) -> ::anyhow::Result<()> { + $( + crate::export::write_json_schema::<$response>(out_dir, stringify!($response))?; + )* + Ok(()) + } }; } @@ -175,13 +185,13 @@ client_request_definitions! { }, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct InitializeParams { pub client_info: ClientInfo, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ClientInfo { pub name: String, @@ -190,13 +200,13 @@ pub struct ClientInfo { pub version: String, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct InitializeResponse { pub user_agent: String, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct NewConversationParams { /// Optional override for the model name (e.g. "o3", "o4-mini"). @@ -239,7 +249,7 @@ pub struct NewConversationParams { pub include_apply_patch_tool: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct NewConversationResponse { pub conversation_id: ConversationId, @@ -250,7 +260,7 @@ pub struct NewConversationResponse { pub rollout_path: PathBuf, } -#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ResumeConversationResponse { pub conversation_id: ConversationId, @@ -259,7 +269,7 @@ pub struct ResumeConversationResponse { pub initial_messages: Option>, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ListConversationsParams { /// Optional page size; defaults to a reasonable server-side value. @@ -270,7 +280,7 @@ pub struct ListConversationsParams { pub cursor: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ConversationSummary { pub conversation_id: ConversationId, @@ -281,7 +291,7 @@ pub struct ConversationSummary { pub timestamp: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ListConversationsResponse { pub items: Vec, @@ -291,7 +301,7 @@ pub struct ListConversationsResponse { pub next_cursor: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ResumeConversationParams { /// Absolute path to the rollout JSONL file. @@ -301,78 +311,81 @@ pub struct ResumeConversationParams { pub overrides: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct AddConversationSubscriptionResponse { + #[schemars(with = "String")] pub subscription_id: Uuid, } /// The [`ConversationId`] must match the `rollout_path`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ArchiveConversationParams { pub conversation_id: ConversationId, pub rollout_path: PathBuf, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ArchiveConversationResponse {} -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct RemoveConversationSubscriptionResponse {} -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct LoginApiKeyParams { pub api_key: String, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct LoginApiKeyResponse {} -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct LoginChatGptResponse { + #[schemars(with = "String")] pub login_id: Uuid, /// URL the client should open in a browser to initiate the OAuth flow. pub auth_url: String, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct GitDiffToRemoteResponse { pub sha: GitSha, pub diff: String, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct CancelLoginChatGptParams { + #[schemars(with = "String")] pub login_id: Uuid, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct GitDiffToRemoteParams { pub cwd: PathBuf, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct CancelLoginChatGptResponse {} -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct LogoutChatGptParams {} -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct LogoutChatGptResponse {} -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct GetAuthStatusParams { /// If true, include the current auth token (if available) in the response. @@ -383,7 +396,7 @@ pub struct GetAuthStatusParams { pub refresh_token: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ExecOneOffCommandParams { /// Command argv to execute. @@ -399,7 +412,7 @@ pub struct ExecOneOffCommandParams { pub sandbox_policy: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ExecOneOffCommandResponse { pub exit_code: i32, @@ -407,7 +420,7 @@ pub struct ExecOneOffCommandResponse { pub stderr: String, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct GetAuthStatusResponse { #[serde(skip_serializing_if = "Option::is_none")] @@ -422,13 +435,13 @@ pub struct GetAuthStatusResponse { pub requires_openai_auth: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct GetUserAgentResponse { pub user_agent: String, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct UserInfoResponse { /// Note: `alleged_user_email` is not currently verified. We read it from @@ -438,13 +451,13 @@ pub struct UserInfoResponse { pub alleged_user_email: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct GetUserSavedConfigResponse { pub config: UserSavedConfig, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct SetDefaultModelParams { /// If set to None, this means `model` should be cleared in config.toml. @@ -456,14 +469,14 @@ pub struct SetDefaultModelParams { pub reasoning_effort: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct SetDefaultModelResponse {} /// UserSavedConfig contains a subset of the config. It is meant to expose mcp /// client-configurable settings that can be specified in the NewConversation /// and SendUserTurn requests. -#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, TS)] +#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct UserSavedConfig { /// Approvals @@ -501,7 +514,7 @@ pub struct UserSavedConfig { } /// MCP representation of a [`codex_core::config_profile::ConfigProfile`]. -#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, TS)] +#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct Profile { pub model: Option, @@ -515,7 +528,7 @@ pub struct Profile { pub chatgpt_base_url: Option, } /// MCP representation of a [`codex_core::config::ToolsToml`]. -#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, TS)] +#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct Tools { #[serde(skip_serializing_if = "Option::is_none")] @@ -525,7 +538,7 @@ pub struct Tools { } /// MCP representation of a [`codex_core::config_types::SandboxWorkspaceWrite`]. -#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, TS)] +#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct SandboxSettings { #[serde(default)] @@ -538,14 +551,14 @@ pub struct SandboxSettings { pub exclude_slash_tmp: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct SendUserMessageParams { pub conversation_id: ConversationId, pub items: Vec, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct SendUserTurnParams { pub conversation_id: ConversationId, @@ -559,39 +572,40 @@ pub struct SendUserTurnParams { pub summary: ReasoningSummary, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct SendUserTurnResponse {} -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct InterruptConversationParams { pub conversation_id: ConversationId, } -#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct InterruptConversationResponse { pub abort_reason: TurnAbortReason, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct SendUserMessageResponse {} -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct AddConversationListenerParams { pub conversation_id: ConversationId, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct RemoveConversationListenerParams { + #[schemars(with = "String")] pub subscription_id: Uuid, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[serde(tag = "type", content = "data")] pub enum InputItem { @@ -623,7 +637,7 @@ macro_rules! server_request_definitions { ) => { paste! { /// Request initiated from the server and sent to the client. - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(tag = "method", rename_all = "camelCase")] pub enum ServerRequest { $( @@ -636,7 +650,7 @@ macro_rules! server_request_definitions { )* } - #[derive(Debug, Clone, PartialEq)] + #[derive(Debug, Clone, PartialEq, JsonSchema)] pub enum ServerRequestPayload { $( $variant([<$variant Params>]), )* } @@ -658,6 +672,15 @@ macro_rules! server_request_definitions { } Ok(()) } + + pub fn export_server_response_schemas( + out_dir: &::std::path::Path, + ) -> ::anyhow::Result<()> { + paste! { + $(crate::export::write_json_schema::<[<$variant Response>]>(out_dir, stringify!([<$variant Response>]))?;)* + } + Ok(()) + } }; } @@ -676,7 +699,7 @@ server_request_definitions! { ExecCommandApproval, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ApplyPatchApprovalParams { pub conversation_id: ConversationId, @@ -693,7 +716,7 @@ pub struct ApplyPatchApprovalParams { pub grant_root: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ExecCommandApprovalParams { pub conversation_id: ConversationId, @@ -707,17 +730,17 @@ pub struct ExecCommandApprovalParams { pub parsed_cmd: Vec, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] pub struct ExecCommandApprovalResponse { pub decision: ReviewDecision, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] pub struct ApplyPatchApprovalResponse { pub decision: ReviewDecision, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] pub struct FuzzyFileSearchParams { @@ -729,7 +752,7 @@ pub struct FuzzyFileSearchParams { } /// Superset of [`codex_file_search::FileMatch`] -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] pub struct FuzzyFileSearchResult { pub root: String, pub path: String, @@ -739,21 +762,22 @@ pub struct FuzzyFileSearchResult { pub indices: Option>, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] pub struct FuzzyFileSearchResponse { pub files: Vec, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct LoginChatGptCompleteNotification { + #[schemars(with = "String")] pub login_id: Uuid, pub success: bool, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct SessionConfiguredNotification { /// Name left as session_id instead of conversation_id for backwards compatibility. @@ -781,7 +805,7 @@ pub struct SessionConfiguredNotification { pub rollout_path: PathBuf, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct AuthStatusChangeNotification { /// Current authentication method; omitted if signed out. @@ -790,7 +814,7 @@ pub struct AuthStatusChangeNotification { } /// Notification sent from the server to the client. -#[derive(Serialize, Deserialize, Debug, Clone, TS, Display)] +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS, Display)] #[serde(tag = "method", content = "params", rename_all = "camelCase")] #[strum(serialize_all = "camelCase")] pub enum ServerNotification { @@ -823,7 +847,7 @@ impl TryFrom for ServerNotification { } /// Notification sent from the client to the server. -#[derive(Serialize, Deserialize, Debug, Clone, TS, Display)] +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS, Display)] #[serde(tag = "method", content = "params", rename_all = "camelCase")] #[strum(serialize_all = "camelCase")] pub enum ClientNotification { diff --git a/codex-rs/mcp-types/Cargo.toml b/codex-rs/mcp-types/Cargo.toml index c8dc5819cd..8ea4fdfee5 100644 --- a/codex-rs/mcp-types/Cargo.toml +++ b/codex-rs/mcp-types/Cargo.toml @@ -10,3 +10,4 @@ workspace = true serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } ts-rs = { workspace = true, features = ["serde-json-impl", "no-serde-warnings"] } +schemars = { workspace = true } diff --git a/codex-rs/mcp-types/generate_mcp_types.py b/codex-rs/mcp-types/generate_mcp_types.py index 60c261e828..1906033537 100755 --- a/codex-rs/mcp-types/generate_mcp_types.py +++ b/codex-rs/mcp-types/generate_mcp_types.py @@ -21,9 +21,9 @@ from typing import Any, Literal SCHEMA_VERSION = "2025-06-18" JSONRPC_VERSION = "2.0" -STANDARD_DERIVE = "#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)]\n" +STANDARD_DERIVE = "#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)]\n" STANDARD_HASHABLE_DERIVE = ( - "#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Hash, Eq, TS)]\n" + "#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Hash, Eq, JsonSchema, TS)]\n" ) # Will be populated with the schema's `definitions` map in `main()` so that @@ -94,6 +94,7 @@ use serde::Serialize; use serde::de::DeserializeOwned; use std::convert::TryFrom; +use schemars::JsonSchema; use ts_rs::TS; pub const MCP_SCHEMA_VERSION: &str = "{SCHEMA_VERSION}"; diff --git a/codex-rs/mcp-types/src/lib.rs b/codex-rs/mcp-types/src/lib.rs index 37d790ab6a..98dab7252b 100644 --- a/codex-rs/mcp-types/src/lib.rs +++ b/codex-rs/mcp-types/src/lib.rs @@ -10,6 +10,7 @@ use serde::Serialize; use serde::de::DeserializeOwned; use std::convert::TryFrom; +use schemars::JsonSchema; use ts_rs::TS; pub const MCP_SCHEMA_VERSION: &str = "2025-06-18"; @@ -33,7 +34,7 @@ fn default_jsonrpc() -> String { } /// Optional annotations for the client. The client can use annotations to inform how objects are used or displayed -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct Annotations { #[serde(default, skip_serializing_if = "Option::is_none")] pub audience: Option>, @@ -48,7 +49,7 @@ pub struct Annotations { } /// Audio provided to or from an LLM. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct AudioContent { #[serde(default, skip_serializing_if = "Option::is_none")] pub annotations: Option, @@ -59,14 +60,14 @@ pub struct AudioContent { } /// Base interface for metadata with name (identifier) and title (display name) properties. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct BaseMetadata { pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub title: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct BlobResourceContents { pub blob: String, #[serde(rename = "mimeType", default, skip_serializing_if = "Option::is_none")] @@ -74,7 +75,7 @@ pub struct BlobResourceContents { pub uri: String, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct BooleanSchema { #[serde(default, skip_serializing_if = "Option::is_none")] pub default: Option, @@ -85,7 +86,7 @@ pub struct BooleanSchema { pub r#type: String, // &'static str = "boolean" } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum CallToolRequest {} impl ModelContextProtocolRequest for CallToolRequest { @@ -94,7 +95,7 @@ impl ModelContextProtocolRequest for CallToolRequest { type Result = CallToolResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct CallToolRequestParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub arguments: Option, @@ -102,7 +103,7 @@ pub struct CallToolRequestParams { } /// The server's response to a tool call. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct CallToolResult { pub content: Vec, #[serde(rename = "isError", default, skip_serializing_if = "Option::is_none")] @@ -123,7 +124,7 @@ impl From for serde_json::Value { } } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum CancelledNotification {} impl ModelContextProtocolNotification for CancelledNotification { @@ -131,7 +132,7 @@ impl ModelContextProtocolNotification for CancelledNotification { type Params = CancelledNotificationParams; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct CancelledNotificationParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub reason: Option, @@ -140,7 +141,7 @@ pub struct CancelledNotificationParams { } /// Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ClientCapabilities { #[serde(default, skip_serializing_if = "Option::is_none")] pub elicitation: Option, @@ -153,7 +154,7 @@ pub struct ClientCapabilities { } /// Present if the client supports listing roots. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ClientCapabilitiesRoots { #[serde( rename = "listChanged", @@ -163,7 +164,7 @@ pub struct ClientCapabilitiesRoots { pub list_changed: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum ClientNotification { CancelledNotification(CancelledNotification), @@ -172,7 +173,7 @@ pub enum ClientNotification { RootsListChangedNotification(RootsListChangedNotification), } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(tag = "method", content = "params")] pub enum ClientRequest { #[serde(rename = "initialize")] @@ -205,7 +206,7 @@ pub enum ClientRequest { CompleteRequest(::Params), } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum ClientResult { Result(Result), @@ -214,7 +215,7 @@ pub enum ClientResult { ElicitResult(ElicitResult), } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum CompleteRequest {} impl ModelContextProtocolRequest for CompleteRequest { @@ -223,7 +224,7 @@ impl ModelContextProtocolRequest for CompleteRequest { type Result = CompleteResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct CompleteRequestParams { pub argument: CompleteRequestParamsArgument, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -232,20 +233,20 @@ pub struct CompleteRequestParams { } /// Additional, optional context for completions -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct CompleteRequestParamsContext { #[serde(default, skip_serializing_if = "Option::is_none")] pub arguments: Option, } /// The argument's information -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct CompleteRequestParamsArgument { pub name: String, pub value: String, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum CompleteRequestParamsRef { PromptReference(PromptReference), @@ -253,12 +254,12 @@ pub enum CompleteRequestParamsRef { } /// The server's response to a completion/complete request -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct CompleteResult { pub completion: CompleteResultCompletion, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct CompleteResultCompletion { #[serde(rename = "hasMore", default, skip_serializing_if = "Option::is_none")] pub has_more: Option, @@ -275,7 +276,7 @@ impl From for serde_json::Value { } } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum ContentBlock { TextContent(TextContent), @@ -285,7 +286,7 @@ pub enum ContentBlock { EmbeddedResource(EmbeddedResource), } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum CreateMessageRequest {} impl ModelContextProtocolRequest for CreateMessageRequest { @@ -294,7 +295,7 @@ impl ModelContextProtocolRequest for CreateMessageRequest { type Result = CreateMessageResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct CreateMessageRequestParams { #[serde( rename = "includeContext", @@ -330,7 +331,7 @@ pub struct CreateMessageRequestParams { } /// The client's response to a sampling/create_message request from the server. The client should inform the user before returning the sampled message, to allow them to inspect the response (human in the loop) and decide whether to allow the server to see it. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct CreateMessageResult { pub content: CreateMessageResultContent, pub model: String, @@ -343,7 +344,7 @@ pub struct CreateMessageResult { pub stop_reason: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum CreateMessageResultContent { TextContent(TextContent), @@ -359,10 +360,10 @@ impl From for serde_json::Value { } } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct Cursor(String); -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ElicitRequest {} impl ModelContextProtocolRequest for ElicitRequest { @@ -371,7 +372,7 @@ impl ModelContextProtocolRequest for ElicitRequest { type Result = ElicitResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ElicitRequestParams { pub message: String, #[serde(rename = "requestedSchema")] @@ -380,7 +381,7 @@ pub struct ElicitRequestParams { /// A restricted subset of JSON Schema. /// Only top-level properties are allowed, without nesting. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ElicitRequestParamsRequestedSchema { pub properties: serde_json::Value, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -389,7 +390,7 @@ pub struct ElicitRequestParamsRequestedSchema { } /// The client's response to an elicitation request. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ElicitResult { pub action: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -408,7 +409,7 @@ impl From for serde_json::Value { /// /// It is up to the client how best to render embedded resources for the benefit /// of the LLM and/or the user. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct EmbeddedResource { #[serde(default, skip_serializing_if = "Option::is_none")] pub annotations: Option, @@ -416,7 +417,7 @@ pub struct EmbeddedResource { pub r#type: String, // &'static str = "resource" } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum EmbeddedResourceResource { TextResourceContents(TextResourceContents), @@ -425,7 +426,7 @@ pub enum EmbeddedResourceResource { pub type EmptyResult = Result; -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct EnumSchema { #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, @@ -437,7 +438,7 @@ pub struct EnumSchema { pub r#type: String, // &'static str = "string" } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum GetPromptRequest {} impl ModelContextProtocolRequest for GetPromptRequest { @@ -446,7 +447,7 @@ impl ModelContextProtocolRequest for GetPromptRequest { type Result = GetPromptResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct GetPromptRequestParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub arguments: Option, @@ -454,7 +455,7 @@ pub struct GetPromptRequestParams { } /// The server's response to a prompts/get request from the client. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct GetPromptResult { #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, @@ -470,7 +471,7 @@ impl From for serde_json::Value { } /// An image provided to or from an LLM. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ImageContent { #[serde(default, skip_serializing_if = "Option::is_none")] pub annotations: Option, @@ -481,7 +482,7 @@ pub struct ImageContent { } /// Describes the name and version of an MCP implementation, with an optional title for UI representation. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct Implementation { pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -492,7 +493,7 @@ pub struct Implementation { pub user_agent: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum InitializeRequest {} impl ModelContextProtocolRequest for InitializeRequest { @@ -501,7 +502,7 @@ impl ModelContextProtocolRequest for InitializeRequest { type Result = InitializeResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct InitializeRequestParams { pub capabilities: ClientCapabilities, #[serde(rename = "clientInfo")] @@ -511,7 +512,7 @@ pub struct InitializeRequestParams { } /// After receiving an initialize request from the client, the server sends this response. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct InitializeResult { pub capabilities: ServerCapabilities, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -530,7 +531,7 @@ impl From for serde_json::Value { } } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum InitializedNotification {} impl ModelContextProtocolNotification for InitializedNotification { @@ -539,7 +540,7 @@ impl ModelContextProtocolNotification for InitializedNotification { } /// A response to a request that indicates an error occurred. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct JSONRPCError { pub error: JSONRPCErrorError, pub id: RequestId, @@ -547,7 +548,7 @@ pub struct JSONRPCError { pub jsonrpc: String, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct JSONRPCErrorError { pub code: i64, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -556,7 +557,7 @@ pub struct JSONRPCErrorError { } /// Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum JSONRPCMessage { Request(JSONRPCRequest), @@ -566,7 +567,7 @@ pub enum JSONRPCMessage { } /// A notification which does not expect a response. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct JSONRPCNotification { #[serde(rename = "jsonrpc", default = "default_jsonrpc")] pub jsonrpc: String, @@ -576,7 +577,7 @@ pub struct JSONRPCNotification { } /// A request that expects a response. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct JSONRPCRequest { pub id: RequestId, #[serde(rename = "jsonrpc", default = "default_jsonrpc")] @@ -587,7 +588,7 @@ pub struct JSONRPCRequest { } /// A successful (non-error) response to a request. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct JSONRPCResponse { pub id: RequestId, #[serde(rename = "jsonrpc", default = "default_jsonrpc")] @@ -595,7 +596,7 @@ pub struct JSONRPCResponse { pub result: Result, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ListPromptsRequest {} impl ModelContextProtocolRequest for ListPromptsRequest { @@ -604,14 +605,14 @@ impl ModelContextProtocolRequest for ListPromptsRequest { type Result = ListPromptsResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ListPromptsRequestParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub cursor: Option, } /// The server's response to a prompts/list request from the client. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ListPromptsResult { #[serde( rename = "nextCursor", @@ -630,7 +631,7 @@ impl From for serde_json::Value { } } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ListResourceTemplatesRequest {} impl ModelContextProtocolRequest for ListResourceTemplatesRequest { @@ -639,14 +640,14 @@ impl ModelContextProtocolRequest for ListResourceTemplatesRequest { type Result = ListResourceTemplatesResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ListResourceTemplatesRequestParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub cursor: Option, } /// The server's response to a resources/templates/list request from the client. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ListResourceTemplatesResult { #[serde( rename = "nextCursor", @@ -666,7 +667,7 @@ impl From for serde_json::Value { } } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ListResourcesRequest {} impl ModelContextProtocolRequest for ListResourcesRequest { @@ -675,14 +676,14 @@ impl ModelContextProtocolRequest for ListResourcesRequest { type Result = ListResourcesResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ListResourcesRequestParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub cursor: Option, } /// The server's response to a resources/list request from the client. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ListResourcesResult { #[serde( rename = "nextCursor", @@ -701,7 +702,7 @@ impl From for serde_json::Value { } } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ListRootsRequest {} impl ModelContextProtocolRequest for ListRootsRequest { @@ -713,7 +714,7 @@ impl ModelContextProtocolRequest for ListRootsRequest { /// The client's response to a roots/list request from the server. /// This result contains an array of Root objects, each representing a root directory /// or file that the server can operate on. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ListRootsResult { pub roots: Vec, } @@ -726,7 +727,7 @@ impl From for serde_json::Value { } } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ListToolsRequest {} impl ModelContextProtocolRequest for ListToolsRequest { @@ -735,14 +736,14 @@ impl ModelContextProtocolRequest for ListToolsRequest { type Result = ListToolsResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ListToolsRequestParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub cursor: Option, } /// The server's response to a tools/list request from the client. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ListToolsResult { #[serde( rename = "nextCursor", @@ -765,7 +766,7 @@ impl From for serde_json::Value { /// /// These map to syslog message severities, as specified in RFC-5424: /// https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1 -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum LoggingLevel { #[serde(rename = "alert")] Alert, @@ -785,7 +786,7 @@ pub enum LoggingLevel { Warning, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum LoggingMessageNotification {} impl ModelContextProtocolNotification for LoggingMessageNotification { @@ -793,7 +794,7 @@ impl ModelContextProtocolNotification for LoggingMessageNotification { type Params = LoggingMessageNotificationParams; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct LoggingMessageNotificationParams { pub data: serde_json::Value, pub level: LoggingLevel, @@ -805,7 +806,7 @@ pub struct LoggingMessageNotificationParams { /// /// Keys not declared here are currently left unspecified by the spec and are up /// to the client to interpret. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ModelHint { #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, @@ -822,7 +823,7 @@ pub struct ModelHint { /// These preferences are always advisory. The client MAY ignore them. It is also /// up to the client to decide how to interpret these preferences and how to /// balance them against other considerations. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ModelPreferences { #[serde( rename = "costPriority", @@ -846,14 +847,14 @@ pub struct ModelPreferences { pub speed_priority: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct Notification { pub method: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub params: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct NumberSchema { #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, @@ -866,20 +867,20 @@ pub struct NumberSchema { pub r#type: String, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct PaginatedRequest { pub method: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub params: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct PaginatedRequestParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub cursor: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct PaginatedResult { #[serde( rename = "nextCursor", @@ -897,7 +898,7 @@ impl From for serde_json::Value { } } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum PingRequest {} impl ModelContextProtocolRequest for PingRequest { @@ -908,7 +909,7 @@ impl ModelContextProtocolRequest for PingRequest { /// Restricted schema definitions that only allow primitive types /// without nested objects or arrays. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum PrimitiveSchemaDefinition { StringSchema(StringSchema), @@ -917,7 +918,7 @@ pub enum PrimitiveSchemaDefinition { EnumSchema(EnumSchema), } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ProgressNotification {} impl ModelContextProtocolNotification for ProgressNotification { @@ -925,7 +926,7 @@ impl ModelContextProtocolNotification for ProgressNotification { type Params = ProgressNotificationParams; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ProgressNotificationParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub message: Option, @@ -936,7 +937,7 @@ pub struct ProgressNotificationParams { pub total: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Hash, Eq, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Hash, Eq, JsonSchema, TS)] #[serde(untagged)] pub enum ProgressToken { String(String), @@ -944,7 +945,7 @@ pub enum ProgressToken { } /// A prompt or prompt template that the server offers. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct Prompt { #[serde(default, skip_serializing_if = "Option::is_none")] pub arguments: Option>, @@ -956,7 +957,7 @@ pub struct Prompt { } /// Describes an argument that a prompt can accept. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct PromptArgument { #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, @@ -967,7 +968,7 @@ pub struct PromptArgument { pub title: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum PromptListChangedNotification {} impl ModelContextProtocolNotification for PromptListChangedNotification { @@ -979,14 +980,14 @@ impl ModelContextProtocolNotification for PromptListChangedNotification { /// /// This is similar to `SamplingMessage`, but also supports the embedding of /// resources from the MCP server. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct PromptMessage { pub content: ContentBlock, pub role: Role, } /// Identifies a prompt. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct PromptReference { pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -994,7 +995,7 @@ pub struct PromptReference { pub r#type: String, // &'static str = "ref/prompt" } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ReadResourceRequest {} impl ModelContextProtocolRequest for ReadResourceRequest { @@ -1003,18 +1004,18 @@ impl ModelContextProtocolRequest for ReadResourceRequest { type Result = ReadResourceResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ReadResourceRequestParams { pub uri: String, } /// The server's response to a resources/read request from the client. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ReadResourceResult { pub contents: Vec, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum ReadResourceResultContents { TextResourceContents(TextResourceContents), @@ -1029,14 +1030,14 @@ impl From for serde_json::Value { } } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct Request { pub method: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub params: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Hash, Eq, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Hash, Eq, JsonSchema, TS)] #[serde(untagged)] pub enum RequestId { String(String), @@ -1044,7 +1045,7 @@ pub enum RequestId { } /// A known resource that the server is capable of reading. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct Resource { #[serde(default, skip_serializing_if = "Option::is_none")] pub annotations: Option, @@ -1061,7 +1062,7 @@ pub struct Resource { } /// The contents of a specific resource or sub-resource. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ResourceContents { #[serde(rename = "mimeType", default, skip_serializing_if = "Option::is_none")] pub mime_type: Option, @@ -1071,7 +1072,7 @@ pub struct ResourceContents { /// A resource that the server is capable of reading, included in a prompt or tool call result. /// /// Note: resource links returned by tools are not guaranteed to appear in the results of `resources/list` requests. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ResourceLink { #[serde(default, skip_serializing_if = "Option::is_none")] pub annotations: Option, @@ -1088,7 +1089,7 @@ pub struct ResourceLink { pub uri: String, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ResourceListChangedNotification {} impl ModelContextProtocolNotification for ResourceListChangedNotification { @@ -1097,7 +1098,7 @@ impl ModelContextProtocolNotification for ResourceListChangedNotification { } /// A template description for resources available on the server. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ResourceTemplate { #[serde(default, skip_serializing_if = "Option::is_none")] pub annotations: Option, @@ -1113,13 +1114,13 @@ pub struct ResourceTemplate { } /// A reference to a resource or resource template definition. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ResourceTemplateReference { pub r#type: String, // &'static str = "ref/resource" pub uri: String, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ResourceUpdatedNotification {} impl ModelContextProtocolNotification for ResourceUpdatedNotification { @@ -1127,7 +1128,7 @@ impl ModelContextProtocolNotification for ResourceUpdatedNotification { type Params = ResourceUpdatedNotificationParams; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ResourceUpdatedNotificationParams { pub uri: String, } @@ -1135,7 +1136,7 @@ pub struct ResourceUpdatedNotificationParams { pub type Result = serde_json::Value; /// The sender or recipient of messages and data in a conversation. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum Role { #[serde(rename = "assistant")] Assistant, @@ -1144,14 +1145,14 @@ pub enum Role { } /// Represents a root directory or file that the server can operate on. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct Root { #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, pub uri: String, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum RootsListChangedNotification {} impl ModelContextProtocolNotification for RootsListChangedNotification { @@ -1160,13 +1161,13 @@ impl ModelContextProtocolNotification for RootsListChangedNotification { } /// Describes a message issued to or received from an LLM API. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct SamplingMessage { pub content: SamplingMessageContent, pub role: Role, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum SamplingMessageContent { TextContent(TextContent), @@ -1175,7 +1176,7 @@ pub enum SamplingMessageContent { } /// Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ServerCapabilities { #[serde(default, skip_serializing_if = "Option::is_none")] pub completions: Option, @@ -1192,7 +1193,7 @@ pub struct ServerCapabilities { } /// Present if the server offers any tools to call. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ServerCapabilitiesTools { #[serde( rename = "listChanged", @@ -1203,7 +1204,7 @@ pub struct ServerCapabilitiesTools { } /// Present if the server offers any resources to read. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ServerCapabilitiesResources { #[serde( rename = "listChanged", @@ -1216,7 +1217,7 @@ pub struct ServerCapabilitiesResources { } /// Present if the server offers any prompt templates. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ServerCapabilitiesPrompts { #[serde( rename = "listChanged", @@ -1226,7 +1227,7 @@ pub struct ServerCapabilitiesPrompts { pub list_changed: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(tag = "method", content = "params")] pub enum ServerNotification { #[serde(rename = "notifications/cancelled")] @@ -1255,7 +1256,7 @@ pub enum ServerNotification { ), } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum ServerRequest { PingRequest(PingRequest), @@ -1264,7 +1265,7 @@ pub enum ServerRequest { ElicitRequest(ElicitRequest), } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] #[allow(clippy::large_enum_variant)] pub enum ServerResult { @@ -1280,7 +1281,7 @@ pub enum ServerResult { CompleteResult(CompleteResult), } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum SetLevelRequest {} impl ModelContextProtocolRequest for SetLevelRequest { @@ -1289,12 +1290,12 @@ impl ModelContextProtocolRequest for SetLevelRequest { type Result = Result; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct SetLevelRequestParams { pub level: LoggingLevel, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct StringSchema { #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, @@ -1309,7 +1310,7 @@ pub struct StringSchema { pub r#type: String, // &'static str = "string" } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum SubscribeRequest {} impl ModelContextProtocolRequest for SubscribeRequest { @@ -1318,13 +1319,13 @@ impl ModelContextProtocolRequest for SubscribeRequest { type Result = Result; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct SubscribeRequestParams { pub uri: String, } /// Text provided to or from an LLM. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct TextContent { #[serde(default, skip_serializing_if = "Option::is_none")] pub annotations: Option, @@ -1332,7 +1333,7 @@ pub struct TextContent { pub r#type: String, // &'static str = "text" } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct TextResourceContents { #[serde(rename = "mimeType", default, skip_serializing_if = "Option::is_none")] pub mime_type: Option, @@ -1341,7 +1342,7 @@ pub struct TextResourceContents { } /// Definition for a tool the client can call. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct Tool { #[serde(default, skip_serializing_if = "Option::is_none")] pub annotations: Option, @@ -1362,7 +1363,7 @@ pub struct Tool { /// An optional JSON Schema object defining the structure of the tool's output returned in /// the structuredContent field of a CallToolResult. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ToolOutputSchema { #[serde(default, skip_serializing_if = "Option::is_none")] pub properties: Option, @@ -1372,7 +1373,7 @@ pub struct ToolOutputSchema { } /// A JSON Schema object defining the expected parameters for the tool. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ToolInputSchema { #[serde(default, skip_serializing_if = "Option::is_none")] pub properties: Option, @@ -1389,7 +1390,7 @@ pub struct ToolInputSchema { /// /// Clients should never make tool use decisions based on ToolAnnotations /// received from untrusted servers. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ToolAnnotations { #[serde( rename = "destructiveHint", @@ -1419,7 +1420,7 @@ pub struct ToolAnnotations { pub title: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ToolListChangedNotification {} impl ModelContextProtocolNotification for ToolListChangedNotification { @@ -1427,7 +1428,7 @@ impl ModelContextProtocolNotification for ToolListChangedNotification { type Params = Option; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum UnsubscribeRequest {} impl ModelContextProtocolRequest for UnsubscribeRequest { @@ -1436,7 +1437,7 @@ impl ModelContextProtocolRequest for UnsubscribeRequest { type Result = Result; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct UnsubscribeRequestParams { pub uri: String, } diff --git a/codex-rs/protocol/Cargo.toml b/codex-rs/protocol/Cargo.toml index 72cf984e96..f21d326610 100644 --- a/codex-rs/protocol/Cargo.toml +++ b/codex-rs/protocol/Cargo.toml @@ -19,6 +19,7 @@ mime_guess = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } serde_with = { workspace = true, features = ["macros", "base64"] } +schemars = { workspace = true } strum = { workspace = true } strum_macros = { workspace = true } sys-locale = { workspace = true } diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index d701ecf41e..32950527bc 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -1,3 +1,4 @@ +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use strum_macros::Display; @@ -6,7 +7,18 @@ use ts_rs::TS; /// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning #[derive( - Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, TS, EnumIter, + Debug, + Serialize, + Deserialize, + Default, + Clone, + Copy, + PartialEq, + Eq, + Display, + JsonSchema, + TS, + EnumIter, )] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] @@ -21,7 +33,9 @@ pub enum ReasoningEffort { /// A summary of the reasoning performed by the model. This can be useful for /// debugging and understanding the model's reasoning process. /// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries -#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, TS)] +#[derive( + Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS, +)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum ReasoningSummary { @@ -35,7 +49,9 @@ pub enum ReasoningSummary { /// Controls output length/detail on GPT-5 models via the Responses API. /// Serialized with lowercase values to match the OpenAI API. -#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, TS)] +#[derive( + Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS, +)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum Verbosity { @@ -45,7 +61,9 @@ pub enum Verbosity { High, } -#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Default, Serialize, Display, TS)] +#[derive( + Deserialize, Debug, Clone, Copy, PartialEq, Default, Serialize, Display, JsonSchema, TS, +)] #[serde(rename_all = "kebab-case")] #[strum(serialize_all = "kebab-case")] pub enum SandboxMode { @@ -60,7 +78,7 @@ pub enum SandboxMode { DangerFullAccess, } -#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, TS)] +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum ForcedLoginMethod { diff --git a/codex-rs/protocol/src/conversation_id.rs b/codex-rs/protocol/src/conversation_id.rs index 687237e0ab..db104d4531 100644 --- a/codex-rs/protocol/src/conversation_id.rs +++ b/codex-rs/protocol/src/conversation_id.rs @@ -1,5 +1,8 @@ use std::fmt::Display; +use schemars::JsonSchema; +use schemars::r#gen::SchemaGenerator; +use schemars::schema::Schema; use serde::Deserialize; use serde::Serialize; use ts_rs::TS; @@ -57,6 +60,16 @@ impl<'de> Deserialize<'de> for ConversationId { } } +impl JsonSchema for ConversationId { + fn schema_name() -> String { + "ConversationId".to_string() + } + + fn json_schema(generator: &mut SchemaGenerator) -> Schema { + ::json_schema(generator) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/protocol/src/custom_prompts.rs b/codex-rs/protocol/src/custom_prompts.rs index 4839cba3c2..7472d1b4c5 100644 --- a/codex-rs/protocol/src/custom_prompts.rs +++ b/codex-rs/protocol/src/custom_prompts.rs @@ -1,3 +1,4 @@ +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use std::path::PathBuf; @@ -9,7 +10,7 @@ use ts_rs::TS; /// - Full slash prefix: `"/{PROMPTS_CMD_PREFIX}:"` pub const PROMPTS_CMD_PREFIX: &str = "prompts"; -#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] pub struct CustomPrompt { pub name: String, pub path: PathBuf, diff --git a/codex-rs/protocol/src/message_history.rs b/codex-rs/protocol/src/message_history.rs index ba79993131..0d8bd8df4e 100644 --- a/codex-rs/protocol/src/message_history.rs +++ b/codex-rs/protocol/src/message_history.rs @@ -1,8 +1,9 @@ +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use ts_rs::TS; -#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] pub struct HistoryEntry { pub conversation_id: String, pub ts: u64, diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 4952aa01d9..285ae666f9 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -9,8 +9,9 @@ use serde::ser::Serializer; use ts_rs::TS; use crate::protocol::InputItem; +use schemars::JsonSchema; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ResponseInputItem { Message { @@ -31,7 +32,7 @@ pub enum ResponseInputItem { }, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ContentItem { InputText { text: String }, @@ -39,7 +40,7 @@ pub enum ContentItem { OutputText { text: String }, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ResponseItem { Message { @@ -159,7 +160,7 @@ impl From for ResponseItem { } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum LocalShellStatus { Completed, @@ -167,13 +168,13 @@ pub enum LocalShellStatus { Incomplete, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum LocalShellAction { Exec(LocalShellExecAction), } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] pub struct LocalShellExecAction { pub command: Vec, pub timeout_ms: Option, @@ -182,7 +183,7 @@ pub struct LocalShellExecAction { pub user: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum WebSearchAction { Search { @@ -192,13 +193,13 @@ pub enum WebSearchAction { Other, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ReasoningItemReasoningSummary { SummaryText { text: String }, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ReasoningItemContent { ReasoningText { text: String }, @@ -242,7 +243,7 @@ impl From> for ResponseInputItem { /// If the `name` of a `ResponseItem::FunctionCall` is either `container.exec` /// or shell`, the `arguments` field should deserialize to this struct. -#[derive(Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] pub struct ShellToolCallParams { pub command: Vec, pub workdir: Option, @@ -256,7 +257,7 @@ pub struct ShellToolCallParams { pub justification: Option, } -#[derive(Debug, Clone, PartialEq, TS)] +#[derive(Debug, Clone, PartialEq, JsonSchema, TS)] pub struct FunctionCallOutputPayload { pub content: String, // TODO(jif) drop this. diff --git a/codex-rs/protocol/src/parse_command.rs b/codex-rs/protocol/src/parse_command.rs index b06e290436..77926f11b6 100644 --- a/codex-rs/protocol/src/parse_command.rs +++ b/codex-rs/protocol/src/parse_command.rs @@ -1,9 +1,10 @@ +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use std::path::PathBuf; use ts_rs::TS; -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ParsedCommand { Read { diff --git a/codex-rs/protocol/src/plan_tool.rs b/codex-rs/protocol/src/plan_tool.rs index 79e672ac73..a9038eb03b 100644 --- a/codex-rs/protocol/src/plan_tool.rs +++ b/codex-rs/protocol/src/plan_tool.rs @@ -1,9 +1,10 @@ +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use ts_rs::TS; // Types for the TODO tool arguments matching codex-vscode/todo-mcp/src/main.rs -#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum StepStatus { Pending, @@ -11,14 +12,14 @@ pub enum StepStatus { Completed, } -#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] #[serde(deny_unknown_fields)] pub struct PlanItemArg { pub step: String, pub status: StepStatus, } -#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] #[serde(deny_unknown_fields)] pub struct UpdatePlanArgs { #[serde(default)] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 495768d90d..ead086cc7b 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -24,6 +24,7 @@ use mcp_types::CallToolResult; use mcp_types::Resource as McpResource; use mcp_types::ResourceTemplate as McpResourceTemplate; use mcp_types::Tool as McpTool; +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use serde_json::Value; @@ -40,7 +41,7 @@ pub const ENVIRONMENT_CONTEXT_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; /// Submission Queue Entry - requests from user -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { /// Unique id for this Submission to correlate with Events pub id: String, @@ -49,7 +50,7 @@ pub struct Submission { } /// Submission operation -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] #[serde(tag = "type", rename_all = "snake_case")] #[allow(clippy::large_enum_variant)] #[non_exhaustive] @@ -182,7 +183,20 @@ pub enum Op { /// Determines the conditions under which the user is consulted to approve /// running the command proposed by Codex. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, Display, TS)] +#[derive( + Debug, + Clone, + Copy, + Default, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + Display, + JsonSchema, + TS, +)] #[serde(rename_all = "kebab-case")] #[strum(serialize_all = "kebab-case")] pub enum AskForApproval { @@ -209,7 +223,7 @@ pub enum AskForApproval { } /// Determines execution restrictions for model shell commands. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display, TS)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display, JsonSchema, TS)] #[strum(serialize_all = "kebab-case")] #[serde(tag = "mode", rename_all = "kebab-case")] pub enum SandboxPolicy { @@ -252,7 +266,7 @@ pub enum SandboxPolicy { /// read‑only even when the root is writable. This is primarily used to ensure /// top‑level VCS metadata directories (e.g. `.git`) under a writable root are /// not modified by the agent. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, JsonSchema)] pub struct WritableRoot { /// Absolute path, by construction. pub root: PathBuf, @@ -391,7 +405,7 @@ impl SandboxPolicy { /// User input #[non_exhaustive] -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum InputItem { Text { @@ -410,7 +424,7 @@ pub enum InputItem { } /// Event Queue Entry - events from agent -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Event { /// Submission `id` that this event is correlated with. pub id: String, @@ -420,7 +434,7 @@ pub struct Event { /// Response event from the agent /// NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen. -#[derive(Debug, Clone, Deserialize, Serialize, Display, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, Display, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] pub enum EventMsg { @@ -526,29 +540,29 @@ pub enum EventMsg { ExitedReviewMode(ExitedReviewModeEvent), } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ExitedReviewModeEvent { pub review_output: Option, } // Individual event payload types matching each `EventMsg` variant. -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ErrorEvent { pub message: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TaskCompleteEvent { pub last_agent_message: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TaskStartedEvent { pub model_context_window: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, Default, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, Default, JsonSchema, TS)] pub struct TokenUsage { #[ts(type = "number")] pub input_tokens: i64, @@ -562,7 +576,7 @@ pub struct TokenUsage { pub total_tokens: i64, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TokenUsageInfo { pub total_token_usage: TokenUsage, pub last_token_usage: TokenUsage, @@ -625,19 +639,19 @@ impl TokenUsageInfo { } } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TokenCountEvent { pub info: Option, pub rate_limits: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct RateLimitSnapshot { pub primary: Option, pub secondary: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct RateLimitWindow { /// Percentage (0-100) of the window that has been consumed. pub used_percent: f64, @@ -711,7 +725,7 @@ impl TokenUsage { } } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct FinalOutput { pub token_usage: TokenUsage, } @@ -752,12 +766,12 @@ impl fmt::Display for FinalOutput { } } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct AgentMessageEvent { pub message: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum InputMessageKind { /// Plain user text (default) @@ -768,7 +782,7 @@ pub enum InputMessageKind { EnvironmentContext, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct UserMessageEvent { pub message: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -820,35 +834,35 @@ fn ends_with_ignore_ascii_case(text: &str, suffix: &str) -> bool { .all(|(a, b)| a.eq_ignore_ascii_case(b)) } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct AgentMessageDeltaEvent { pub delta: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct AgentReasoningEvent { pub text: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct AgentReasoningRawContentEvent { pub text: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct AgentReasoningRawContentDeltaEvent { pub delta: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct AgentReasoningSectionBreakEvent {} -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct AgentReasoningDeltaEvent { pub delta: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct McpInvocation { /// Name of the MCP server as defined in the config. pub server: String, @@ -858,14 +872,14 @@ pub struct McpInvocation { pub arguments: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct McpToolCallBeginEvent { /// Identifier so this can be paired with the McpToolCallEnd event. pub call_id: String, pub invocation: McpInvocation, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct McpToolCallEndEvent { /// Identifier for the corresponding McpToolCallBegin that finished. pub call_id: String, @@ -885,12 +899,12 @@ impl McpToolCallEndEvent { } } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct WebSearchBeginEvent { pub call_id: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct WebSearchEndEvent { pub call_id: String, pub query: String, @@ -898,20 +912,20 @@ pub struct WebSearchEndEvent { /// Response payload for `Op::GetHistory` containing the current session's /// in-memory transcript. -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ConversationPathResponseEvent { pub conversation_id: ConversationId, pub path: PathBuf, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ResumedHistory { pub conversation_id: ConversationId, pub history: Vec, pub rollout_path: PathBuf, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub enum InitialHistory { New, Resumed(ResumedHistory), @@ -953,7 +967,7 @@ impl InitialHistory { } } -#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, TS, Default)] +#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, JsonSchema, TS, Default)] #[serde(rename_all = "lowercase")] #[ts(rename_all = "lowercase")] pub enum SessionSource { @@ -966,7 +980,7 @@ pub enum SessionSource { Unknown, } -#[derive(Serialize, Deserialize, Clone, Debug, TS)] +#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] pub struct SessionMeta { pub id: ConversationId, pub timestamp: String, @@ -992,7 +1006,7 @@ impl Default for SessionMeta { } } -#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] pub struct SessionMetaLine { #[serde(flatten)] pub meta: SessionMeta, @@ -1000,7 +1014,7 @@ pub struct SessionMetaLine { pub git: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] #[serde(tag = "type", content = "payload", rename_all = "snake_case")] pub enum RolloutItem { SessionMeta(SessionMetaLine), @@ -1010,7 +1024,7 @@ pub enum RolloutItem { EventMsg(EventMsg), } -#[derive(Serialize, Deserialize, Clone, Debug, TS)] +#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] pub struct CompactedItem { pub message: String, } @@ -1027,7 +1041,7 @@ impl From for ResponseItem { } } -#[derive(Serialize, Deserialize, Clone, Debug, TS)] +#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] pub struct TurnContextItem { pub cwd: PathBuf, pub approval_policy: AskForApproval, @@ -1038,14 +1052,14 @@ pub struct TurnContextItem { pub summary: ReasoningSummaryConfig, } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, JsonSchema)] pub struct RolloutLine { pub timestamp: String, #[serde(flatten)] pub item: RolloutItem, } -#[derive(Serialize, Deserialize, Clone, Debug, TS)] +#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] pub struct GitInfo { /// Current commit hash (SHA) #[serde(skip_serializing_if = "Option::is_none")] @@ -1059,14 +1073,14 @@ pub struct GitInfo { } /// Review request sent to the review session. -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct ReviewRequest { pub prompt: String, pub user_facing_hint: String, } /// Structured review result produced by a child review session. -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct ReviewOutputEvent { pub findings: Vec, pub overall_correctness: String, @@ -1086,7 +1100,7 @@ impl Default for ReviewOutputEvent { } /// A single review finding describing an observed issue or recommendation. -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct ReviewFinding { pub title: String, pub body: String, @@ -1096,20 +1110,20 @@ pub struct ReviewFinding { } /// Location of the code related to a review finding. -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct ReviewCodeLocation { pub absolute_file_path: PathBuf, pub line_range: ReviewLineRange, } /// Inclusive line range in a file associated with the finding. -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct ReviewLineRange { pub start: u32, pub end: u32, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ExecCommandBeginEvent { /// Identifier so this can be paired with the ExecCommandEnd event. pub call_id: String, @@ -1120,7 +1134,7 @@ pub struct ExecCommandBeginEvent { pub parsed_cmd: Vec, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ExecCommandEndEvent { /// Identifier for the ExecCommandBegin that finished. pub call_id: String, @@ -1140,7 +1154,7 @@ pub struct ExecCommandEndEvent { pub formatted_output: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ViewImageToolCallEvent { /// Identifier for the originating tool call. pub call_id: String, @@ -1148,7 +1162,7 @@ pub struct ViewImageToolCallEvent { pub path: PathBuf, } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum ExecOutputStream { Stdout, @@ -1156,7 +1170,7 @@ pub enum ExecOutputStream { } #[serde_as] -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct ExecCommandOutputDeltaEvent { /// Identifier for the ExecCommandBegin that produced this chunk. pub call_id: String, @@ -1164,11 +1178,12 @@ pub struct ExecCommandOutputDeltaEvent { pub stream: ExecOutputStream, /// Raw bytes from the stream (may not be valid UTF-8). #[serde_as(as = "serde_with::base64::Base64")] + #[schemars(with = "String")] #[ts(type = "string")] pub chunk: Vec, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ExecApprovalRequestEvent { /// Identifier for the associated exec call, if available. pub call_id: String, @@ -1182,7 +1197,7 @@ pub struct ExecApprovalRequestEvent { pub parsed_cmd: Vec, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ApplyPatchApprovalRequestEvent { /// Responses API call id for the associated patch apply call, if available. pub call_id: String, @@ -1195,22 +1210,22 @@ pub struct ApplyPatchApprovalRequestEvent { pub grant_root: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct BackgroundEventEvent { pub message: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct StreamErrorEvent { pub message: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct StreamInfoEvent { pub message: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct PatchApplyBeginEvent { /// Identifier so this can be paired with the PatchApplyEnd event. pub call_id: String, @@ -1220,7 +1235,7 @@ pub struct PatchApplyBeginEvent { pub changes: HashMap, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct PatchApplyEndEvent { /// Identifier for the PatchApplyBegin that finished. pub call_id: String, @@ -1232,12 +1247,12 @@ pub struct PatchApplyEndEvent { pub success: bool, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnDiffEvent { pub unified_diff: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct GetHistoryEntryResponseEvent { pub offset: usize, pub log_id: u64, @@ -1246,7 +1261,7 @@ pub struct GetHistoryEntryResponseEvent { pub entry: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct McpListToolsResponseEvent { /// Fully qualified tool name -> tool definition. pub tools: std::collections::HashMap, @@ -1258,7 +1273,7 @@ pub struct McpListToolsResponseEvent { pub auth_statuses: std::collections::HashMap, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(rename_all = "snake_case")] pub enum McpAuthStatus { @@ -1281,12 +1296,12 @@ impl fmt::Display for McpAuthStatus { } /// Response payload for `Op::ListCustomPrompts`. -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ListCustomPromptsResponseEvent { pub custom_prompts: Vec, } -#[derive(Debug, Default, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct SessionConfiguredEvent { /// Name left as session_id instead of conversation_id for backwards compatibility. pub session_id: ConversationId, @@ -1313,7 +1328,9 @@ pub struct SessionConfiguredEvent { } /// User's decision in response to an ExecApprovalRequest. -#[derive(Debug, Default, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Display, TS)] +#[derive( + Debug, Default, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Display, JsonSchema, TS, +)] #[serde(rename_all = "snake_case")] pub enum ReviewDecision { /// User has approved this command and the agent should execute it. @@ -1334,7 +1351,7 @@ pub enum ReviewDecision { Abort, } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum FileChange { Add { @@ -1349,7 +1366,7 @@ pub enum FileChange { }, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct Chunk { /// 1-based line index of the first line in the original file pub orig_index: u32, @@ -1357,12 +1374,12 @@ pub struct Chunk { pub inserted_lines: Vec, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnAbortedEvent { pub reason: TurnAbortReason, } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum TurnAbortReason { Interrupted, From 8044b55335ee903a49e72461d0e1ce573bca7041 Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Mon, 20 Oct 2025 12:08:06 -0700 Subject: [PATCH 039/287] fix: warn when --add-dir would be ignored (#5351) Add shared helper to format warnings when add-dir is incompatible with the sandbox. Surface the warning in the TUI entrypoint and document the limitation for add-dir. --- codex-rs/tui/src/additional_dirs.rs | 71 +++++++++++++++++++++++++++++ codex-rs/tui/src/lib.rs | 10 ++++ 2 files changed, 81 insertions(+) create mode 100644 codex-rs/tui/src/additional_dirs.rs diff --git a/codex-rs/tui/src/additional_dirs.rs b/codex-rs/tui/src/additional_dirs.rs new file mode 100644 index 0000000000..cc43f3294b --- /dev/null +++ b/codex-rs/tui/src/additional_dirs.rs @@ -0,0 +1,71 @@ +use codex_core::protocol::SandboxPolicy; +use std::path::PathBuf; + +/// Returns a warning describing why `--add-dir` entries will be ignored for the +/// resolved sandbox policy. The caller is responsible for presenting the +/// warning to the user (for example, printing to stderr). +pub fn add_dir_warning_message( + additional_dirs: &[PathBuf], + sandbox_policy: &SandboxPolicy, +) -> Option { + if additional_dirs.is_empty() { + return None; + } + + match sandbox_policy { + SandboxPolicy::WorkspaceWrite { .. } | SandboxPolicy::DangerFullAccess => None, + SandboxPolicy::ReadOnly => Some(format_warning(additional_dirs)), + } +} + +fn format_warning(additional_dirs: &[PathBuf]) -> String { + let joined_paths = additional_dirs + .iter() + .map(|path| path.to_string_lossy()) + .collect::>() + .join(", "); + format!( + "Ignoring --add-dir ({joined_paths}) because the effective sandbox mode is read-only. Switch to workspace-write or danger-full-access to allow additional writable roots." + ) +} + +#[cfg(test)] +mod tests { + use super::add_dir_warning_message; + use codex_core::protocol::SandboxPolicy; + use pretty_assertions::assert_eq; + use std::path::PathBuf; + + #[test] + fn returns_none_for_workspace_write() { + let sandbox = SandboxPolicy::new_workspace_write_policy(); + let dirs = vec![PathBuf::from("/tmp/example")]; + assert_eq!(add_dir_warning_message(&dirs, &sandbox), None); + } + + #[test] + fn returns_none_for_danger_full_access() { + let sandbox = SandboxPolicy::DangerFullAccess; + let dirs = vec![PathBuf::from("/tmp/example")]; + assert_eq!(add_dir_warning_message(&dirs, &sandbox), None); + } + + #[test] + fn warns_for_read_only() { + let sandbox = SandboxPolicy::ReadOnly; + let dirs = vec![PathBuf::from("relative"), PathBuf::from("/abs")]; + let message = add_dir_warning_message(&dirs, &sandbox) + .expect("expected warning for read-only sandbox"); + assert_eq!( + message, + "Ignoring --add-dir (relative, /abs) because the effective sandbox mode is read-only. Switch to workspace-write or danger-full-access to allow additional writable roots." + ); + } + + #[test] + fn returns_none_when_no_additional_dirs() { + let sandbox = SandboxPolicy::ReadOnly; + let dirs: Vec = Vec::new(); + assert_eq!(add_dir_warning_message(&dirs, &sandbox), None); + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 12bcb2ea57..6b13d6f91f 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -3,6 +3,7 @@ // alternate‑screen mode starts; that file opts‑out locally via `allow`. #![deny(clippy::print_stdout, clippy::print_stderr)] #![deny(clippy::disallowed_methods)] +use additional_dirs::add_dir_warning_message; use app::App; pub use app::AppExitInfo; use codex_app_server_protocol::AuthMode; @@ -27,6 +28,7 @@ use tracing_subscriber::EnvFilter; use tracing_subscriber::filter::Targets; use tracing_subscriber::prelude::*; +mod additional_dirs; mod app; mod app_backtrack; mod app_event; @@ -194,6 +196,14 @@ pub async fn run_main( let config = load_config_or_exit(cli_kv_overrides.clone(), overrides.clone()).await; + if let Some(warning) = add_dir_warning_message(&cli.add_dir, &config.sandbox_policy) { + #[allow(clippy::print_stderr)] + { + eprintln!("Error adding directories: {warning}"); + std::process::exit(1); + } + } + #[allow(clippy::print_stderr)] if let Err(err) = enforce_login_restrictions(&config).await { eprintln!("{err}"); From c84fc832229965a27681acc36167e86b8e1dd7ee Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Mon, 20 Oct 2025 12:26:46 -0700 Subject: [PATCH 040/287] Use int timestamps for rate limit reset_at (#5383) The backend will be returning unix timestamps (seconds since epoch) instead of RFC 3339 strings. This will make it more ergonomic for developers to integrate against - no string parsing. --- codex-rs/core/src/client.rs | 17 +++++++---------- codex-rs/core/src/error.rs | 12 ++++++++++-- codex-rs/core/tests/suite/client.rs | 18 +++++++++--------- codex-rs/protocol/src/protocol.rs | 6 +++--- codex-rs/tui/src/status/rate_limits.rs | 4 ++-- codex-rs/tui/src/status/tests.rs | 4 ++-- 6 files changed, 33 insertions(+), 28 deletions(-) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 6865e67f93..0d48a87dcd 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -73,7 +73,7 @@ struct Error { // Optional fields available on "usage_limit_reached" and "usage_not_included" errors plan_type: Option, - resets_at: Option, + resets_at: Option, } #[derive(Debug, Clone)] @@ -425,9 +425,7 @@ impl ModelClient { .or_else(|| auth.as_ref().and_then(CodexAuth::get_plan_type)); let resets_at = error .resets_at - .as_deref() - .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) - .map(|dt| dt.with_timezone(&Utc)); + .and_then(|seconds| DateTime::::from_timestamp(seconds, 0)); let codex_err = CodexErr::UsageLimitReached(UsageLimitReachedError { plan_type, resets_at, @@ -636,10 +634,7 @@ fn parse_rate_limit_window( used_percent.and_then(|used_percent| { let window_minutes = parse_header_i64(headers, window_minutes_header); - let resets_at = parse_header_str(headers, resets_header) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(std::string::ToString::to_string); + let resets_at = parse_header_i64(headers, resets_header); let has_data = used_percent != 0.0 || window_minutes.is_some_and(|minutes| minutes != 0) @@ -1426,7 +1421,8 @@ mod tests { use crate::token_data::KnownPlan; use crate::token_data::PlanType; - let json = r#"{"error":{"type":"usage_limit_reached","plan_type":"pro","resets_at":"2024-01-01T00:00:00Z"}}"#; + let json = + r#"{"error":{"type":"usage_limit_reached","plan_type":"pro","resets_at":1704067200}}"#; let resp: ErrorResponse = serde_json::from_str(json).expect("should deserialize schema"); assert_matches!(resp.error.plan_type, Some(PlanType::Known(KnownPlan::Pro))); @@ -1439,7 +1435,8 @@ mod tests { fn error_response_deserializes_schema_unknown_plan_type_and_serializes_back() { use crate::token_data::PlanType; - let json = r#"{"error":{"type":"usage_limit_reached","plan_type":"vip","resets_at":"2024-01-01T00:01:00Z"}}"#; + let json = + r#"{"error":{"type":"usage_limit_reached","plan_type":"vip","resets_at":1704067260}}"#; let resp: ErrorResponse = serde_json::from_str(json).expect("should deserialize schema"); assert_matches!(resp.error.plan_type, Some(PlanType::Unknown(ref s)) if s == "vip"); diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 5597ffd431..459cc1752f 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -421,16 +421,24 @@ mod tests { use pretty_assertions::assert_eq; fn rate_limit_snapshot() -> RateLimitSnapshot { + let primary_reset_at = Utc + .with_ymd_and_hms(2024, 1, 1, 1, 0, 0) + .unwrap() + .timestamp(); + let secondary_reset_at = Utc + .with_ymd_and_hms(2024, 1, 1, 2, 0, 0) + .unwrap() + .timestamp(); RateLimitSnapshot { primary: Some(RateLimitWindow { used_percent: 50.0, window_minutes: Some(60), - resets_at: Some("2024-01-01T01:00:00Z".to_string()), + resets_at: Some(primary_reset_at), }), secondary: Some(RateLimitWindow { used_percent: 30.0, window_minutes: Some(120), - resets_at: Some("2024-01-01T02:00:00Z".to_string()), + resets_at: Some(secondary_reset_at), }), } } diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index d84d8c03da..7bc22449a8 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -768,8 +768,8 @@ async fn token_count_includes_rate_limits_snapshot() { .insert_header("x-codex-secondary-used-percent", "40.0") .insert_header("x-codex-primary-window-minutes", "10") .insert_header("x-codex-secondary-window-minutes", "60") - .insert_header("x-codex-primary-reset-at", "2024-01-01T00:30:00Z") - .insert_header("x-codex-secondary-reset-at", "2024-01-01T02:00:00Z") + .insert_header("x-codex-primary-reset-at", "1704069000") + .insert_header("x-codex-secondary-reset-at", "1704074400") .set_body_raw(sse_body, "text/event-stream"); Mock::given(method("POST")) @@ -818,12 +818,12 @@ async fn token_count_includes_rate_limits_snapshot() { "primary": { "used_percent": 12.5, "window_minutes": 10, - "resets_at": "2024-01-01T00:30:00Z" + "resets_at": 1704069000 }, "secondary": { "used_percent": 40.0, "window_minutes": 60, - "resets_at": "2024-01-01T02:00:00Z" + "resets_at": 1704074400 } } }) @@ -865,12 +865,12 @@ async fn token_count_includes_rate_limits_snapshot() { "primary": { "used_percent": 12.5, "window_minutes": 10, - "resets_at": "2024-01-01T00:30:00Z" + "resets_at": 1704069000 }, "secondary": { "used_percent": 40.0, "window_minutes": 60, - "resets_at": "2024-01-01T02:00:00Z" + "resets_at": 1704074400 } } }) @@ -893,8 +893,8 @@ async fn token_count_includes_rate_limits_snapshot() { final_snapshot .primary .as_ref() - .and_then(|window| window.resets_at.as_deref()), - Some("2024-01-01T00:30:00Z") + .and_then(|window| window.resets_at), + Some(1704069000) ); wait_for_event(&codex, |msg| matches!(msg, EventMsg::TaskComplete(_))).await; @@ -915,7 +915,7 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { "error": { "type": "usage_limit_reached", "message": "limit reached", - "resets_at": "2024-01-01T00:00:42Z", + "resets_at": 1704067242, "plan_type": "pro" } })); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index ead086cc7b..e22981745c 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -658,9 +658,9 @@ pub struct RateLimitWindow { /// Rolling window duration, in minutes. #[ts(type = "number | null")] pub window_minutes: Option, - /// Timestamp (RFC3339) when the window resets. - #[ts(type = "string | null")] - pub resets_at: Option, + /// Unix timestamp (seconds since epoch) when the window resets. + #[ts(type = "number | null")] + pub resets_at: Option, } // Includes prompts, tools and space to call compact. diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index f0c0c97310..ea63f189f3 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -3,6 +3,7 @@ use crate::chatwidget::get_limits_duration; use super::helpers::format_reset_timestamp; use chrono::DateTime; use chrono::Local; +use chrono::Utc; use codex_core::protocol::RateLimitSnapshot; use codex_core::protocol::RateLimitWindow; @@ -34,8 +35,7 @@ impl RateLimitWindowDisplay { fn from_window(window: &RateLimitWindow, captured_at: DateTime) -> Self { let resets_at = window .resets_at - .as_deref() - .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) + .and_then(|seconds| DateTime::::from_timestamp(seconds, 0)) .map(|dt| dt.with_timezone(&Local)) .map(|dt| format_reset_timestamp(dt, captured_at)); diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 5cb730c905..b8c945eeec 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -62,10 +62,10 @@ fn sanitize_directory(lines: Vec) -> Vec { .collect() } -fn reset_at_from(captured_at: &chrono::DateTime, seconds: i64) -> String { +fn reset_at_from(captured_at: &chrono::DateTime, seconds: i64) -> i64 { (*captured_at + ChronoDuration::seconds(seconds)) .with_timezone(&Utc) - .to_rfc3339() + .timestamp() } #[test] From 5e4f3bbb0b77a36613870f08df5ddba2f422e14a Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 20 Oct 2025 20:57:37 +0100 Subject: [PATCH 041/287] chore: rework tools execution workflow (#5278) Re-work the tool execution flow. Read `orchestrator.rs` to understand the structure --- codex-rs/Cargo.lock | 11 +- codex-rs/Cargo.toml | 2 + .../app-server/src/codex_message_processor.rs | 1 + codex-rs/core/Cargo.toml | 2 +- codex-rs/core/src/chat_completions.rs | 2 +- codex-rs/core/src/client.rs | 2 +- codex-rs/core/src/client_common.rs | 2 +- codex-rs/core/src/codex.rs | 264 +----- .../src/command_safety/is_safe_command.rs | 30 +- codex-rs/core/src/exec.rs | 184 ++-- .../src/exec_command/exec_command_params.rs | 57 -- .../src/exec_command/exec_command_session.rs | 96 --- codex-rs/core/src/exec_command/mod.rs | 14 - .../core/src/exec_command/responses_api.rs | 98 --- codex-rs/core/src/exec_command/session_id.rs | 5 - .../core/src/exec_command/session_manager.rs | 506 ----------- codex-rs/core/src/executor/backends.rs | 109 --- codex-rs/core/src/executor/cache.rs | 51 -- codex-rs/core/src/executor/mod.rs | 68 -- codex-rs/core/src/executor/runner.rs | 433 ---------- codex-rs/core/src/executor/sandbox.rs | 405 --------- codex-rs/core/src/function_tool.rs | 1 + codex-rs/core/src/landlock.rs | 4 +- codex-rs/core/src/lib.rs | 4 +- codex-rs/core/src/openai_tools.rs | 1 - codex-rs/core/src/safety.rs | 255 +----- codex-rs/core/src/sandboxing/mod.rs | 170 ++++ codex-rs/core/src/seatbelt.rs | 4 +- codex-rs/core/src/shell.rs | 2 + codex-rs/core/src/state/service.rs | 6 +- codex-rs/core/src/tools/context.rs | 2 + codex-rs/core/src/tools/events.rs | 235 ++++++ .../core/src/tools/handlers/apply_patch.rs | 3 +- .../core/src/tools/handlers/exec_stream.rs | 68 -- codex-rs/core/src/tools/handlers/mod.rs | 2 - codex-rs/core/src/tools/handlers/plan.rs | 2 +- codex-rs/core/src/tools/handlers/shell.rs | 1 + .../core/src/tools/handlers/unified_exec.rs | 22 +- codex-rs/core/src/tools/mod.rs | 222 +++-- codex-rs/core/src/tools/orchestrator.rs | 172 ++++ .../core/src/tools/runtimes/apply_patch.rs | 148 ++++ codex-rs/core/src/tools/runtimes/mod.rs | 38 + codex-rs/core/src/tools/runtimes/shell.rs | 164 ++++ .../core/src/tools/runtimes/unified_exec.rs | 123 +++ codex-rs/core/src/tools/sandboxing.rs | 190 +++++ codex-rs/core/src/tools/spec.rs | 20 +- codex-rs/core/src/unified_exec/errors.rs | 21 +- codex-rs/core/src/unified_exec/mod.rs | 786 ++++++------------ codex-rs/core/src/unified_exec/session.rs | 217 +++++ .../core/src/unified_exec/session_manager.rs | 288 +++++++ codex-rs/core/tests/suite/exec.rs | 1 + .../core/tests/suite/exec_stream_events.rs | 229 ----- codex-rs/core/tests/suite/mod.rs | 1 - codex-rs/core/tests/suite/otel.rs | 28 +- codex-rs/linux-sandbox/src/linux_run_main.rs | 2 + .../linux-sandbox/tests/suite/landlock.rs | 2 + codex-rs/mcp-server/tests/suite/codex_tool.rs | 2 +- codex-rs/utils/pty/Cargo.toml | 16 + codex-rs/utils/pty/src/lib.rs | 210 +++++ 59 files changed, 2630 insertions(+), 3374 deletions(-) delete mode 100644 codex-rs/core/src/exec_command/exec_command_params.rs delete mode 100644 codex-rs/core/src/exec_command/exec_command_session.rs delete mode 100644 codex-rs/core/src/exec_command/mod.rs delete mode 100644 codex-rs/core/src/exec_command/responses_api.rs delete mode 100644 codex-rs/core/src/exec_command/session_id.rs delete mode 100644 codex-rs/core/src/exec_command/session_manager.rs delete mode 100644 codex-rs/core/src/executor/backends.rs delete mode 100644 codex-rs/core/src/executor/cache.rs delete mode 100644 codex-rs/core/src/executor/mod.rs delete mode 100644 codex-rs/core/src/executor/runner.rs delete mode 100644 codex-rs/core/src/executor/sandbox.rs delete mode 100644 codex-rs/core/src/openai_tools.rs create mode 100644 codex-rs/core/src/sandboxing/mod.rs create mode 100644 codex-rs/core/src/tools/events.rs delete mode 100644 codex-rs/core/src/tools/handlers/exec_stream.rs create mode 100644 codex-rs/core/src/tools/orchestrator.rs create mode 100644 codex-rs/core/src/tools/runtimes/apply_patch.rs create mode 100644 codex-rs/core/src/tools/runtimes/mod.rs create mode 100644 codex-rs/core/src/tools/runtimes/shell.rs create mode 100644 codex-rs/core/src/tools/runtimes/unified_exec.rs create mode 100644 codex-rs/core/src/tools/sandboxing.rs create mode 100644 codex-rs/core/src/unified_exec/session.rs create mode 100644 codex-rs/core/src/unified_exec/session_manager.rs delete mode 100644 codex-rs/core/tests/suite/exec_stream_events.rs create mode 100644 codex-rs/utils/pty/Cargo.toml create mode 100644 codex-rs/utils/pty/src/lib.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c700a2dd1c..46955c4342 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1056,6 +1056,7 @@ dependencies = [ "codex-otel", "codex-protocol", "codex-rmcp-client", + "codex-utils-pty", "codex-utils-string", "core-foundation 0.9.4", "core_test_support", @@ -1072,7 +1073,6 @@ dependencies = [ "mcp-types", "openssl-sys", "os_info", - "portable-pty", "predicates", "pretty_assertions", "rand 0.9.2", @@ -1484,6 +1484,15 @@ dependencies = [ "toml", ] +[[package]] +name = "codex-utils-pty" +version = "0.0.0" +dependencies = [ + "anyhow", + "portable-pty", + "tokio", +] + [[package]] name = "codex-utils-readiness" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 5286ef8702..c0493c39d6 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -35,6 +35,7 @@ members = [ "git-apply", "utils/json-to-toml", "utils/readiness", + "utils/pty", "utils/string", ] resolver = "2" @@ -78,6 +79,7 @@ codex-stdio-to-uds = { path = "stdio-to-uds" } codex-tui = { path = "tui" } codex-utils-json-to-toml = { path = "utils/json-to-toml" } codex-utils-readiness = { path = "utils/readiness" } +codex-utils-pty = { path = "utils/pty" } codex-utils-string = { path = "utils/string" } core_test_support = { path = "core/tests/common" } mcp-types = { path = "mcp-types" } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 40e6a45fa3..0d9fe4b089 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -631,6 +631,7 @@ impl CodexMessageProcessor { env, with_escalated_permissions: None, justification: None, + arg0: None, }; let effective_policy = params diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index d13205699e..fdc1136f08 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -28,6 +28,7 @@ codex-protocol = { workspace = true } codex-rmcp-client = { workspace = true } codex-async-utils = { workspace = true } codex-utils-string = { workspace = true } +codex-utils-pty = { workspace = true } dirs = { workspace = true } dunce = { workspace = true } env-flags = { workspace = true } @@ -37,7 +38,6 @@ indexmap = { workspace = true } libc = { workspace = true } mcp-types = { workspace = true } os_info = { workspace = true } -portable-pty = { workspace = true } rand = { workspace = true } regex-lite = { workspace = true } reqwest = { workspace = true, features = ["json", "stream"] } diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs index de0b9d4151..aeea012948 100644 --- a/codex-rs/core/src/chat_completions.rs +++ b/codex-rs/core/src/chat_completions.rs @@ -11,7 +11,7 @@ use crate::error::Result; use crate::error::RetryLimitReachedError; use crate::error::UnexpectedResponseError; use crate::model_family::ModelFamily; -use crate::openai_tools::create_tools_json_for_chat_completions_api; +use crate::tools::spec::create_tools_json_for_chat_completions_api; use crate::util::backoff; use bytes::Bytes; use codex_otel::otel_event_manager::OtelEventManager; diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 0d48a87dcd..a88ad778ed 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -45,12 +45,12 @@ use crate::model_family::ModelFamily; use crate::model_provider_info::ModelProviderInfo; use crate::model_provider_info::WireApi; use crate::openai_model_info::get_model_info; -use crate::openai_tools::create_tools_json_for_responses_api; use crate::protocol::RateLimitSnapshot; use crate::protocol::RateLimitWindow; use crate::protocol::TokenUsage; use crate::state::TaskKind; use crate::token_data::PlanType; +use crate::tools::spec::create_tools_json_for_responses_api; use crate::util::backoff; use chrono::DateTime; use chrono::Utc; diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index d30da5192a..397b09dd7c 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -281,7 +281,7 @@ pub(crate) struct ResponsesApiRequest<'a> { } pub(crate) mod tools { - use crate::openai_tools::JsonSchema; + use crate::tools::spec::JsonSchema; use serde::Deserialize; use serde::Serialize; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b7554f0e14..170e00c71e 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -8,6 +8,7 @@ use crate::AuthManager; use crate::client_common::REVIEW_PROMPT; use crate::event_mapping::map_response_item_to_event_messages; use crate::function_tool::FunctionCallError; +use crate::parse_command::parse_command; use crate::review_format::format_review_findings_block; use crate::terminal; use crate::user_notification::UserNotifier; @@ -56,22 +57,14 @@ use crate::conversation_history::ConversationHistory; use crate::environment_context::EnvironmentContext; use crate::error::CodexErr; use crate::error::Result as CodexResult; -use crate::exec::ExecToolCallOutput; #[cfg(test)] use crate::exec::StreamOutput; -use crate::exec_command::ExecCommandParams; -use crate::exec_command::ExecSessionManager; -use crate::exec_command::WriteStdinParams; -use crate::executor::Executor; -use crate::executor::ExecutorConfig; -use crate::executor::normalize_exec_result; +// Removed: legacy executor wiring replaced by ToolOrchestrator flows. +// legacy normalize_exec_result no longer used after orchestrator migration use crate::mcp::auth::compute_auth_statuses; use crate::mcp_connection_manager::McpConnectionManager; use crate::model_family::find_family_for_model; use crate::openai_model_info::get_model_info; -use crate::openai_tools::ToolsConfig; -use crate::openai_tools::ToolsConfigParams; -use crate::parse_command::parse_command; use crate::project_doc::get_user_instructions; use crate::protocol::AgentMessageDeltaEvent; use crate::protocol::AgentReasoningDeltaEvent; @@ -84,13 +77,9 @@ use crate::protocol::ErrorEvent; use crate::protocol::Event; use crate::protocol::EventMsg; use crate::protocol::ExecApprovalRequestEvent; -use crate::protocol::ExecCommandBeginEvent; -use crate::protocol::ExecCommandEndEvent; use crate::protocol::InputItem; use crate::protocol::ListCustomPromptsResponseEvent; use crate::protocol::Op; -use crate::protocol::PatchApplyBeginEvent; -use crate::protocol::PatchApplyEndEvent; use crate::protocol::RateLimitSnapshot; use crate::protocol::ReviewDecision; use crate::protocol::ReviewOutputEvent; @@ -114,8 +103,10 @@ use crate::tasks::RegularTask; use crate::tasks::ReviewTask; use crate::tools::ToolRouter; use crate::tools::context::SharedTurnDiffTracker; -use crate::tools::format_exec_output_str; use crate::tools::parallel::ToolCallRuntime; +use crate::tools::sandboxing::ApprovalStore; +use crate::tools::spec::ToolsConfig; +use crate::tools::spec::ToolsConfigParams; use crate::turn_diff_tracker::TurnDiffTracker; use crate::unified_exec::UnifiedExecSessionManager; use crate::user_instructions::UserInstructions; @@ -272,6 +263,7 @@ pub(crate) struct TurnContext { pub(crate) tools_config: ToolsConfig, pub(crate) is_review_mode: bool, pub(crate) final_output_json_schema: Option, + pub(crate) codex_linux_sandbox_exe: Option, } impl TurnContext { @@ -404,6 +396,7 @@ impl Session { tools_config, is_review_mode: false, final_output_json_schema: None, + codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), } } @@ -561,19 +554,14 @@ impl Session { let services = SessionServices { mcp_connection_manager, - session_manager: ExecSessionManager::default(), unified_exec_manager: UnifiedExecSessionManager::default(), notifier: UserNotifier::new(config.notify.clone()), rollout: Mutex::new(Some(rollout_recorder)), user_shell: default_shell, show_raw_agent_reasoning: config.show_raw_agent_reasoning, - executor: Executor::new(ExecutorConfig::new( - session_configuration.sandbox_policy.clone(), - session_configuration.cwd.clone(), - config.codex_linux_sandbox_exe.clone(), - )), auth_manager: Arc::clone(&auth_manager), otel_event_manager, + tool_approvals: Mutex::new(ApprovalStore::default()), }; let sess = Arc::new(Session { @@ -655,13 +643,12 @@ impl Session { } pub(crate) async fn new_turn(&self, updates: SessionSettingsUpdate) -> Arc { - let current_configuration = self.state.lock().await.session_configuration.clone(); - let session_configuration = current_configuration.apply(&updates); - - self.services.executor.update_environment( - session_configuration.sandbox_policy.clone(), - session_configuration.cwd.clone(), - ); + let session_configuration = { + let mut state = self.state.lock().await; + let session_configuration = state.session_configuration.clone().apply(&updates); + state.session_configuration = session_configuration.clone(); + session_configuration + }; let mut turn_context: TurnContext = Self::make_turn_context( Some(Arc::clone(&self.services.auth_manager)), @@ -965,168 +952,6 @@ impl Session { } } - async fn on_exec_command_begin( - &self, - turn_diff_tracker: SharedTurnDiffTracker, - exec_command_context: ExecCommandContext, - ) { - let ExecCommandContext { - sub_id, - call_id, - command_for_display, - cwd, - apply_patch, - .. - } = exec_command_context; - let msg = match apply_patch { - Some(ApplyPatchCommandContext { - user_explicitly_approved_this_action, - changes, - }) => { - { - let mut tracker = turn_diff_tracker.lock().await; - tracker.on_patch_begin(&changes); - } - - EventMsg::PatchApplyBegin(PatchApplyBeginEvent { - call_id, - auto_approved: !user_explicitly_approved_this_action, - changes, - }) - } - None => EventMsg::ExecCommandBegin(ExecCommandBeginEvent { - call_id, - command: command_for_display.clone(), - cwd, - parsed_cmd: parse_command(&command_for_display), - }), - }; - let event = Event { - id: sub_id.to_string(), - msg, - }; - self.send_event(event).await; - } - - async fn on_exec_command_end( - &self, - turn_diff_tracker: SharedTurnDiffTracker, - sub_id: &str, - call_id: &str, - output: &ExecToolCallOutput, - is_apply_patch: bool, - ) { - let ExecToolCallOutput { - stdout, - stderr, - aggregated_output, - duration, - exit_code, - timed_out: _, - .. - } = output; - // Send full stdout/stderr to clients; do not truncate. - let stdout = stdout.text.clone(); - let stderr = stderr.text.clone(); - let formatted_output = format_exec_output_str(output); - let aggregated_output: String = aggregated_output.text.clone(); - - let msg = if is_apply_patch { - EventMsg::PatchApplyEnd(PatchApplyEndEvent { - call_id: call_id.to_string(), - stdout, - stderr, - success: *exit_code == 0, - }) - } else { - EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: call_id.to_string(), - stdout, - stderr, - aggregated_output, - exit_code: *exit_code, - duration: *duration, - formatted_output, - }) - }; - - let event = Event { - id: sub_id.to_string(), - msg, - }; - self.send_event(event).await; - - // If this is an apply_patch, after we emit the end patch, emit a second event - // with the full turn diff if there is one. - if is_apply_patch { - let unified_diff = { - let mut tracker = turn_diff_tracker.lock().await; - tracker.get_unified_diff() - }; - if let Ok(Some(unified_diff)) = unified_diff { - let msg = EventMsg::TurnDiff(TurnDiffEvent { unified_diff }); - let event = Event { - id: sub_id.into(), - msg, - }; - self.send_event(event).await; - } - } - } - /// Runs the exec tool call and emits events for the begin and end of the - /// command even on error. - /// - /// Returns the output of the exec tool call. - pub(crate) async fn run_exec_with_events( - &self, - turn_diff_tracker: SharedTurnDiffTracker, - prepared: PreparedExec, - approval_policy: AskForApproval, - ) -> Result { - let PreparedExec { context, request } = prepared; - let is_apply_patch = context.apply_patch.is_some(); - let sub_id = context.sub_id.clone(); - let call_id = context.call_id.clone(); - - let begin_turn_diff = turn_diff_tracker.clone(); - let begin_context = context.clone(); - let session = self; - let result = self - .services - .executor - .run(request, self, approval_policy, &context, move || { - let turn_diff = begin_turn_diff.clone(); - let ctx = begin_context.clone(); - async move { - session.on_exec_command_begin(turn_diff, ctx).await; - } - }) - .await; - - if matches!( - &result, - Err(ExecError::Function(FunctionCallError::Denied(_))) - ) { - return result; - } - - let normalized = normalize_exec_result(&result); - let borrowed = normalized.event_output(); - - self.on_exec_command_end( - turn_diff_tracker, - &sub_id, - &call_id, - borrowed, - is_apply_patch, - ) - .await; - - drop(normalized); - - result - } - /// Helper that emits a BackgroundEvent with the given message. This keeps /// the call‑sites terse so adding more diagnostics does not clutter the /// core agent logic. @@ -1235,43 +1060,6 @@ impl Session { .parse_tool_name(tool_name) } - pub(crate) async fn handle_exec_command_tool( - &self, - params: ExecCommandParams, - ) -> Result { - let result = self - .services - .session_manager - .handle_exec_command_request(params) - .await; - match result { - Ok(output) => Ok(output.to_text_output()), - Err(err) => Err(FunctionCallError::RespondToModel(err)), - } - } - - pub(crate) async fn handle_write_stdin_tool( - &self, - params: WriteStdinParams, - ) -> Result { - self.services - .session_manager - .handle_write_stdin_request(params) - .await - .map(|output| output.to_text_output()) - .map_err(FunctionCallError::RespondToModel) - } - - pub(crate) async fn run_unified_exec_request( - &self, - request: crate::unified_exec::UnifiedExecRequest<'_>, - ) -> Result { - self.services - .unified_exec_manager - .handle_request(request) - .await - } - pub async fn interrupt_task(self: &Arc) { info!("interrupt received: abort current task, if any"); self.abort_all_tasks(TurnAbortReason::Interrupted).await; @@ -1633,6 +1421,7 @@ async fn spawn_review_thread( cwd: parent_turn_context.cwd.clone(), is_review_mode: true, final_output_json_schema: None, + codex_linux_sandbox_exe: parent_turn_context.codex_linux_sandbox_exe.clone(), }; // Seed the child task with the review prompt as the initial user message. @@ -2511,10 +2300,6 @@ fn is_mcp_client_auth_required_error(error: &anyhow::Error) -> bool { error.to_string().contains("Auth required") } -use crate::executor::errors::ExecError; -use crate::executor::linkers::PreparedExec; -use crate::tools::context::ApplyPatchCommandContext; -use crate::tools::context::ExecCommandContext; #[cfg(test)] pub(crate) use tests::make_session_and_context; @@ -2523,6 +2308,8 @@ mod tests { use super::*; use crate::config::ConfigOverrides; use crate::config::ConfigToml; + use crate::exec::ExecToolCallOutput; + use crate::tools::format_exec_output_str; use crate::protocol::CompactedItem; use crate::protocol::InitialHistory; @@ -2827,19 +2614,14 @@ mod tests { let services = SessionServices { mcp_connection_manager: McpConnectionManager::default(), - session_manager: ExecSessionManager::default(), unified_exec_manager: UnifiedExecSessionManager::default(), notifier: UserNotifier::new(None), rollout: Mutex::new(None), user_shell: shell::Shell::Unknown, show_raw_agent_reasoning: config.show_raw_agent_reasoning, - executor: Executor::new(ExecutorConfig::new( - session_configuration.sandbox_policy.clone(), - session_configuration.cwd.clone(), - None, - )), auth_manager: Arc::clone(&auth_manager), otel_event_manager: otel_event_manager.clone(), + tool_approvals: Mutex::new(ApprovalStore::default()), }; let session = Session { @@ -2898,19 +2680,14 @@ mod tests { let services = SessionServices { mcp_connection_manager: McpConnectionManager::default(), - session_manager: ExecSessionManager::default(), unified_exec_manager: UnifiedExecSessionManager::default(), notifier: UserNotifier::new(None), rollout: Mutex::new(None), user_shell: shell::Shell::Unknown, show_raw_agent_reasoning: config.show_raw_agent_reasoning, - executor: Executor::new(ExecutorConfig::new( - session_configuration.sandbox_policy.clone(), - session_configuration.cwd.clone(), - None, - )), auth_manager: Arc::clone(&auth_manager), otel_event_manager: otel_event_manager.clone(), + tool_approvals: Mutex::new(ApprovalStore::default()), }; let session = Arc::new(Session { @@ -3258,6 +3035,7 @@ mod tests { env: HashMap::new(), with_escalated_permissions: Some(true), justification: Some("test".to_string()), + arg0: None, }; let params2 = ExecParams { diff --git a/codex-rs/core/src/command_safety/is_safe_command.rs b/codex-rs/core/src/command_safety/is_safe_command.rs index c1273caa94..f076744906 100644 --- a/codex-rs/core/src/command_safety/is_safe_command.rs +++ b/codex-rs/core/src/command_safety/is_safe_command.rs @@ -1,15 +1,25 @@ use crate::bash::parse_bash_lc_plain_commands; pub fn is_known_safe_command(command: &[String]) -> bool { + let command: Vec = command + .iter() + .map(|s| { + if s == "zsh" { + "bash".to_string() + } else { + s.clone() + } + }) + .collect(); #[cfg(target_os = "windows")] { use super::windows_safe_commands::is_safe_command_windows; - if is_safe_command_windows(command) { + if is_safe_command_windows(&command) { return true; } } - if is_safe_to_call_with_exec(command) { + if is_safe_to_call_with_exec(&command) { return true; } @@ -19,7 +29,7 @@ pub fn is_known_safe_command(command: &[String]) -> bool { // introduce side effects ( "&&", "||", ";", and "|" ). If every // individual command in the script is itself a known‑safe command, then // the composite expression is considered safe. - if let Some(all_commands) = parse_bash_lc_plain_commands(command) + if let Some(all_commands) = parse_bash_lc_plain_commands(&command) && !all_commands.is_empty() && all_commands .iter() @@ -31,9 +41,14 @@ pub fn is_known_safe_command(command: &[String]) -> bool { } fn is_safe_to_call_with_exec(command: &[String]) -> bool { - let cmd0 = command.first().map(String::as_str); + let Some(cmd0) = command.first().map(String::as_str) else { + return false; + }; - match cmd0 { + match std::path::Path::new(&cmd0) + .file_name() + .and_then(|osstr| osstr.to_str()) + { #[rustfmt::skip] Some( "cat" | @@ -103,13 +118,12 @@ fn is_safe_to_call_with_exec(command: &[String]) -> bool { // Rust Some("cargo") if command.get(1).map(String::as_str) == Some("check") => true, - // Special-case `sed -n {N|M,N}p FILE` + // Special-case `sed -n {N|M,N}p` Some("sed") if { - command.len() == 4 + command.len() <= 4 && command.get(1).map(String::as_str) == Some("-n") && is_valid_sed_n_arg(command.get(2).map(String::as_str)) - && command.get(3).map(String::is_empty) == Some(false) } => { true diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 22d1deeb1c..d2fa9735d0 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -18,13 +18,14 @@ use tokio::process::Child; use crate::error::CodexErr; use crate::error::Result; use crate::error::SandboxErr; -use crate::landlock::spawn_command_under_linux_sandbox; use crate::protocol::Event; use crate::protocol::EventMsg; use crate::protocol::ExecCommandOutputDeltaEvent; use crate::protocol::ExecOutputStream; use crate::protocol::SandboxPolicy; -use crate::seatbelt::spawn_command_under_seatbelt; +use crate::sandboxing::CommandSpec; +use crate::sandboxing::ExecEnv; +use crate::sandboxing::SandboxManager; use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; @@ -53,6 +54,7 @@ pub struct ExecParams { pub env: HashMap, pub with_escalated_permissions: Option, pub justification: Option, + pub arg0: Option, } impl ExecParams { @@ -87,57 +89,85 @@ pub async fn process_exec_tool_call( codex_linux_sandbox_exe: &Option, stdout_stream: Option, ) -> Result { - let start = Instant::now(); + let ExecParams { + command, + cwd, + timeout_ms, + env, + with_escalated_permissions, + justification, + arg0: _, + } = params; - let timeout_duration = params.timeout_duration(); + let (program, args) = command.split_first().ok_or_else(|| { + CodexErr::Io(io::Error::new( + io::ErrorKind::InvalidInput, + "command args are empty", + )) + })?; - let raw_output_result: std::result::Result = match sandbox_type - { - SandboxType::None => exec(params, sandbox_policy, stdout_stream.clone()).await, - SandboxType::MacosSeatbelt => { - let ExecParams { - command, - cwd: command_cwd, - env, - .. - } = params; - let child = spawn_command_under_seatbelt( - command, - command_cwd, - sandbox_policy, - sandbox_cwd, - StdioPolicy::RedirectForShellTool, - env, - ) - .await?; - consume_truncated_output(child, timeout_duration, stdout_stream.clone()).await - } - SandboxType::LinuxSeccomp => { - let ExecParams { - command, - cwd: command_cwd, - env, - .. - } = params; - - let codex_linux_sandbox_exe = codex_linux_sandbox_exe - .as_ref() - .ok_or(CodexErr::LandlockSandboxExecutableNotProvided)?; - let child = spawn_command_under_linux_sandbox( - codex_linux_sandbox_exe, - command, - command_cwd, - sandbox_policy, - sandbox_cwd, - StdioPolicy::RedirectForShellTool, - env, - ) - .await?; - - consume_truncated_output(child, timeout_duration, stdout_stream).await - } + let spec = CommandSpec { + program: program.clone(), + args: args.to_vec(), + cwd, + env, + timeout_ms, + with_escalated_permissions, + justification, }; + + let manager = SandboxManager::new(); + let exec_env = manager + .transform( + &spec, + sandbox_policy, + sandbox_type, + sandbox_cwd, + codex_linux_sandbox_exe.as_ref(), + ) + .map_err(CodexErr::from)?; + + // Route through the sandboxing module for a single, unified execution path. + crate::sandboxing::execute_env(&exec_env, sandbox_policy, stdout_stream).await +} + +pub(crate) async fn execute_exec_env( + env: ExecEnv, + sandbox_policy: &SandboxPolicy, + stdout_stream: Option, +) -> Result { + let ExecEnv { + command, + cwd, + env, + timeout_ms, + sandbox, + with_escalated_permissions, + justification, + arg0, + } = env; + + let params = ExecParams { + command, + cwd, + timeout_ms, + env, + with_escalated_permissions, + justification, + arg0, + }; + + let start = Instant::now(); + let raw_output_result = exec(params, sandbox_policy, stdout_stream).await; let duration = start.elapsed(); + finalize_exec_result(raw_output_result, sandbox, duration) +} + +fn finalize_exec_result( + raw_output_result: std::result::Result, + sandbox_type: SandboxType, + duration: Duration, +) -> Result { match raw_output_result { Ok(raw_output) => { #[allow(unused_mut)] @@ -192,12 +222,30 @@ pub async fn process_exec_tool_call( } } +pub(crate) mod errors { + use super::CodexErr; + use crate::sandboxing::SandboxTransformError; + + impl From for CodexErr { + fn from(err: SandboxTransformError) -> Self { + match err { + SandboxTransformError::MissingLinuxSandboxExecutable => { + CodexErr::LandlockSandboxExecutableNotProvided + } + } + } + } +} + /// 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. -fn is_likely_sandbox_denied(sandbox_type: SandboxType, exec_output: &ExecToolCallOutput) -> bool { +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; } @@ -206,21 +254,17 @@ fn is_likely_sandbox_denied(sandbox_type: SandboxType, exec_output: &ExecToolCal // 2: misuse of shell builtins // 126: permission denied // 127: command not found - const QUICK_REJECT_EXIT_CODES: [i32; 3] = [2, 126, 127]; - if QUICK_REJECT_EXIT_CODES.contains(&exec_output.exit_code) { - return false; - } - - const SANDBOX_DENIED_KEYWORDS: [&str; 6] = [ + const SANDBOX_DENIED_KEYWORDS: [&str; 7] = [ "operation not permitted", "permission denied", "read-only file system", "seccomp", "sandbox", "landlock", + "failed to write file", ]; - if [ + let has_sandbox_keyword = [ &exec_output.stderr.text, &exec_output.stdout.text, &exec_output.aggregated_output.text, @@ -231,10 +275,17 @@ fn is_likely_sandbox_denied(sandbox_type: SandboxType, exec_output: &ExecToolCal 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; @@ -248,11 +299,12 @@ fn is_likely_sandbox_denied(sandbox_type: SandboxType, exec_output: &ExecToolCal false } -#[derive(Debug)] -pub struct StreamOutput { +#[derive(Debug, Clone)] +pub struct StreamOutput { pub text: T, pub truncated_after_lines: Option, } + #[derive(Debug)] struct RawExecToolCallOutput { pub exit_status: ExitStatus, @@ -285,7 +337,7 @@ fn append_all(dst: &mut Vec, src: &[u8]) { dst.extend_from_slice(src); } -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct ExecToolCallOutput { pub exit_code: i32, pub stdout: StreamOutput, @@ -302,7 +354,11 @@ async fn exec( ) -> Result { let timeout = params.timeout_duration(); let ExecParams { - command, cwd, env, .. + command, + cwd, + env, + arg0, + .. } = params; let (program, args) = command.split_first().ok_or_else(|| { @@ -311,11 +367,11 @@ async fn exec( "command args are empty", )) })?; - let arg0 = None; + let arg0_ref = arg0.as_deref(); let child = spawn_child_async( PathBuf::from(program), args.into(), - arg0, + arg0_ref, cwd, sandbox_policy, StdioPolicy::RedirectForShellTool, diff --git a/codex-rs/core/src/exec_command/exec_command_params.rs b/codex-rs/core/src/exec_command/exec_command_params.rs deleted file mode 100644 index 11a3fd4596..0000000000 --- a/codex-rs/core/src/exec_command/exec_command_params.rs +++ /dev/null @@ -1,57 +0,0 @@ -use serde::Deserialize; -use serde::Serialize; - -use crate::exec_command::session_id::SessionId; - -#[derive(Debug, Clone, Deserialize)] -pub struct ExecCommandParams { - pub(crate) cmd: String, - - #[serde(default = "default_yield_time")] - pub(crate) yield_time_ms: u64, - - #[serde(default = "max_output_tokens")] - pub(crate) max_output_tokens: u64, - - #[serde(default = "default_shell")] - pub(crate) shell: String, - - #[serde(default = "default_login")] - pub(crate) login: bool, -} - -fn default_yield_time() -> u64 { - 10_000 -} - -fn max_output_tokens() -> u64 { - 10_000 -} - -fn default_login() -> bool { - true -} - -fn default_shell() -> String { - "/bin/bash".to_string() -} - -#[derive(Debug, Deserialize, Serialize)] -pub struct WriteStdinParams { - pub(crate) session_id: SessionId, - pub(crate) chars: String, - - #[serde(default = "write_stdin_default_yield_time_ms")] - pub(crate) yield_time_ms: u64, - - #[serde(default = "write_stdin_default_max_output_tokens")] - pub(crate) max_output_tokens: u64, -} - -fn write_stdin_default_yield_time_ms() -> u64 { - 250 -} - -fn write_stdin_default_max_output_tokens() -> u64 { - 10_000 -} diff --git a/codex-rs/core/src/exec_command/exec_command_session.rs b/codex-rs/core/src/exec_command/exec_command_session.rs deleted file mode 100644 index 31b3c929b2..0000000000 --- a/codex-rs/core/src/exec_command/exec_command_session.rs +++ /dev/null @@ -1,96 +0,0 @@ -use std::sync::Mutex as StdMutex; - -use tokio::sync::broadcast; -use tokio::sync::mpsc; -use tokio::task::JoinHandle; - -#[derive(Debug)] -pub(crate) struct ExecCommandSession { - /// Queue for writing bytes to the process stdin (PTY master write side). - writer_tx: mpsc::Sender>, - /// Broadcast stream of output chunks read from the PTY. New subscribers - /// receive only chunks emitted after they subscribe. - output_tx: broadcast::Sender>, - - /// Child killer handle for termination on drop (can signal independently - /// of a thread blocked in `.wait()`). - killer: StdMutex>>, - - /// JoinHandle for the blocking PTY reader task. - reader_handle: StdMutex>>, - - /// JoinHandle for the stdin writer task. - writer_handle: StdMutex>>, - - /// JoinHandle for the child wait task. - wait_handle: StdMutex>>, - - /// Tracks whether the underlying process has exited. - exit_status: std::sync::Arc, -} - -impl ExecCommandSession { - pub(crate) fn new( - writer_tx: mpsc::Sender>, - output_tx: broadcast::Sender>, - killer: Box, - reader_handle: JoinHandle<()>, - writer_handle: JoinHandle<()>, - wait_handle: JoinHandle<()>, - exit_status: std::sync::Arc, - ) -> (Self, broadcast::Receiver>) { - let initial_output_rx = output_tx.subscribe(); - ( - Self { - writer_tx, - output_tx, - killer: StdMutex::new(Some(killer)), - reader_handle: StdMutex::new(Some(reader_handle)), - writer_handle: StdMutex::new(Some(writer_handle)), - wait_handle: StdMutex::new(Some(wait_handle)), - exit_status, - }, - initial_output_rx, - ) - } - - pub(crate) fn writer_sender(&self) -> mpsc::Sender> { - self.writer_tx.clone() - } - - pub(crate) fn output_receiver(&self) -> broadcast::Receiver> { - self.output_tx.subscribe() - } - - pub(crate) fn has_exited(&self) -> bool { - self.exit_status.load(std::sync::atomic::Ordering::SeqCst) - } -} - -impl Drop for ExecCommandSession { - fn drop(&mut self) { - // Best-effort: terminate child first so blocking tasks can complete. - if let Ok(mut killer_opt) = self.killer.lock() - && let Some(mut killer) = killer_opt.take() - { - let _ = killer.kill(); - } - - // Abort background tasks; they may already have exited after kill. - if let Ok(mut h) = self.reader_handle.lock() - && let Some(handle) = h.take() - { - handle.abort(); - } - if let Ok(mut h) = self.writer_handle.lock() - && let Some(handle) = h.take() - { - handle.abort(); - } - if let Ok(mut h) = self.wait_handle.lock() - && let Some(handle) = h.take() - { - handle.abort(); - } - } -} diff --git a/codex-rs/core/src/exec_command/mod.rs b/codex-rs/core/src/exec_command/mod.rs deleted file mode 100644 index 2cfd022510..0000000000 --- a/codex-rs/core/src/exec_command/mod.rs +++ /dev/null @@ -1,14 +0,0 @@ -mod exec_command_params; -mod exec_command_session; -mod responses_api; -mod session_id; -mod session_manager; - -pub use exec_command_params::ExecCommandParams; -pub use exec_command_params::WriteStdinParams; -pub(crate) use exec_command_session::ExecCommandSession; -pub use responses_api::EXEC_COMMAND_TOOL_NAME; -pub use responses_api::WRITE_STDIN_TOOL_NAME; -pub use responses_api::create_exec_command_tool_for_responses_api; -pub use responses_api::create_write_stdin_tool_for_responses_api; -pub use session_manager::SessionManager as ExecSessionManager; diff --git a/codex-rs/core/src/exec_command/responses_api.rs b/codex-rs/core/src/exec_command/responses_api.rs deleted file mode 100644 index 24f6d35c57..0000000000 --- a/codex-rs/core/src/exec_command/responses_api.rs +++ /dev/null @@ -1,98 +0,0 @@ -use std::collections::BTreeMap; - -use crate::client_common::tools::ResponsesApiTool; -use crate::openai_tools::JsonSchema; - -pub const EXEC_COMMAND_TOOL_NAME: &str = "exec_command"; -pub const WRITE_STDIN_TOOL_NAME: &str = "write_stdin"; - -pub fn create_exec_command_tool_for_responses_api() -> ResponsesApiTool { - let mut properties = BTreeMap::::new(); - properties.insert( - "cmd".to_string(), - JsonSchema::String { - description: Some("The shell command to execute.".to_string()), - }, - ); - properties.insert( - "yield_time_ms".to_string(), - JsonSchema::Number { - description: Some("The maximum time in milliseconds to wait for output.".to_string()), - }, - ); - properties.insert( - "max_output_tokens".to_string(), - JsonSchema::Number { - description: Some("The maximum number of tokens to output.".to_string()), - }, - ); - properties.insert( - "shell".to_string(), - JsonSchema::String { - description: Some("The shell to use. Defaults to \"/bin/bash\".".to_string()), - }, - ); - properties.insert( - "login".to_string(), - JsonSchema::Boolean { - description: Some( - "Whether to run the command as a login shell. Defaults to true.".to_string(), - ), - }, - ); - - ResponsesApiTool { - name: EXEC_COMMAND_TOOL_NAME.to_owned(), - description: r#"Execute shell commands on the local machine with streaming output."# - .to_string(), - strict: false, - parameters: JsonSchema::Object { - properties, - required: Some(vec!["cmd".to_string()]), - additional_properties: Some(false.into()), - }, - } -} - -pub fn create_write_stdin_tool_for_responses_api() -> ResponsesApiTool { - let mut properties = BTreeMap::::new(); - properties.insert( - "session_id".to_string(), - JsonSchema::Number { - description: Some("The ID of the exec_command session.".to_string()), - }, - ); - properties.insert( - "chars".to_string(), - JsonSchema::String { - description: Some("The characters to write to stdin.".to_string()), - }, - ); - properties.insert( - "yield_time_ms".to_string(), - JsonSchema::Number { - description: Some( - "The maximum time in milliseconds to wait for output after writing.".to_string(), - ), - }, - ); - properties.insert( - "max_output_tokens".to_string(), - JsonSchema::Number { - description: Some("The maximum number of tokens to output.".to_string()), - }, - ); - - ResponsesApiTool { - name: WRITE_STDIN_TOOL_NAME.to_owned(), - description: r#"Write characters to an exec session's stdin. Returns all stdout+stderr received within yield_time_ms. -Can write control characters (\u0003 for Ctrl-C), or an empty string to just poll stdout+stderr."# - .to_string(), - strict: false, - parameters: JsonSchema::Object { - properties, - required: Some(vec!["session_id".to_string(), "chars".to_string()]), - additional_properties: Some(false.into()), - }, - } -} diff --git a/codex-rs/core/src/exec_command/session_id.rs b/codex-rs/core/src/exec_command/session_id.rs deleted file mode 100644 index c97c5d5440..0000000000 --- a/codex-rs/core/src/exec_command/session_id.rs +++ /dev/null @@ -1,5 +0,0 @@ -use serde::Deserialize; -use serde::Serialize; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub(crate) struct SessionId(pub u32); diff --git a/codex-rs/core/src/exec_command/session_manager.rs b/codex-rs/core/src/exec_command/session_manager.rs deleted file mode 100644 index cd1c5329a2..0000000000 --- a/codex-rs/core/src/exec_command/session_manager.rs +++ /dev/null @@ -1,506 +0,0 @@ -use std::collections::HashMap; -use std::io::ErrorKind; -use std::io::Read; -use std::sync::Arc; -use std::sync::Mutex as StdMutex; -use std::sync::atomic::AtomicBool; -use std::sync::atomic::AtomicU32; - -use portable_pty::CommandBuilder; -use portable_pty::PtySize; -use portable_pty::native_pty_system; -use tokio::sync::Mutex; -use tokio::sync::mpsc; -use tokio::sync::oneshot; -use tokio::time::Duration; -use tokio::time::Instant; -use tokio::time::timeout; - -use crate::exec_command::exec_command_params::ExecCommandParams; -use crate::exec_command::exec_command_params::WriteStdinParams; -use crate::exec_command::exec_command_session::ExecCommandSession; -use crate::exec_command::session_id::SessionId; -use crate::truncate::truncate_middle; - -#[derive(Debug, Default)] -pub struct SessionManager { - next_session_id: AtomicU32, - sessions: Mutex>, -} - -#[derive(Debug)] -pub struct ExecCommandOutput { - wall_time: Duration, - exit_status: ExitStatus, - original_token_count: Option, - output: String, -} - -impl ExecCommandOutput { - pub(crate) fn to_text_output(&self) -> String { - let wall_time_secs = self.wall_time.as_secs_f32(); - let termination_status = match self.exit_status { - ExitStatus::Exited(code) => format!("Process exited with code {code}"), - ExitStatus::Ongoing(session_id) => { - format!("Process running with session ID {}", session_id.0) - } - }; - let truncation_status = match self.original_token_count { - Some(tokens) => { - format!("\nWarning: truncated output (original token count: {tokens})") - } - None => "".to_string(), - }; - format!( - r#"Wall time: {wall_time_secs:.3} seconds -{termination_status}{truncation_status} -Output: -{output}"#, - output = self.output - ) - } -} - -#[derive(Debug)] -pub enum ExitStatus { - Exited(i32), - Ongoing(SessionId), -} - -impl SessionManager { - /// Processes the request and is required to send a response via `outgoing`. - pub async fn handle_exec_command_request( - &self, - params: ExecCommandParams, - ) -> Result { - // Allocate a session id. - let session_id = SessionId( - self.next_session_id - .fetch_add(1, std::sync::atomic::Ordering::SeqCst), - ); - - let (session, mut output_rx, mut exit_rx) = create_exec_command_session(params.clone()) - .await - .map_err(|err| { - format!( - "failed to create exec command session for session id {}: {err}", - session_id.0 - ) - })?; - - // Insert into session map. - self.sessions.lock().await.insert(session_id, session); - - // Collect output until either timeout expires or process exits. - // Do not cap during collection; truncate at the end if needed. - // Use a modest initial capacity to avoid large preallocation. - let cap_bytes_u64 = params.max_output_tokens.saturating_mul(4); - let cap_bytes: usize = cap_bytes_u64.min(usize::MAX as u64) as usize; - let mut collected: Vec = Vec::with_capacity(4096); - - let start_time = Instant::now(); - let deadline = start_time + Duration::from_millis(params.yield_time_ms); - let mut exit_code: Option = None; - - loop { - if Instant::now() >= deadline { - break; - } - let remaining = deadline.saturating_duration_since(Instant::now()); - tokio::select! { - biased; - exit = &mut exit_rx => { - exit_code = exit.ok(); - // Small grace period to pull remaining buffered output - let grace_deadline = Instant::now() + Duration::from_millis(25); - while Instant::now() < grace_deadline { - match timeout(Duration::from_millis(1), output_rx.recv()).await { - Ok(Ok(chunk)) => { - collected.extend_from_slice(&chunk); - } - Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => { - // Skip missed messages; keep trying within grace period. - continue; - } - Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => break, - Err(_) => break, - } - } - break; - } - chunk = timeout(remaining, output_rx.recv()) => { - match chunk { - Ok(Ok(chunk)) => { - collected.extend_from_slice(&chunk); - } - Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => { - // Skip missed messages; continue collecting fresh output. - } - Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => { break; } - Err(_) => { break; } - } - } - } - } - - let output = String::from_utf8_lossy(&collected).to_string(); - - let exit_status = if let Some(code) = exit_code { - ExitStatus::Exited(code) - } else { - ExitStatus::Ongoing(session_id) - }; - - // If output exceeds cap, truncate the middle and record original token estimate. - let (output, original_token_count) = truncate_middle(&output, cap_bytes); - Ok(ExecCommandOutput { - wall_time: Instant::now().duration_since(start_time), - exit_status, - original_token_count, - output, - }) - } - - /// Write characters to a session's stdin and collect combined output for up to `yield_time_ms`. - pub async fn handle_write_stdin_request( - &self, - params: WriteStdinParams, - ) -> Result { - let WriteStdinParams { - session_id, - chars, - yield_time_ms, - max_output_tokens, - } = params; - - // Grab handles without holding the sessions lock across await points. - let (writer_tx, mut output_rx) = { - let sessions = self.sessions.lock().await; - match sessions.get(&session_id) { - Some(session) => (session.writer_sender(), session.output_receiver()), - None => { - return Err(format!("unknown session id {}", session_id.0)); - } - } - }; - - // Write stdin if provided. - if !chars.is_empty() && writer_tx.send(chars.into_bytes()).await.is_err() { - return Err("failed to write to stdin".to_string()); - } - - // Collect output up to yield_time_ms, truncating to max_output_tokens bytes. - let mut collected: Vec = Vec::with_capacity(4096); - let start_time = Instant::now(); - let deadline = start_time + Duration::from_millis(yield_time_ms); - loop { - let now = Instant::now(); - if now >= deadline { - break; - } - let remaining = deadline - now; - match timeout(remaining, output_rx.recv()).await { - Ok(Ok(chunk)) => { - // Collect all output within the time budget; truncate at the end. - collected.extend_from_slice(&chunk); - } - Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => { - // Skip missed messages; continue collecting fresh output. - } - Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => break, - Err(_) => break, // timeout - } - } - - // Return structured output, truncating middle if over cap. - let output = String::from_utf8_lossy(&collected).to_string(); - let cap_bytes_u64 = max_output_tokens.saturating_mul(4); - let cap_bytes: usize = cap_bytes_u64.min(usize::MAX as u64) as usize; - let (output, original_token_count) = truncate_middle(&output, cap_bytes); - Ok(ExecCommandOutput { - wall_time: Instant::now().duration_since(start_time), - exit_status: ExitStatus::Ongoing(session_id), - original_token_count, - output, - }) - } -} - -/// Spawn PTY and child process per spawn_exec_command_session logic. -async fn create_exec_command_session( - params: ExecCommandParams, -) -> anyhow::Result<( - ExecCommandSession, - tokio::sync::broadcast::Receiver>, - oneshot::Receiver, -)> { - let ExecCommandParams { - cmd, - yield_time_ms: _, - max_output_tokens: _, - shell, - login, - } = params; - - // Use the native pty implementation for the system - let pty_system = native_pty_system(); - - // Create a new pty - let pair = pty_system.openpty(PtySize { - rows: 24, - cols: 80, - pixel_width: 0, - pixel_height: 0, - })?; - - // Spawn a shell into the pty - let mut command_builder = CommandBuilder::new(shell); - let shell_mode_opt = if login { "-lc" } else { "-c" }; - command_builder.arg(shell_mode_opt); - command_builder.arg(cmd); - - let mut child = pair.slave.spawn_command(command_builder)?; - // Obtain a killer that can signal the process independently of `.wait()`. - let killer = child.clone_killer(); - - // Channel to forward write requests to the PTY writer. - let (writer_tx, mut writer_rx) = mpsc::channel::>(128); - // Broadcast for streaming PTY output to readers: subscribers receive from subscription time. - let (output_tx, _) = tokio::sync::broadcast::channel::>(256); - // Reader task: drain PTY and forward chunks to output channel. - let mut reader = pair.master.try_clone_reader()?; - let output_tx_clone = output_tx.clone(); - let reader_handle = tokio::task::spawn_blocking(move || { - let mut buf = [0u8; 8192]; - loop { - match reader.read(&mut buf) { - Ok(0) => break, // EOF - Ok(n) => { - // Forward to broadcast; best-effort if there are subscribers. - let _ = output_tx_clone.send(buf[..n].to_vec()); - } - Err(ref e) if e.kind() == ErrorKind::Interrupted => { - // Retry on EINTR - continue; - } - Err(ref e) if e.kind() == ErrorKind::WouldBlock => { - // We're in a blocking thread; back off briefly and retry. - std::thread::sleep(Duration::from_millis(5)); - continue; - } - Err(_) => break, - } - } - }); - - // Writer task: apply stdin writes to the PTY writer. - let writer = pair.master.take_writer()?; - let writer = Arc::new(StdMutex::new(writer)); - let writer_handle = tokio::spawn({ - let writer = writer.clone(); - async move { - while let Some(bytes) = writer_rx.recv().await { - let writer = writer.clone(); - // Perform blocking write on a blocking thread. - let _ = tokio::task::spawn_blocking(move || { - if let Ok(mut guard) = writer.lock() { - use std::io::Write; - let _ = guard.write_all(&bytes); - let _ = guard.flush(); - } - }) - .await; - } - } - }); - - // Keep the child alive until it exits, then signal exit code. - let (exit_tx, exit_rx) = oneshot::channel::(); - let exit_status = Arc::new(AtomicBool::new(false)); - let wait_exit_status = exit_status.clone(); - let wait_handle = tokio::task::spawn_blocking(move || { - let code = match child.wait() { - Ok(status) => status.exit_code() as i32, - Err(_) => -1, - }; - wait_exit_status.store(true, std::sync::atomic::Ordering::SeqCst); - let _ = exit_tx.send(code); - }); - - // Create and store the session with channels. - let (session, initial_output_rx) = ExecCommandSession::new( - writer_tx, - output_tx, - killer, - reader_handle, - writer_handle, - wait_handle, - exit_status, - ); - Ok((session, initial_output_rx, exit_rx)) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::exec_command::session_id::SessionId; - - /// Test that verifies that [`SessionManager::handle_exec_command_request()`] - /// and [`SessionManager::handle_write_stdin_request()`] work as expected - /// in the presence of a process that never terminates (but produces - /// output continuously). - #[cfg(unix)] - #[allow(clippy::print_stderr)] - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn session_manager_streams_and_truncates_from_now() { - use crate::exec_command::exec_command_params::ExecCommandParams; - use crate::exec_command::exec_command_params::WriteStdinParams; - use tokio::time::sleep; - - let session_manager = SessionManager::default(); - // Long-running loop that prints an increasing counter every ~100ms. - // Use Python for a portable, reliable sleep across shells/PTYs. - let cmd = r#"python3 - <<'PY' -import sys, time -count = 0 -while True: - print(count) - sys.stdout.flush() - count += 100 - time.sleep(0.1) -PY"# - .to_string(); - - // Start the session and collect ~3s of output. - let params = ExecCommandParams { - cmd, - yield_time_ms: 3_000, - max_output_tokens: 1_000, // large enough to avoid truncation here - shell: "/bin/bash".to_string(), - login: false, - }; - let initial_output = match session_manager - .handle_exec_command_request(params.clone()) - .await - { - Ok(v) => v, - Err(e) => { - // PTY may be restricted in some sandboxes; skip in that case. - if e.contains("openpty") || e.contains("Operation not permitted") { - eprintln!("skipping test due to restricted PTY: {e}"); - return; - } - panic!("exec request failed unexpectedly: {e}"); - } - }; - eprintln!("initial output: {initial_output:?}"); - - // Should be ongoing (we launched a never-ending loop). - let session_id = match initial_output.exit_status { - ExitStatus::Ongoing(id) => id, - _ => panic!("expected ongoing session"), - }; - - // Parse the numeric lines and get the max observed value in the first window. - let first_nums = extract_monotonic_numbers(&initial_output.output); - assert!( - !first_nums.is_empty(), - "expected some output from first window" - ); - let first_max = *first_nums.iter().max().unwrap(); - - // Wait ~4s so counters progress while we're not reading. - sleep(Duration::from_millis(4_000)).await; - - // Now read ~3s of output "from now" only. - // Use a small token cap so truncation occurs and we test middle truncation. - let write_params = WriteStdinParams { - session_id, - chars: String::new(), - yield_time_ms: 3_000, - max_output_tokens: 16, // 16 tokens ~= 64 bytes -> likely truncation - }; - let second = session_manager - .handle_write_stdin_request(write_params) - .await - .expect("write stdin should succeed"); - - // Verify truncation metadata and size bound (cap is tokens*4 bytes). - assert!(second.original_token_count.is_some()); - let cap_bytes = (16u64 * 4) as usize; - assert!(second.output.len() <= cap_bytes); - // New middle marker should be present. - assert!( - second.output.contains("tokens truncated") && second.output.contains('…'), - "expected truncation marker in output, got: {}", - second.output - ); - - // Minimal freshness check: the earliest number we see in the second window - // should be significantly larger than the last from the first window. - let second_nums = extract_monotonic_numbers(&second.output); - assert!( - !second_nums.is_empty(), - "expected some numeric output from second window" - ); - let second_min = *second_nums.iter().min().unwrap(); - - // We slept 4 seconds (~40 ticks at 100ms/tick, each +100), so expect - // an increase of roughly 4000 or more. Allow a generous margin. - assert!( - second_min >= first_max + 2000, - "second_min={second_min} first_max={first_max}", - ); - } - - #[cfg(unix)] - fn extract_monotonic_numbers(s: &str) -> Vec { - s.lines() - .filter_map(|line| { - if !line.is_empty() - && line.chars().all(|c| c.is_ascii_digit()) - && let Ok(n) = line.parse::() - { - // Our generator increments by 100; ignore spurious fragments. - if n % 100 == 0 { - return Some(n); - } - } - None - }) - .collect() - } - - #[test] - fn to_text_output_exited_no_truncation() { - let out = ExecCommandOutput { - wall_time: Duration::from_millis(1234), - exit_status: ExitStatus::Exited(0), - original_token_count: None, - output: "hello".to_string(), - }; - let text = out.to_text_output(); - let expected = r#"Wall time: 1.234 seconds -Process exited with code 0 -Output: -hello"#; - assert_eq!(expected, text); - } - - #[test] - fn to_text_output_ongoing_with_truncation() { - let out = ExecCommandOutput { - wall_time: Duration::from_millis(500), - exit_status: ExitStatus::Ongoing(SessionId(42)), - original_token_count: Some(1000), - output: "abc".to_string(), - }; - let text = out.to_text_output(); - let expected = r#"Wall time: 0.500 seconds -Process running with session ID 42 -Warning: truncated output (original token count: 1000) -Output: -abc"#; - assert_eq!(expected, text); - } -} diff --git a/codex-rs/core/src/executor/backends.rs b/codex-rs/core/src/executor/backends.rs deleted file mode 100644 index 9c65745c4f..0000000000 --- a/codex-rs/core/src/executor/backends.rs +++ /dev/null @@ -1,109 +0,0 @@ -use std::collections::HashMap; -use std::env; - -use async_trait::async_trait; - -use crate::CODEX_APPLY_PATCH_ARG1; -use crate::apply_patch::ApplyPatchExec; -use crate::exec::ExecParams; -use crate::executor::ExecutorConfig; -use crate::function_tool::FunctionCallError; - -pub(crate) enum ExecutionMode { - Shell, - ApplyPatch(ApplyPatchExec), -} - -#[async_trait] -/// Backend-specific hooks that prepare and post-process execution requests for a -/// given [`ExecutionMode`]. -pub(crate) trait ExecutionBackend: Send + Sync { - fn prepare( - &self, - params: ExecParams, - // Required for downcasting the apply_patch. - mode: &ExecutionMode, - config: &ExecutorConfig, - ) -> Result; - - fn stream_stdout(&self, _mode: &ExecutionMode) -> bool { - true - } -} - -static SHELL_BACKEND: ShellBackend = ShellBackend; -static APPLY_PATCH_BACKEND: ApplyPatchBackend = ApplyPatchBackend; - -pub(crate) fn backend_for_mode(mode: &ExecutionMode) -> &'static dyn ExecutionBackend { - match mode { - ExecutionMode::Shell => &SHELL_BACKEND, - ExecutionMode::ApplyPatch(_) => &APPLY_PATCH_BACKEND, - } -} - -struct ShellBackend; - -#[async_trait] -impl ExecutionBackend for ShellBackend { - fn prepare( - &self, - params: ExecParams, - mode: &ExecutionMode, - _config: &ExecutorConfig, - ) -> Result { - match mode { - ExecutionMode::Shell => Ok(params), - _ => Err(FunctionCallError::RespondToModel( - "shell backend invoked with non-shell mode".to_string(), - )), - } - } -} - -struct ApplyPatchBackend; - -#[async_trait] -impl ExecutionBackend for ApplyPatchBackend { - fn prepare( - &self, - params: ExecParams, - mode: &ExecutionMode, - config: &ExecutorConfig, - ) -> Result { - match mode { - ExecutionMode::ApplyPatch(exec) => { - let path_to_codex = if let Some(exe_path) = &config.codex_exe { - exe_path.to_string_lossy().to_string() - } else { - env::current_exe() - .ok() - .map(|p| p.to_string_lossy().to_string()) - .ok_or_else(|| { - FunctionCallError::RespondToModel( - "failed to determine path to codex executable".to_string(), - ) - })? - }; - - let patch = exec.action.patch.clone(); - Ok(ExecParams { - command: vec![path_to_codex, CODEX_APPLY_PATCH_ARG1.to_string(), patch], - cwd: exec.action.cwd.clone(), - timeout_ms: params.timeout_ms, - // Run apply_patch with a minimal environment for determinism and to - // avoid leaking host environment variables into the patch process. - env: HashMap::new(), - with_escalated_permissions: params.with_escalated_permissions, - justification: params.justification, - }) - } - ExecutionMode::Shell => Err(FunctionCallError::RespondToModel( - "apply_patch backend invoked without patch context".to_string(), - )), - } - } - - fn stream_stdout(&self, _mode: &ExecutionMode) -> bool { - false - } -} diff --git a/codex-rs/core/src/executor/cache.rs b/codex-rs/core/src/executor/cache.rs deleted file mode 100644 index 737ecb927c..0000000000 --- a/codex-rs/core/src/executor/cache.rs +++ /dev/null @@ -1,51 +0,0 @@ -use std::collections::HashSet; -use std::sync::Arc; -use std::sync::Mutex; - -#[derive(Clone, Debug, Default)] -/// Thread-safe store of user approvals so repeated commands can reuse -/// previously granted trust. -pub(crate) struct ApprovalCache { - inner: Arc>>>, -} - -impl ApprovalCache { - pub(crate) fn insert(&self, command: Vec) { - if command.is_empty() { - return; - } - if let Ok(mut guard) = self.inner.lock() { - guard.insert(command); - } - } - - pub(crate) fn snapshot(&self) -> HashSet> { - self.inner.lock().map(|g| g.clone()).unwrap_or_default() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn insert_ignores_empty_and_dedupes() { - let cache = ApprovalCache::default(); - - // Empty should be ignored - cache.insert(vec![]); - assert!(cache.snapshot().is_empty()); - - // Insert a command and verify snapshot contains it - let cmd = vec!["foo".to_string(), "bar".to_string()]; - cache.insert(cmd.clone()); - let snap1 = cache.snapshot(); - assert!(snap1.contains(&cmd)); - - // Reinserting should not create duplicates - cache.insert(cmd); - let snap2 = cache.snapshot(); - assert_eq!(snap1, snap2); - } -} diff --git a/codex-rs/core/src/executor/mod.rs b/codex-rs/core/src/executor/mod.rs deleted file mode 100644 index 3f1e02dff2..0000000000 --- a/codex-rs/core/src/executor/mod.rs +++ /dev/null @@ -1,68 +0,0 @@ -mod backends; -mod cache; -mod runner; -mod sandbox; - -pub(crate) use backends::ExecutionMode; -pub(crate) use runner::ExecutionRequest; -pub(crate) use runner::Executor; -pub(crate) use runner::ExecutorConfig; -pub(crate) use runner::normalize_exec_result; - -pub(crate) mod linkers { - use crate::exec::ExecParams; - use crate::exec::StdoutStream; - use crate::executor::backends::ExecutionMode; - use crate::executor::runner::ExecutionRequest; - use crate::tools::context::ExecCommandContext; - - pub struct PreparedExec { - pub(crate) context: ExecCommandContext, - pub(crate) request: ExecutionRequest, - } - - impl PreparedExec { - pub fn new( - context: ExecCommandContext, - params: ExecParams, - approval_command: Vec, - mode: ExecutionMode, - stdout_stream: Option, - use_shell_profile: bool, - ) -> Self { - let request = ExecutionRequest { - params, - approval_command, - mode, - stdout_stream, - use_shell_profile, - }; - - Self { context, request } - } - } -} - -pub mod errors { - use crate::error::CodexErr; - use crate::function_tool::FunctionCallError; - use thiserror::Error; - - #[derive(Debug, Error)] - pub enum ExecError { - #[error(transparent)] - Function(#[from] FunctionCallError), - #[error(transparent)] - Codex(#[from] CodexErr), - } - - impl ExecError { - pub(crate) fn rejection(msg: impl Into) -> Self { - FunctionCallError::RespondToModel(msg.into()).into() - } - - pub(crate) fn denied(msg: impl Into) -> Self { - FunctionCallError::Denied(msg.into()).into() - } - } -} diff --git a/codex-rs/core/src/executor/runner.rs b/codex-rs/core/src/executor/runner.rs deleted file mode 100644 index 072debb5d3..0000000000 --- a/codex-rs/core/src/executor/runner.rs +++ /dev/null @@ -1,433 +0,0 @@ -use std::future::Future; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::RwLock; -use std::time::Duration; - -use super::backends::ExecutionMode; -use super::backends::backend_for_mode; -use super::cache::ApprovalCache; -use crate::codex::Session; -use crate::error::CodexErr; -use crate::error::SandboxErr; -use crate::error::get_error_message_ui; -use crate::exec::ExecParams; -use crate::exec::ExecToolCallOutput; -use crate::exec::SandboxType; -use crate::exec::StdoutStream; -use crate::exec::StreamOutput; -use crate::exec::process_exec_tool_call; -use crate::executor::errors::ExecError; -use crate::executor::sandbox::select_sandbox; -use crate::function_tool::FunctionCallError; -use crate::protocol::AskForApproval; -use crate::protocol::ReviewDecision; -use crate::protocol::SandboxPolicy; -use crate::shell; -use crate::tools::context::ExecCommandContext; -use codex_otel::otel_event_manager::ToolDecisionSource; - -#[derive(Clone, Debug)] -pub(crate) struct ExecutorConfig { - pub(crate) sandbox_policy: SandboxPolicy, - pub(crate) sandbox_cwd: PathBuf, - pub(crate) codex_exe: Option, -} - -impl ExecutorConfig { - pub(crate) fn new( - sandbox_policy: SandboxPolicy, - sandbox_cwd: PathBuf, - codex_exe: Option, - ) -> Self { - Self { - sandbox_policy, - sandbox_cwd, - codex_exe, - } - } -} - -/// Coordinates sandbox selection, backend-specific preparation, and command -/// execution for tool calls requested by the model. -pub(crate) struct Executor { - approval_cache: ApprovalCache, - config: Arc>, -} - -impl Executor { - pub(crate) fn new(config: ExecutorConfig) -> Self { - Self { - approval_cache: ApprovalCache::default(), - config: Arc::new(RwLock::new(config)), - } - } - - /// Updates the sandbox policy and working directory used for future - /// executions without recreating the executor. - pub(crate) fn update_environment(&self, sandbox_policy: SandboxPolicy, sandbox_cwd: PathBuf) { - if let Ok(mut cfg) = self.config.write() { - cfg.sandbox_policy = sandbox_policy; - cfg.sandbox_cwd = sandbox_cwd; - } - } - - /// Runs a prepared execution request end-to-end: prepares parameters, decides on - /// sandbox placement (prompting the user when necessary), launches the command, - /// and lets the backend post-process the final output. - pub(crate) async fn run( - &self, - mut request: ExecutionRequest, - session: &Session, - approval_policy: AskForApproval, - context: &ExecCommandContext, - on_exec_begin: F, - ) -> Result - where - F: FnOnce() -> Fut, - Fut: Future, - { - if matches!(request.mode, ExecutionMode::Shell) { - request.params = - maybe_translate_shell_command(request.params, session, request.use_shell_profile); - } - - // Step 1: Snapshot sandbox configuration so it stays stable for this run. - let config = self - .config - .read() - .map_err(|_| ExecError::rejection("executor config poisoned"))? - .clone(); - - // Step 2: Normalise parameters via the selected backend. - let backend = backend_for_mode(&request.mode); - let stdout_stream = if backend.stream_stdout(&request.mode) { - request.stdout_stream.clone() - } else { - None - }; - request.params = backend - .prepare(request.params, &request.mode, &config) - .map_err(ExecError::from)?; - - // Step 3: Decide sandbox placement, prompting for approval when needed. - let sandbox_decision = select_sandbox( - &request, - approval_policy, - self.approval_cache.snapshot(), - &config, - session, - &context.sub_id, - &context.call_id, - &context.otel_event_manager, - ) - .await?; - if sandbox_decision.record_session_approval { - self.approval_cache.insert(request.approval_command.clone()); - } - on_exec_begin().await; - // Step 4: Launch the command within the chosen sandbox. - let first_attempt = self - .spawn( - request.params.clone(), - sandbox_decision.initial_sandbox, - &config, - stdout_stream.clone(), - ) - .await; - - // Step 5: Handle sandbox outcomes, optionally escalating to an unsandboxed retry. - match first_attempt { - Ok(output) => Ok(output), - Err(CodexErr::Sandbox(SandboxErr::Timeout { output })) => { - Err(CodexErr::Sandbox(SandboxErr::Timeout { output }).into()) - } - Err(CodexErr::Sandbox(error)) => { - if sandbox_decision.escalate_on_failure { - self.retry_without_sandbox( - &request, - &config, - session, - context, - stdout_stream, - error, - ) - .await - } else { - let message = sandbox_failure_message(error); - Err(ExecError::rejection(message)) - } - } - Err(err) => Err(err.into()), - } - } - - /// Fallback path invoked when a sandboxed run is denied so the user can - /// approve rerunning without isolation. - async fn retry_without_sandbox( - &self, - request: &ExecutionRequest, - config: &ExecutorConfig, - session: &Session, - context: &ExecCommandContext, - stdout_stream: Option, - sandbox_error: SandboxErr, - ) -> Result { - session - .notify_background_event( - &context.sub_id, - format!("Execution failed: {sandbox_error}"), - ) - .await; - let decision = session - .request_command_approval( - context.sub_id.to_string(), - context.call_id.to_string(), - request.approval_command.clone(), - request.params.cwd.clone(), - Some("command failed; retry without sandbox?".to_string()), - ) - .await; - - context.otel_event_manager.tool_decision( - &context.tool_name, - &context.call_id, - decision, - ToolDecisionSource::User, - ); - match decision { - ReviewDecision::Approved | ReviewDecision::ApprovedForSession => { - if matches!(decision, ReviewDecision::ApprovedForSession) { - self.approval_cache.insert(request.approval_command.clone()); - } - session - .notify_background_event(&context.sub_id, "retrying command without sandbox") - .await; - - let retry_output = self - .spawn( - request.params.clone(), - SandboxType::None, - config, - stdout_stream, - ) - .await?; - - Ok(retry_output) - } - ReviewDecision::Denied | ReviewDecision::Abort => { - Err(ExecError::denied("exec command rejected by user")) - } - } - } - - async fn spawn( - &self, - params: ExecParams, - sandbox: SandboxType, - config: &ExecutorConfig, - stdout_stream: Option, - ) -> Result { - process_exec_tool_call( - params, - sandbox, - &config.sandbox_policy, - &config.sandbox_cwd, - &config.codex_exe, - stdout_stream, - ) - .await - } -} - -fn maybe_translate_shell_command( - params: ExecParams, - session: &Session, - use_shell_profile: bool, -) -> ExecParams { - let should_translate = - matches!(session.user_shell(), shell::Shell::PowerShell(_)) || use_shell_profile; - - if should_translate - && let Some(command) = session - .user_shell() - .format_default_shell_invocation(params.command.clone()) - { - return ExecParams { command, ..params }; - } - - params -} - -fn sandbox_failure_message(error: SandboxErr) -> String { - let codex_error = CodexErr::Sandbox(error); - let friendly = get_error_message_ui(&codex_error); - format!("failed in sandbox: {friendly}") -} - -pub(crate) struct ExecutionRequest { - pub params: ExecParams, - pub approval_command: Vec, - pub mode: ExecutionMode, - pub stdout_stream: Option, - pub use_shell_profile: bool, -} - -pub(crate) struct NormalizedExecOutput<'a> { - borrowed: Option<&'a ExecToolCallOutput>, - synthetic: Option, -} - -impl<'a> NormalizedExecOutput<'a> { - pub(crate) fn event_output(&'a self) -> &'a ExecToolCallOutput { - match (self.borrowed, self.synthetic.as_ref()) { - (Some(output), _) => output, - (None, Some(output)) => output, - (None, None) => unreachable!("normalized exec output missing data"), - } - } -} - -/// Converts a raw execution result into a uniform view that always exposes an -/// [`ExecToolCallOutput`], synthesizing error output when the command fails -/// before producing a response. -pub(crate) fn normalize_exec_result( - result: &Result, -) -> NormalizedExecOutput<'_> { - match result { - Ok(output) => NormalizedExecOutput { - borrowed: Some(output), - synthetic: None, - }, - Err(ExecError::Codex(CodexErr::Sandbox(SandboxErr::Timeout { output }))) => { - NormalizedExecOutput { - borrowed: Some(output.as_ref()), - synthetic: None, - } - } - Err(err) => { - let message = match err { - ExecError::Function(FunctionCallError::RespondToModel(msg)) - | ExecError::Function(FunctionCallError::Denied(msg)) => msg.clone(), - ExecError::Codex(e) => get_error_message_ui(e), - err => err.to_string(), - }; - let synthetic = ExecToolCallOutput { - exit_code: -1, - stdout: StreamOutput::new(String::new()), - stderr: StreamOutput::new(message.clone()), - aggregated_output: StreamOutput::new(message), - duration: Duration::default(), - timed_out: false, - }; - NormalizedExecOutput { - borrowed: None, - synthetic: Some(synthetic), - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::error::CodexErr; - use crate::error::EnvVarError; - use crate::error::SandboxErr; - use crate::exec::StreamOutput; - use pretty_assertions::assert_eq; - - fn make_output(text: &str) -> ExecToolCallOutput { - ExecToolCallOutput { - exit_code: 1, - stdout: StreamOutput::new(String::new()), - stderr: StreamOutput::new(String::new()), - aggregated_output: StreamOutput::new(text.to_string()), - duration: Duration::from_millis(123), - timed_out: false, - } - } - - #[test] - fn normalize_success_borrows() { - let out = make_output("ok"); - let result: Result = Ok(out); - let normalized = normalize_exec_result(&result); - assert_eq!(normalized.event_output().aggregated_output.text, "ok"); - } - - #[test] - fn normalize_timeout_borrows_embedded_output() { - let out = make_output("timed out payload"); - let err = CodexErr::Sandbox(SandboxErr::Timeout { - output: Box::new(out), - }); - let result: Result = Err(ExecError::Codex(err)); - let normalized = normalize_exec_result(&result); - assert_eq!( - normalized.event_output().aggregated_output.text, - "timed out payload" - ); - } - - #[test] - fn sandbox_failure_message_uses_denied_stderr() { - let output = ExecToolCallOutput { - exit_code: 101, - stdout: StreamOutput::new(String::new()), - stderr: StreamOutput::new("sandbox stderr".to_string()), - aggregated_output: StreamOutput::new(String::new()), - duration: Duration::from_millis(10), - timed_out: false, - }; - let err = SandboxErr::Denied { - output: Box::new(output), - }; - let message = sandbox_failure_message(err); - assert_eq!(message, "failed in sandbox: sandbox stderr"); - } - - #[test] - fn sandbox_failure_message_falls_back_to_aggregated_output() { - let output = ExecToolCallOutput { - exit_code: 101, - stdout: StreamOutput::new(String::new()), - stderr: StreamOutput::new(String::new()), - aggregated_output: StreamOutput::new("aggregate text".to_string()), - duration: Duration::from_millis(10), - timed_out: false, - }; - let err = SandboxErr::Denied { - output: Box::new(output), - }; - let message = sandbox_failure_message(err); - assert_eq!(message, "failed in sandbox: aggregate text"); - } - - #[test] - fn normalize_function_error_synthesizes_payload() { - let err = FunctionCallError::RespondToModel("boom".to_string()); - let result: Result = Err(ExecError::Function(err)); - let normalized = normalize_exec_result(&result); - assert_eq!(normalized.event_output().aggregated_output.text, "boom"); - } - - #[test] - fn normalize_codex_error_synthesizes_user_message() { - // Use a simple EnvVar error which formats to a clear message - let e = CodexErr::EnvVar(EnvVarError { - var: "FOO".to_string(), - instructions: Some("set it".to_string()), - }); - let result: Result = Err(ExecError::Codex(e)); - let normalized = normalize_exec_result(&result); - assert!( - normalized - .event_output() - .aggregated_output - .text - .contains("Missing environment variable: `FOO`"), - "expected synthesized user-friendly message" - ); - } -} diff --git a/codex-rs/core/src/executor/sandbox.rs b/codex-rs/core/src/executor/sandbox.rs deleted file mode 100644 index f0a421a29a..0000000000 --- a/codex-rs/core/src/executor/sandbox.rs +++ /dev/null @@ -1,405 +0,0 @@ -use crate::apply_patch::ApplyPatchExec; -use crate::codex::Session; -use crate::exec::SandboxType; -use crate::executor::ExecutionMode; -use crate::executor::ExecutionRequest; -use crate::executor::ExecutorConfig; -use crate::executor::errors::ExecError; -use crate::safety::SafetyCheck; -use crate::safety::assess_command_safety; -use crate::safety::assess_patch_safety; -use codex_otel::otel_event_manager::OtelEventManager; -use codex_otel::otel_event_manager::ToolDecisionSource; -use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::ReviewDecision; -use std::collections::HashSet; - -/// Sandbox placement options selected for an execution run, including whether -/// to escalate after failures and whether approvals should persist. -pub(crate) struct SandboxDecision { - pub(crate) initial_sandbox: SandboxType, - pub(crate) escalate_on_failure: bool, - pub(crate) record_session_approval: bool, -} - -impl SandboxDecision { - fn auto(sandbox: SandboxType, escalate_on_failure: bool) -> Self { - Self { - initial_sandbox: sandbox, - escalate_on_failure, - record_session_approval: false, - } - } - - fn user_override(record_session_approval: bool) -> Self { - Self { - initial_sandbox: SandboxType::None, - escalate_on_failure: false, - record_session_approval, - } - } -} - -fn should_escalate_on_failure(approval: AskForApproval, sandbox: SandboxType) -> bool { - matches!( - (approval, sandbox), - ( - AskForApproval::UnlessTrusted | AskForApproval::OnFailure, - SandboxType::MacosSeatbelt | SandboxType::LinuxSeccomp - ) - ) -} - -/// Determines how a command should be sandboxed, prompting the user when -/// policy requires explicit approval. -#[allow(clippy::too_many_arguments)] -pub async fn select_sandbox( - request: &ExecutionRequest, - approval_policy: AskForApproval, - approval_cache: HashSet>, - config: &ExecutorConfig, - session: &Session, - sub_id: &str, - call_id: &str, - otel_event_manager: &OtelEventManager, -) -> Result { - match &request.mode { - ExecutionMode::Shell => { - select_shell_sandbox( - request, - approval_policy, - approval_cache, - config, - session, - sub_id, - call_id, - otel_event_manager, - ) - .await - } - ExecutionMode::ApplyPatch(exec) => { - select_apply_patch_sandbox(exec, approval_policy, config) - } - } -} - -#[allow(clippy::too_many_arguments)] -async fn select_shell_sandbox( - request: &ExecutionRequest, - approval_policy: AskForApproval, - approved_snapshot: HashSet>, - config: &ExecutorConfig, - session: &Session, - sub_id: &str, - call_id: &str, - otel_event_manager: &OtelEventManager, -) -> Result { - let command_for_safety = if request.approval_command.is_empty() { - request.params.command.clone() - } else { - request.approval_command.clone() - }; - - let safety = assess_command_safety( - &command_for_safety, - approval_policy, - &config.sandbox_policy, - &approved_snapshot, - request.params.with_escalated_permissions.unwrap_or(false), - ); - - match safety { - SafetyCheck::AutoApprove { - sandbox_type, - user_explicitly_approved, - } => { - let mut decision = SandboxDecision::auto( - sandbox_type, - should_escalate_on_failure(approval_policy, sandbox_type), - ); - if user_explicitly_approved { - decision.record_session_approval = true; - } - let (decision_for_event, source) = if user_explicitly_approved { - (ReviewDecision::ApprovedForSession, ToolDecisionSource::User) - } else { - (ReviewDecision::Approved, ToolDecisionSource::Config) - }; - otel_event_manager.tool_decision("local_shell", call_id, decision_for_event, source); - Ok(decision) - } - SafetyCheck::AskUser => { - let decision = session - .request_command_approval( - sub_id.to_string(), - call_id.to_string(), - request.approval_command.clone(), - request.params.cwd.clone(), - request.params.justification.clone(), - ) - .await; - - otel_event_manager.tool_decision( - "local_shell", - call_id, - decision, - ToolDecisionSource::User, - ); - match decision { - ReviewDecision::Approved => Ok(SandboxDecision::user_override(false)), - ReviewDecision::ApprovedForSession => Ok(SandboxDecision::user_override(true)), - ReviewDecision::Denied | ReviewDecision::Abort => { - Err(ExecError::denied("exec command rejected by user")) - } - } - } - SafetyCheck::Reject { reason } => Err(ExecError::rejection(format!( - "exec command rejected: {reason}" - ))), - } -} - -fn select_apply_patch_sandbox( - exec: &ApplyPatchExec, - approval_policy: AskForApproval, - config: &ExecutorConfig, -) -> Result { - if exec.user_explicitly_approved_this_action { - return Ok(SandboxDecision::user_override(false)); - } - - match assess_patch_safety( - &exec.action, - approval_policy, - &config.sandbox_policy, - &config.sandbox_cwd, - ) { - SafetyCheck::AutoApprove { sandbox_type, .. } => Ok(SandboxDecision::auto( - sandbox_type, - should_escalate_on_failure(approval_policy, sandbox_type), - )), - SafetyCheck::AskUser => Err(ExecError::rejection( - "patch requires approval but none was recorded", - )), - SafetyCheck::Reject { reason } => { - Err(ExecError::rejection(format!("patch rejected: {reason}"))) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::codex::make_session_and_context; - use crate::exec::ExecParams; - use crate::function_tool::FunctionCallError; - use crate::protocol::SandboxPolicy; - use codex_apply_patch::ApplyPatchAction; - use pretty_assertions::assert_eq; - - #[tokio::test] - async fn select_apply_patch_user_override_when_explicit() { - let (session, ctx) = make_session_and_context(); - let tmp = tempfile::tempdir().expect("tmp"); - let p = tmp.path().join("a.txt"); - let action = ApplyPatchAction::new_add_for_test(&p, "hello".to_string()); - let exec = ApplyPatchExec { - action, - user_explicitly_approved_this_action: true, - }; - let cfg = ExecutorConfig::new(SandboxPolicy::ReadOnly, std::env::temp_dir(), None); - let request = ExecutionRequest { - params: ExecParams { - command: vec!["apply_patch".into()], - cwd: std::env::temp_dir(), - timeout_ms: None, - env: std::collections::HashMap::new(), - with_escalated_permissions: None, - justification: None, - }, - approval_command: vec!["apply_patch".into()], - mode: ExecutionMode::ApplyPatch(exec), - stdout_stream: None, - use_shell_profile: false, - }; - let otel_event_manager = ctx.client.get_otel_event_manager(); - let decision = select_sandbox( - &request, - AskForApproval::OnRequest, - Default::default(), - &cfg, - &session, - "sub", - "call", - &otel_event_manager, - ) - .await - .expect("ok"); - // Explicit user override runs without sandbox - assert_eq!(decision.initial_sandbox, SandboxType::None); - assert_eq!(decision.escalate_on_failure, false); - } - - #[tokio::test] - async fn select_apply_patch_autoapprove_in_danger() { - let (session, ctx) = make_session_and_context(); - let tmp = tempfile::tempdir().expect("tmp"); - let p = tmp.path().join("a.txt"); - let action = ApplyPatchAction::new_add_for_test(&p, "hello".to_string()); - let exec = ApplyPatchExec { - action, - user_explicitly_approved_this_action: false, - }; - let cfg = ExecutorConfig::new(SandboxPolicy::DangerFullAccess, std::env::temp_dir(), None); - let request = ExecutionRequest { - params: ExecParams { - command: vec!["apply_patch".into()], - cwd: std::env::temp_dir(), - timeout_ms: None, - env: std::collections::HashMap::new(), - with_escalated_permissions: None, - justification: None, - }, - approval_command: vec!["apply_patch".into()], - mode: ExecutionMode::ApplyPatch(exec), - stdout_stream: None, - use_shell_profile: false, - }; - let otel_event_manager = ctx.client.get_otel_event_manager(); - let decision = select_sandbox( - &request, - AskForApproval::OnRequest, - Default::default(), - &cfg, - &session, - "sub", - "call", - &otel_event_manager, - ) - .await - .expect("ok"); - // On platforms with a sandbox, DangerFullAccess still prefers it - let expected = crate::safety::get_platform_sandbox().unwrap_or(SandboxType::None); - assert_eq!(decision.initial_sandbox, expected); - assert_eq!(decision.escalate_on_failure, false); - } - - #[tokio::test] - async fn select_apply_patch_requires_approval_on_unless_trusted() { - let (session, ctx) = make_session_and_context(); - let tempdir = tempfile::tempdir().expect("tmpdir"); - let p = tempdir.path().join("a.txt"); - let action = ApplyPatchAction::new_add_for_test(&p, "hello".to_string()); - let exec = ApplyPatchExec { - action, - user_explicitly_approved_this_action: false, - }; - let cfg = ExecutorConfig::new(SandboxPolicy::ReadOnly, std::env::temp_dir(), None); - let request = ExecutionRequest { - params: ExecParams { - command: vec!["apply_patch".into()], - cwd: std::env::temp_dir(), - timeout_ms: None, - env: std::collections::HashMap::new(), - with_escalated_permissions: None, - justification: None, - }, - approval_command: vec!["apply_patch".into()], - mode: ExecutionMode::ApplyPatch(exec), - stdout_stream: None, - use_shell_profile: false, - }; - let otel_event_manager = ctx.client.get_otel_event_manager(); - let result = select_sandbox( - &request, - AskForApproval::UnlessTrusted, - Default::default(), - &cfg, - &session, - "sub", - "call", - &otel_event_manager, - ) - .await; - match result { - Ok(_) => panic!("expected error"), - Err(ExecError::Function(FunctionCallError::RespondToModel(msg))) => { - assert!(msg.contains("requires approval")) - } - Err(other) => panic!("unexpected error: {other:?}"), - } - } - - #[tokio::test] - async fn select_shell_autoapprove_in_danger_mode() { - let (session, ctx) = make_session_and_context(); - let cfg = ExecutorConfig::new(SandboxPolicy::DangerFullAccess, std::env::temp_dir(), None); - let request = ExecutionRequest { - params: ExecParams { - command: vec!["some-unknown".into()], - cwd: std::env::temp_dir(), - timeout_ms: None, - env: std::collections::HashMap::new(), - with_escalated_permissions: None, - justification: None, - }, - approval_command: vec!["some-unknown".into()], - mode: ExecutionMode::Shell, - stdout_stream: None, - use_shell_profile: false, - }; - let otel_event_manager = ctx.client.get_otel_event_manager(); - let decision = select_sandbox( - &request, - AskForApproval::OnRequest, - Default::default(), - &cfg, - &session, - "sub", - "call", - &otel_event_manager, - ) - .await - .expect("ok"); - assert_eq!(decision.initial_sandbox, SandboxType::None); - assert_eq!(decision.escalate_on_failure, false); - } - - #[cfg(any(target_os = "macos", target_os = "linux"))] - #[tokio::test] - async fn select_shell_escalates_on_failure_with_platform_sandbox() { - let (session, ctx) = make_session_and_context(); - let cfg = ExecutorConfig::new(SandboxPolicy::ReadOnly, std::env::temp_dir(), None); - let request = ExecutionRequest { - params: ExecParams { - // Unknown command => untrusted but not flagged dangerous - command: vec!["some-unknown".into()], - cwd: std::env::temp_dir(), - timeout_ms: None, - env: std::collections::HashMap::new(), - with_escalated_permissions: None, - justification: None, - }, - approval_command: vec!["some-unknown".into()], - mode: ExecutionMode::Shell, - stdout_stream: None, - use_shell_profile: false, - }; - let otel_event_manager = ctx.client.get_otel_event_manager(); - let decision = select_sandbox( - &request, - AskForApproval::OnFailure, - Default::default(), - &cfg, - &session, - "sub", - "call", - &otel_event_manager, - ) - .await - .expect("ok"); - // On macOS/Linux we should have a platform sandbox and escalate on failure - assert_ne!(decision.initial_sandbox, SandboxType::None); - assert_eq!(decision.escalate_on_failure, true); - } -} diff --git a/codex-rs/core/src/function_tool.rs b/codex-rs/core/src/function_tool.rs index a25fff6113..9d24165569 100644 --- a/codex-rs/core/src/function_tool.rs +++ b/codex-rs/core/src/function_tool.rs @@ -5,6 +5,7 @@ pub enum FunctionCallError { #[error("{0}")] RespondToModel(String), #[error("{0}")] + #[allow(dead_code)] // TODO(jif) fix in a follow-up PR Denied(String), #[error("LocalShellCall without call_id or id")] MissingLocalShellCallId, diff --git a/codex-rs/core/src/landlock.rs b/codex-rs/core/src/landlock.rs index 264ea747ca..340aebff2c 100644 --- a/codex-rs/core/src/landlock.rs +++ b/codex-rs/core/src/landlock.rs @@ -40,7 +40,7 @@ where } /// Converts the sandbox policy into the CLI invocation for `codex-linux-sandbox`. -fn create_linux_sandbox_command_args( +pub(crate) fn create_linux_sandbox_command_args( command: Vec, sandbox_policy: &SandboxPolicy, sandbox_policy_cwd: &Path, @@ -56,7 +56,9 @@ fn create_linux_sandbox_command_args( serde_json::to_string(sandbox_policy).expect("Failed to serialize SandboxPolicy to JSON"); let mut linux_cmd: Vec = vec![ + "--sandbox-policy-cwd".to_string(), sandbox_policy_cwd, + "--sandbox-policy".to_string(), sandbox_policy_json, // Separator so that command arguments starting with `-` are not parsed as // options of the helper itself. diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 8ddf8bff9c..fa307b1ea7 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -25,9 +25,7 @@ pub mod custom_prompts; mod environment_context; pub mod error; pub mod exec; -mod exec_command; pub mod exec_env; -pub mod executor; pub mod features; mod flags; pub mod git_info; @@ -38,6 +36,7 @@ mod mcp_tool_call; mod message_history; mod model_provider_info; pub mod parse_command; +pub mod sandboxing; pub mod token_data; mod truncate; mod unified_exec; @@ -59,7 +58,6 @@ pub use auth::CodexAuth; pub mod default_client; pub mod model_family; mod openai_model_info; -mod openai_tools; pub mod project_doc; mod rollout; pub(crate) mod safety; diff --git a/codex-rs/core/src/openai_tools.rs b/codex-rs/core/src/openai_tools.rs deleted file mode 100644 index 0e10f909ec..0000000000 --- a/codex-rs/core/src/openai_tools.rs +++ /dev/null @@ -1 +0,0 @@ -pub use crate::tools::spec::*; diff --git a/codex-rs/core/src/safety.rs b/codex-rs/core/src/safety.rs index 0ed0f929ff..4492947d58 100644 --- a/codex-rs/core/src/safety.rs +++ b/codex-rs/core/src/safety.rs @@ -1,4 +1,3 @@ -use std::collections::HashSet; use std::path::Component; use std::path::Path; use std::path::PathBuf; @@ -8,8 +7,6 @@ use codex_apply_patch::ApplyPatchFileChange; use crate::exec::SandboxType; -use crate::command_safety::is_dangerous_command::command_might_be_dangerous; -use crate::command_safety::is_safe_command::is_known_safe_command; use crate::protocol::AskForApproval; use crate::protocol::SandboxPolicy; @@ -48,30 +45,29 @@ pub fn assess_patch_safety( } } - // Even though the patch *appears* to be constrained to writable paths, it - // is possible that paths in the patch are hard links to files outside the - // writable roots, so we should still run `apply_patch` in a sandbox in that - // case. + // Even though the patch appears to be constrained to writable paths, it is + // possible that paths in the patch are hard links to files outside the + // writable roots, so we should still run `apply_patch` in a sandbox in that case. if is_write_patch_constrained_to_writable_paths(action, sandbox_policy, cwd) || policy == AskForApproval::OnFailure { - // Only auto‑approve when we can actually enforce a sandbox. Otherwise - // fall back to asking the user because the patch may touch arbitrary - // paths outside the project. - match get_platform_sandbox() { - Some(sandbox_type) => SafetyCheck::AutoApprove { - sandbox_type, + if matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) { + // DangerFullAccess is intended to bypass sandboxing entirely. + SafetyCheck::AutoApprove { + sandbox_type: SandboxType::None, user_explicitly_approved: false, - }, - None if sandbox_policy == &SandboxPolicy::DangerFullAccess => { - // If the user has explicitly requested DangerFullAccess, then - // we can auto-approve even without a sandbox. - SafetyCheck::AutoApprove { - sandbox_type: SandboxType::None, - user_explicitly_approved: false, - } } - None => SafetyCheck::AskUser, + } else { + // Only auto‑approve when we can actually enforce a sandbox. Otherwise + // fall back to asking the user because the patch may touch arbitrary + // paths outside the project. + match get_platform_sandbox() { + Some(sandbox_type) => SafetyCheck::AutoApprove { + sandbox_type, + user_explicitly_approved: false, + }, + None => SafetyCheck::AskUser, + } } } else if policy == AskForApproval::Never { SafetyCheck::Reject { @@ -83,124 +79,6 @@ pub fn assess_patch_safety( } } -/// For a command to be run _without_ a sandbox, one of the following must be -/// true: -/// -/// - the user has explicitly approved the command -/// - the command is on the "known safe" list -/// - `DangerFullAccess` was specified and `UnlessTrusted` was not -pub fn assess_command_safety( - command: &[String], - approval_policy: AskForApproval, - sandbox_policy: &SandboxPolicy, - approved: &HashSet>, - with_escalated_permissions: bool, -) -> SafetyCheck { - // Some commands look dangerous. Even if they are run inside a sandbox, - // unless the user has explicitly approved them, we should ask, - // or reject if the approval_policy tells us not to ask. - if command_might_be_dangerous(command) && !approved.contains(command) { - if approval_policy == AskForApproval::Never { - return SafetyCheck::Reject { - reason: "dangerous command detected; rejected by user approval settings" - .to_string(), - }; - } - - return SafetyCheck::AskUser; - } - - // A command is "trusted" because either: - // - it belongs to a set of commands we consider "safe" by default, or - // - the user has explicitly approved the command for this session - // - // Currently, whether a command is "trusted" is a simple boolean, but we - // should include more metadata on this command test to indicate whether it - // should be run inside a sandbox or not. (This could be something the user - // defines as part of `execpolicy`.) - // - // For example, when `is_known_safe_command(command)` returns `true`, it - // would probably be fine to run the command in a sandbox, but when - // `approved.contains(command)` is `true`, the user may have approved it for - // the session _because_ they know it needs to run outside a sandbox. - - if is_known_safe_command(command) || approved.contains(command) { - let user_explicitly_approved = approved.contains(command); - return SafetyCheck::AutoApprove { - sandbox_type: SandboxType::None, - user_explicitly_approved, - }; - } - - assess_safety_for_untrusted_command(approval_policy, sandbox_policy, with_escalated_permissions) -} - -pub(crate) fn assess_safety_for_untrusted_command( - approval_policy: AskForApproval, - sandbox_policy: &SandboxPolicy, - with_escalated_permissions: bool, -) -> SafetyCheck { - use AskForApproval::*; - use SandboxPolicy::*; - - match (approval_policy, sandbox_policy) { - (UnlessTrusted, _) => { - // Even though the user may have opted into DangerFullAccess, - // they also requested that we ask for approval for untrusted - // commands. - SafetyCheck::AskUser - } - (OnFailure, DangerFullAccess) - | (Never, DangerFullAccess) - | (OnRequest, DangerFullAccess) => SafetyCheck::AutoApprove { - sandbox_type: SandboxType::None, - user_explicitly_approved: false, - }, - (OnRequest, ReadOnly) | (OnRequest, WorkspaceWrite { .. }) => { - if with_escalated_permissions { - SafetyCheck::AskUser - } else { - match get_platform_sandbox() { - Some(sandbox_type) => SafetyCheck::AutoApprove { - sandbox_type, - user_explicitly_approved: false, - }, - // Fall back to asking since the command is untrusted and - // we do not have a sandbox available - None => SafetyCheck::AskUser, - } - } - } - (Never, ReadOnly) - | (Never, WorkspaceWrite { .. }) - | (OnFailure, ReadOnly) - | (OnFailure, WorkspaceWrite { .. }) => { - match get_platform_sandbox() { - Some(sandbox_type) => SafetyCheck::AutoApprove { - sandbox_type, - user_explicitly_approved: false, - }, - None => { - if matches!(approval_policy, OnFailure) { - // Since the command is not trusted, even though the - // user has requested to only ask for approval on - // failure, we will ask the user because no sandbox is - // available. - SafetyCheck::AskUser - } else { - // We are in non-interactive mode and lack approval, so - // all we can do is reject the command. - SafetyCheck::Reject { - reason: "auto-rejected because command is not on trusted list" - .to_string(), - } - } - } - } - } - } -} - pub fn get_platform_sandbox() -> Option { if cfg!(target_os = "macos") { Some(SandboxType::MacosSeatbelt) @@ -339,101 +217,4 @@ mod tests { &cwd, )); } - - #[test] - fn test_request_escalated_privileges() { - // Should not be a trusted command - let command = vec!["git commit".to_string()]; - let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = SandboxPolicy::ReadOnly; - let approved: HashSet> = HashSet::new(); - let request_escalated_privileges = true; - - let safety_check = assess_command_safety( - &command, - approval_policy, - &sandbox_policy, - &approved, - request_escalated_privileges, - ); - - assert_eq!(safety_check, SafetyCheck::AskUser); - } - - #[test] - fn dangerous_command_allowed_if_explicitly_approved() { - let command = vec!["git".to_string(), "reset".to_string(), "--hard".to_string()]; - let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = SandboxPolicy::ReadOnly; - let mut approved: HashSet> = HashSet::new(); - approved.insert(command.clone()); - let request_escalated_privileges = false; - - let safety_check = assess_command_safety( - &command, - approval_policy, - &sandbox_policy, - &approved, - request_escalated_privileges, - ); - - assert_eq!( - safety_check, - SafetyCheck::AutoApprove { - sandbox_type: SandboxType::None, - user_explicitly_approved: true, - } - ); - } - - #[test] - fn dangerous_command_not_allowed_if_not_explicitly_approved() { - let command = vec!["git".to_string(), "reset".to_string(), "--hard".to_string()]; - let approval_policy = AskForApproval::Never; - let sandbox_policy = SandboxPolicy::ReadOnly; - let approved: HashSet> = HashSet::new(); - let request_escalated_privileges = false; - - let safety_check = assess_command_safety( - &command, - approval_policy, - &sandbox_policy, - &approved, - request_escalated_privileges, - ); - - assert_eq!( - safety_check, - SafetyCheck::Reject { - reason: "dangerous command detected; rejected by user approval settings" - .to_string(), - } - ); - } - - #[test] - fn test_request_escalated_privileges_no_sandbox_fallback() { - let command = vec!["git".to_string(), "commit".to_string()]; - let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = SandboxPolicy::ReadOnly; - let approved: HashSet> = HashSet::new(); - let request_escalated_privileges = false; - - let safety_check = assess_command_safety( - &command, - approval_policy, - &sandbox_policy, - &approved, - request_escalated_privileges, - ); - - let expected = match get_platform_sandbox() { - Some(sandbox_type) => SafetyCheck::AutoApprove { - sandbox_type, - user_explicitly_approved: false, - }, - None => SafetyCheck::AskUser, - }; - assert_eq!(safety_check, expected); - } } diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs new file mode 100644 index 0000000000..d632b5da19 --- /dev/null +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -0,0 +1,170 @@ +/* +Module: sandboxing + +Build platform wrappers and produce ExecEnv for execution. Owns low‑level +sandbox placement and transformation of portable CommandSpec into a +ready‑to‑spawn environment. +*/ +use crate::exec::ExecToolCallOutput; +use crate::exec::SandboxType; +use crate::exec::StdoutStream; +use crate::exec::execute_exec_env; +use crate::landlock::create_linux_sandbox_command_args; +use crate::protocol::SandboxPolicy; +use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; +use crate::seatbelt::create_seatbelt_command_args; +use crate::spawn::CODEX_SANDBOX_ENV_VAR; +use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; +use crate::tools::sandboxing::SandboxablePreference; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; + +#[derive(Clone, Debug)] +pub struct CommandSpec { + pub program: String, + pub args: Vec, + pub cwd: PathBuf, + pub env: HashMap, + pub timeout_ms: Option, + pub with_escalated_permissions: Option, + pub justification: Option, +} + +#[derive(Clone, Debug)] +pub struct ExecEnv { + pub command: Vec, + pub cwd: PathBuf, + pub env: HashMap, + pub timeout_ms: Option, + pub sandbox: SandboxType, + pub with_escalated_permissions: Option, + pub justification: Option, + pub arg0: Option, +} + +pub enum SandboxPreference { + Auto, + Require, + Forbid, +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum SandboxTransformError { + #[error("missing codex-linux-sandbox executable path")] + MissingLinuxSandboxExecutable, +} + +#[derive(Default)] +pub struct SandboxManager; + +impl SandboxManager { + pub fn new() -> Self { + Self + } + + pub(crate) fn select_initial( + &self, + policy: &SandboxPolicy, + pref: SandboxablePreference, + ) -> SandboxType { + match pref { + SandboxablePreference::Forbid => SandboxType::None, + SandboxablePreference::Require => { + #[cfg(target_os = "macos")] + { + return SandboxType::MacosSeatbelt; + } + #[cfg(target_os = "linux")] + { + return SandboxType::LinuxSeccomp; + } + #[allow(unreachable_code)] + SandboxType::None + } + SandboxablePreference::Auto => match policy { + SandboxPolicy::DangerFullAccess => SandboxType::None, + #[cfg(target_os = "macos")] + _ => SandboxType::MacosSeatbelt, + #[cfg(target_os = "linux")] + _ => SandboxType::LinuxSeccomp, + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + _ => SandboxType::None, + }, + } + } + + pub(crate) fn transform( + &self, + spec: &CommandSpec, + policy: &SandboxPolicy, + sandbox: SandboxType, + sandbox_policy_cwd: &Path, + codex_linux_sandbox_exe: Option<&PathBuf>, + ) -> Result { + let mut env = spec.env.clone(); + if !policy.has_full_network_access() { + env.insert( + CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR.to_string(), + "1".to_string(), + ); + } + + let mut command = Vec::with_capacity(1 + spec.args.len()); + command.push(spec.program.clone()); + command.extend(spec.args.iter().cloned()); + + let (command, sandbox_env, arg0_override) = match sandbox { + SandboxType::None => (command, HashMap::new(), None), + SandboxType::MacosSeatbelt => { + let mut seatbelt_env = HashMap::new(); + seatbelt_env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string()); + let mut args = + create_seatbelt_command_args(command.clone(), policy, sandbox_policy_cwd); + 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, seatbelt_env, None) + } + SandboxType::LinuxSeccomp => { + let exe = codex_linux_sandbox_exe + .ok_or(SandboxTransformError::MissingLinuxSandboxExecutable)?; + let mut args = + create_linux_sandbox_command_args(command.clone(), policy, sandbox_policy_cwd); + let mut full_command = Vec::with_capacity(1 + args.len()); + full_command.push(exe.to_string_lossy().to_string()); + full_command.append(&mut args); + ( + full_command, + HashMap::new(), + Some("codex-linux-sandbox".to_string()), + ) + } + }; + + env.extend(sandbox_env); + + Ok(ExecEnv { + command, + cwd: spec.cwd.clone(), + env, + timeout_ms: spec.timeout_ms, + sandbox, + with_escalated_permissions: spec.with_escalated_permissions, + justification: spec.justification.clone(), + arg0: arg0_override, + }) + } + + pub fn denied(&self, sandbox: SandboxType, out: &ExecToolCallOutput) -> bool { + crate::exec::is_likely_sandbox_denied(sandbox, out) + } +} + +pub async fn execute_env( + env: &ExecEnv, + policy: &SandboxPolicy, + stdout_stream: Option, +) -> crate::error::Result { + execute_exec_env(env.clone(), policy, stdout_stream).await +} diff --git a/codex-rs/core/src/seatbelt.rs b/codex-rs/core/src/seatbelt.rs index 09e93668bc..abd88d41bf 100644 --- a/codex-rs/core/src/seatbelt.rs +++ b/codex-rs/core/src/seatbelt.rs @@ -14,7 +14,7 @@ const MACOS_SEATBELT_BASE_POLICY: &str = include_str!("seatbelt_base_policy.sbpl /// to defend against an attacker trying to inject a malicious version on the /// PATH. If /usr/bin/sandbox-exec has been tampered with, then the attacker /// already has root access. -const MACOS_PATH_TO_SEATBELT_EXECUTABLE: &str = "/usr/bin/sandbox-exec"; +pub(crate) const MACOS_PATH_TO_SEATBELT_EXECUTABLE: &str = "/usr/bin/sandbox-exec"; pub async fn spawn_command_under_seatbelt( command: Vec, @@ -39,7 +39,7 @@ pub async fn spawn_command_under_seatbelt( .await } -fn create_seatbelt_command_args( +pub(crate) fn create_seatbelt_command_args( command: Vec, sandbox_policy: &SandboxPolicy, sandbox_policy_cwd: &Path, diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index ac93d8b12e..9a3f88976e 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -347,6 +347,7 @@ mod tests { )]), with_escalated_permissions: None, justification: None, + arg0: None, }, SandboxType::None, &SandboxPolicy::DangerFullAccess, @@ -455,6 +456,7 @@ mod macos_tests { )]), with_escalated_permissions: None, justification: None, + arg0: None, }, SandboxType::None, &SandboxPolicy::DangerFullAccess, diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index b18f1b9940..ad6f5f90e8 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -2,9 +2,8 @@ use std::sync::Arc; use crate::AuthManager; use crate::RolloutRecorder; -use crate::exec_command::ExecSessionManager; -use crate::executor::Executor; use crate::mcp_connection_manager::McpConnectionManager; +use crate::tools::sandboxing::ApprovalStore; use crate::unified_exec::UnifiedExecSessionManager; use crate::user_notification::UserNotifier; use codex_otel::otel_event_manager::OtelEventManager; @@ -12,13 +11,12 @@ use tokio::sync::Mutex; pub(crate) struct SessionServices { pub(crate) mcp_connection_manager: McpConnectionManager, - pub(crate) session_manager: ExecSessionManager, pub(crate) unified_exec_manager: UnifiedExecSessionManager, pub(crate) notifier: UserNotifier, pub(crate) rollout: Mutex>, pub(crate) user_shell: crate::shell::Shell, pub(crate) show_raw_agent_reasoning: bool, - pub(crate) executor: Executor, pub(crate) auth_manager: Arc, pub(crate) otel_event_manager: OtelEventManager, + pub(crate) tool_approvals: Mutex, } diff --git a/codex-rs/core/src/tools/context.rs b/codex-rs/core/src/tools/context.rs index 7ab4691a55..a262404b05 100644 --- a/codex-rs/core/src/tools/context.rs +++ b/codex-rs/core/src/tools/context.rs @@ -232,6 +232,7 @@ mod tests { } #[derive(Clone, Debug)] +#[allow(dead_code)] pub(crate) struct ExecCommandContext { pub(crate) sub_id: String, pub(crate) call_id: String, @@ -243,6 +244,7 @@ pub(crate) struct ExecCommandContext { } #[derive(Clone, Debug)] +#[allow(dead_code)] pub(crate) struct ApplyPatchCommandContext { pub(crate) user_explicitly_approved_this_action: bool, pub(crate) changes: HashMap, diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs new file mode 100644 index 0000000000..e09f266bbe --- /dev/null +++ b/codex-rs/core/src/tools/events.rs @@ -0,0 +1,235 @@ +use crate::codex::Session; +use crate::exec::ExecToolCallOutput; +use crate::parse_command::parse_command; +use crate::protocol::Event; +use crate::protocol::EventMsg; +use crate::protocol::ExecCommandBeginEvent; +use crate::protocol::ExecCommandEndEvent; +use crate::protocol::FileChange; +use crate::protocol::PatchApplyBeginEvent; +use crate::protocol::PatchApplyEndEvent; +use crate::protocol::TurnDiffEvent; +use crate::tools::context::SharedTurnDiffTracker; +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; + +use super::format_exec_output; +use super::format_exec_output_str; + +#[derive(Clone, Copy)] +pub(crate) struct ToolEventCtx<'a> { + pub session: &'a Session, + pub sub_id: &'a str, + pub call_id: &'a str, + pub turn_diff_tracker: Option<&'a SharedTurnDiffTracker>, +} + +impl<'a> ToolEventCtx<'a> { + pub fn new( + session: &'a Session, + sub_id: &'a str, + call_id: &'a str, + turn_diff_tracker: Option<&'a SharedTurnDiffTracker>, + ) -> Self { + Self { + session, + sub_id, + call_id, + turn_diff_tracker, + } + } +} + +pub(crate) enum ToolEventStage { + Begin, + Success(ExecToolCallOutput), + Failure(ToolEventFailure), +} + +pub(crate) enum ToolEventFailure { + Output(ExecToolCallOutput), + Message(String), +} +// Concrete, allocation-free emitter: avoid trait objects and boxed futures. +pub(crate) enum ToolEmitter { + Shell { + command: Vec, + cwd: PathBuf, + }, + ApplyPatch { + changes: HashMap, + auto_approved: bool, + }, +} + +impl ToolEmitter { + pub fn shell(command: Vec, cwd: PathBuf) -> Self { + Self::Shell { command, cwd } + } + + pub fn apply_patch(changes: HashMap, auto_approved: bool) -> Self { + Self::ApplyPatch { + changes, + auto_approved, + } + } + + pub async fn emit(&self, ctx: ToolEventCtx<'_>, stage: ToolEventStage) { + match (self, stage) { + (Self::Shell { command, cwd }, ToolEventStage::Begin) => { + ctx.session + .send_event(Event { + id: ctx.sub_id.to_string(), + msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: ctx.call_id.to_string(), + command: command.clone(), + cwd: cwd.clone(), + parsed_cmd: parse_command(command), + }), + }) + .await; + } + (Self::Shell { .. }, ToolEventStage::Success(output)) => { + emit_exec_end( + ctx, + output.stdout.text.clone(), + output.stderr.text.clone(), + output.aggregated_output.text.clone(), + output.exit_code, + output.duration, + format_exec_output_str(&output), + ) + .await; + } + (Self::Shell { .. }, ToolEventStage::Failure(ToolEventFailure::Output(output))) => { + emit_exec_end( + ctx, + output.stdout.text.clone(), + output.stderr.text.clone(), + output.aggregated_output.text.clone(), + output.exit_code, + output.duration, + format_exec_output_str(&output), + ) + .await; + } + (Self::Shell { .. }, ToolEventStage::Failure(ToolEventFailure::Message(message))) => { + emit_exec_end( + ctx, + String::new(), + (*message).to_string(), + (*message).to_string(), + -1, + Duration::ZERO, + format_exec_output(&message), + ) + .await; + } + + ( + Self::ApplyPatch { + changes, + auto_approved, + }, + ToolEventStage::Begin, + ) => { + if let Some(tracker) = ctx.turn_diff_tracker { + let mut guard = tracker.lock().await; + guard.on_patch_begin(changes); + } + ctx.session + .send_event(Event { + id: ctx.sub_id.to_string(), + msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent { + call_id: ctx.call_id.to_string(), + auto_approved: *auto_approved, + changes: changes.clone(), + }), + }) + .await; + } + (Self::ApplyPatch { .. }, ToolEventStage::Success(output)) => { + emit_patch_end( + ctx, + output.stdout.text.clone(), + output.stderr.text.clone(), + output.exit_code == 0, + ) + .await; + } + ( + Self::ApplyPatch { .. }, + ToolEventStage::Failure(ToolEventFailure::Output(output)), + ) => { + emit_patch_end( + ctx, + output.stdout.text.clone(), + output.stderr.text.clone(), + output.exit_code == 0, + ) + .await; + } + ( + Self::ApplyPatch { .. }, + ToolEventStage::Failure(ToolEventFailure::Message(message)), + ) => { + emit_patch_end(ctx, String::new(), (*message).to_string(), false).await; + } + } + } +} + +async fn emit_exec_end( + ctx: ToolEventCtx<'_>, + stdout: String, + stderr: String, + aggregated_output: String, + exit_code: i32, + duration: Duration, + formatted_output: String, +) { + ctx.session + .send_event(Event { + id: ctx.sub_id.to_string(), + msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: ctx.call_id.to_string(), + stdout, + stderr, + aggregated_output, + exit_code, + duration, + formatted_output, + }), + }) + .await; +} + +async fn emit_patch_end(ctx: ToolEventCtx<'_>, stdout: String, stderr: String, success: bool) { + ctx.session + .send_event(Event { + id: ctx.sub_id.to_string(), + msg: EventMsg::PatchApplyEnd(PatchApplyEndEvent { + call_id: ctx.call_id.to_string(), + stdout, + stderr, + success, + }), + }) + .await; + + if let Some(tracker) = ctx.turn_diff_tracker { + let unified_diff = { + let mut guard = tracker.lock().await; + guard.get_unified_diff() + }; + if let Ok(Some(unified_diff)) = unified_diff { + ctx.session + .send_event(Event { + id: ctx.sub_id.to_string(), + msg: EventMsg::TurnDiff(TurnDiffEvent { unified_diff }), + }) + .await; + } + } +} diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index d85ac8b76f..4223740c3a 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -8,7 +8,6 @@ use crate::client_common::tools::ResponsesApiTool; use crate::client_common::tools::ToolSpec; use crate::exec::ExecParams; use crate::function_tool::FunctionCallError; -use crate::openai_tools::JsonSchema; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; @@ -16,6 +15,7 @@ use crate::tools::handle_container_exec_with_params; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; use crate::tools::spec::ApplyPatchToolArgs; +use crate::tools::spec::JsonSchema; use async_trait::async_trait; use serde::Deserialize; use serde::Serialize; @@ -72,6 +72,7 @@ impl ToolHandler for ApplyPatchHandler { env: HashMap::new(), with_escalated_permissions: None, justification: None, + arg0: None, }; let content = handle_container_exec_with_params( diff --git a/codex-rs/core/src/tools/handlers/exec_stream.rs b/codex-rs/core/src/tools/handlers/exec_stream.rs deleted file mode 100644 index 7f14c6737a..0000000000 --- a/codex-rs/core/src/tools/handlers/exec_stream.rs +++ /dev/null @@ -1,68 +0,0 @@ -use async_trait::async_trait; - -use crate::exec_command::EXEC_COMMAND_TOOL_NAME; -use crate::exec_command::ExecCommandParams; -use crate::exec_command::WRITE_STDIN_TOOL_NAME; -use crate::exec_command::WriteStdinParams; -use crate::function_tool::FunctionCallError; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolOutput; -use crate::tools::context::ToolPayload; -use crate::tools::registry::ToolHandler; -use crate::tools::registry::ToolKind; - -pub struct ExecStreamHandler; - -#[async_trait] -impl ToolHandler for ExecStreamHandler { - fn kind(&self) -> ToolKind { - ToolKind::Function - } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - tool_name, - payload, - .. - } = invocation; - - let arguments = match payload { - ToolPayload::Function { arguments } => arguments, - _ => { - return Err(FunctionCallError::RespondToModel( - "exec_stream handler received unsupported payload".to_string(), - )); - } - }; - - let content = match tool_name.as_str() { - EXEC_COMMAND_TOOL_NAME => { - let params: ExecCommandParams = serde_json::from_str(&arguments).map_err(|e| { - FunctionCallError::RespondToModel(format!( - "failed to parse function arguments: {e:?}" - )) - })?; - session.handle_exec_command_tool(params).await? - } - WRITE_STDIN_TOOL_NAME => { - let params: WriteStdinParams = serde_json::from_str(&arguments).map_err(|e| { - FunctionCallError::RespondToModel(format!( - "failed to parse function arguments: {e:?}" - )) - })?; - session.handle_write_stdin_tool(params).await? - } - _ => { - return Err(FunctionCallError::RespondToModel(format!( - "exec_stream handler does not support tool {tool_name}" - ))); - } - }; - - Ok(ToolOutput::Function { - content, - success: Some(true), - }) - } -} diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 355e5d607a..187b444414 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -1,5 +1,4 @@ pub mod apply_patch; -mod exec_stream; mod grep_files; mod list_dir; mod mcp; @@ -14,7 +13,6 @@ mod view_image; pub use plan::PLAN_TOOL; pub use apply_patch::ApplyPatchHandler; -pub use exec_stream::ExecStreamHandler; pub use grep_files::GrepFilesHandler; pub use list_dir::ListDirHandler; pub use mcp::McpHandler; diff --git a/codex-rs/core/src/tools/handlers/plan.rs b/codex-rs/core/src/tools/handlers/plan.rs index 386933a52d..9291443f08 100644 --- a/codex-rs/core/src/tools/handlers/plan.rs +++ b/codex-rs/core/src/tools/handlers/plan.rs @@ -2,12 +2,12 @@ use crate::client_common::tools::ResponsesApiTool; use crate::client_common::tools::ToolSpec; use crate::codex::Session; use crate::function_tool::FunctionCallError; -use crate::openai_tools::JsonSchema; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; +use crate::tools::spec::JsonSchema; use async_trait::async_trait; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::Event; diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 1b27a58ef3..168b8cd03e 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -24,6 +24,7 @@ impl ShellHandler { env: create_env(&turn_context.shell_environment_policy), with_escalated_permissions: params.with_escalated_permissions, justification: params.justification, + arg0: None, } } } diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index ce47dded3c..d171a1f7e3 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -35,7 +35,13 @@ impl ToolHandler for UnifiedExecHandler { async fn handle(&self, invocation: ToolInvocation) -> Result { let ToolInvocation { - session, payload, .. + session, + turn, + sub_id, + call_id, + tool_name: _tool_name, + payload, + .. } = invocation; let args = match payload { @@ -73,13 +79,23 @@ impl ToolHandler for UnifiedExecHandler { }; let request = UnifiedExecRequest { - session_id: parsed_session_id, input_chunks: &input, timeout_ms, }; let value = session - .run_unified_exec_request(request) + .services + .unified_exec_manager + .handle_request( + request, + crate::unified_exec::UnifiedExecContext { + session: &session, + turn: turn.as_ref(), + sub_id: &sub_id, + call_id: &call_id, + session_id: parsed_session_id, + }, + ) .await .map_err(|err| { FunctionCallError::RespondToModel(format!("unified exec failed: {err:?}")) diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index 607697e083..c0e9a90fb7 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -1,12 +1,15 @@ pub mod context; +pub mod events; pub(crate) mod handlers; +pub mod orchestrator; pub mod parallel; pub mod registry; pub mod router; +pub mod runtimes; +pub mod sandboxing; pub mod spec; use crate::apply_patch; -use crate::apply_patch::ApplyPatchExec; use crate::apply_patch::InternalApplyPatchInvocation; use crate::apply_patch::convert_apply_patch_to_protocol; use crate::codex::Session; @@ -15,14 +18,19 @@ use crate::error::CodexErr; use crate::error::SandboxErr; use crate::exec::ExecParams; use crate::exec::ExecToolCallOutput; -use crate::exec::StdoutStream; -use crate::executor::ExecutionMode; -use crate::executor::errors::ExecError; -use crate::executor::linkers::PreparedExec; use crate::function_tool::FunctionCallError; -use crate::tools::context::ApplyPatchCommandContext; -use crate::tools::context::ExecCommandContext; use crate::tools::context::SharedTurnDiffTracker; +use crate::tools::events::ToolEmitter; +use crate::tools::events::ToolEventCtx; +use crate::tools::events::ToolEventFailure; +use crate::tools::events::ToolEventStage; +use crate::tools::orchestrator::ToolOrchestrator; +use crate::tools::runtimes::apply_patch::ApplyPatchRequest; +use crate::tools::runtimes::apply_patch::ApplyPatchRuntime; +use crate::tools::runtimes::shell::ShellRequest; +use crate::tools::runtimes::shell::ShellRuntime; +use crate::tools::sandboxing::ToolCtx; +use crate::tools::sandboxing::ToolError; use codex_apply_patch::MaybeApplyPatchVerified; use codex_apply_patch::maybe_parse_apply_patch_verified; use codex_protocol::protocol::AskForApproval; @@ -56,7 +64,7 @@ pub(crate) async fn handle_container_exec_with_params( sub_id: String, call_id: String, ) -> Result { - let otel_event_manager = turn_context.client.get_otel_event_manager(); + let _otel_event_manager = turn_context.client.get_otel_event_manager(); if params.with_escalated_permissions.unwrap_or(false) && !matches!(turn_context.approval_policy, AskForApproval::OnRequest) @@ -100,86 +108,142 @@ pub(crate) async fn handle_container_exec_with_params( MaybeApplyPatchVerified::NotApplyPatch => None, }; - let command_for_display = if let Some(exec) = apply_patch_exec.as_ref() { - vec!["apply_patch".to_string(), exec.action.patch.clone()] - } else { - params.command.clone() - }; - - let exec_command_context = ExecCommandContext { - sub_id: sub_id.clone(), - call_id: call_id.clone(), - command_for_display: command_for_display.clone(), - cwd: params.cwd.clone(), - apply_patch: apply_patch_exec.as_ref().map( - |ApplyPatchExec { - action, - user_explicitly_approved_this_action, - }| ApplyPatchCommandContext { - user_explicitly_approved_this_action: *user_explicitly_approved_this_action, - changes: convert_apply_patch_to_protocol(action), - }, + let (event_emitter, diff_opt) = match apply_patch_exec.as_ref() { + Some(exec) => ( + ToolEmitter::apply_patch( + convert_apply_patch_to_protocol(&exec.action), + !exec.user_explicitly_approved_this_action, + ), + Some(&turn_diff_tracker), + ), + None => ( + ToolEmitter::shell(params.command.clone(), params.cwd.clone()), + None, ), - tool_name: tool_name.to_string(), - otel_event_manager, }; - let mode = match apply_patch_exec { - Some(exec) => ExecutionMode::ApplyPatch(exec), - None => ExecutionMode::Shell, - }; + let event_ctx = ToolEventCtx::new(sess.as_ref(), &sub_id, &call_id, diff_opt); + event_emitter.emit(event_ctx, ToolEventStage::Begin).await; - sess.services.executor.update_environment( - turn_context.sandbox_policy.clone(), - turn_context.cwd.clone(), - ); + // Build runtime contexts only when needed (shell/apply_patch below). - let prepared_exec = PreparedExec::new( - exec_command_context, - params, - command_for_display, - mode, - Some(StdoutStream { + if let Some(exec) = apply_patch_exec { + // Route apply_patch execution through the new orchestrator/runtime. + let req = ApplyPatchRequest { + patch: exec.action.patch.clone(), + cwd: params.cwd.clone(), + timeout_ms: params.timeout_ms, + user_explicitly_approved: exec.user_explicitly_approved_this_action, + codex_exe: turn_context.codex_linux_sandbox_exe.clone(), + }; + + let mut orchestrator = ToolOrchestrator::new(); + let mut runtime = ApplyPatchRuntime::new(); + let tool_ctx = ToolCtx { + session: sess.as_ref(), sub_id: sub_id.clone(), call_id: call_id.clone(), - tx_event: sess.get_tx_event(), - }), - turn_context.shell_environment_policy.use_profile, - ); + tool_name: tool_name.to_string(), + }; - let output_result = sess - .run_exec_with_events( - turn_diff_tracker.clone(), - prepared_exec, - turn_context.approval_policy, - ) - .await; + let out = orchestrator + .run( + &mut runtime, + &req, + &tool_ctx, + &turn_context, + turn_context.approval_policy, + ) + .await; - // always make sure to truncate the output if its length isn't controlled. - match output_result { + handle_exec_outcome(&event_emitter, event_ctx, out).await + } else { + // Route shell execution through the new orchestrator/runtime. + let req = ShellRequest { + command: params.command.clone(), + cwd: params.cwd.clone(), + timeout_ms: params.timeout_ms, + env: params.env.clone(), + with_escalated_permissions: params.with_escalated_permissions, + justification: params.justification.clone(), + }; + + let mut orchestrator = ToolOrchestrator::new(); + let mut runtime = ShellRuntime::new(); + let tool_ctx = ToolCtx { + session: sess.as_ref(), + sub_id: sub_id.clone(), + call_id: call_id.clone(), + tool_name: tool_name.to_string(), + }; + + let out = orchestrator + .run( + &mut runtime, + &req, + &tool_ctx, + &turn_context, + turn_context.approval_policy, + ) + .await; + + handle_exec_outcome(&event_emitter, event_ctx, out).await + } +} + +async fn handle_exec_outcome( + event_emitter: &ToolEmitter, + event_ctx: ToolEventCtx<'_>, + out: Result, +) -> Result { + let event; + let result = match out { Ok(output) => { - let ExecToolCallOutput { exit_code, .. } = &output; - let content = format_exec_output_apply_patch(&output); - if *exit_code == 0 { + let content = format_exec_output_for_model(&output); + let exit_code = output.exit_code; + event = ToolEventStage::Success(output); + if exit_code == 0 { Ok(content) } else { Err(FunctionCallError::RespondToModel(content)) } } - Err(ExecError::Function(err)) => Err(truncate_function_error(err)), - Err(ExecError::Codex(CodexErr::Sandbox(SandboxErr::Timeout { output }))) => Err( - FunctionCallError::RespondToModel(format_exec_output_apply_patch(&output)), - ), - Err(ExecError::Codex(err)) => { + Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Timeout { output }))) + | Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output }))) => { + let response = format_exec_output_for_model(&output); + event = ToolEventStage::Failure(ToolEventFailure::Output(*output)); + Err(FunctionCallError::RespondToModel(response)) + } + Err(ToolError::Codex(err)) => { let message = format!("execution error: {err:?}"); + let response = format_exec_output(&message); + event = ToolEventStage::Failure(ToolEventFailure::Message(message)); Err(FunctionCallError::RespondToModel(format_exec_output( - &message, + &response, ))) } - } + Err(ToolError::Rejected(msg)) | Err(ToolError::SandboxDenied(msg)) => { + // Normalize common rejection messages for exec tools so tests and + // users see a clear, consistent phrase. + let normalized = if msg == "rejected by user" { + "exec command rejected by user".to_string() + } else { + msg + }; + let response = format_exec_output(&normalized); + event = ToolEventStage::Failure(ToolEventFailure::Message(normalized)); + Err(FunctionCallError::RespondToModel(format_exec_output( + &response, + ))) + } + }; + event_emitter.emit(event_ctx, event).await; + result } -pub fn format_exec_output_apply_patch(exec_output: &ExecToolCallOutput) -> String { +/// Format the combined exec output for sending back to the model. +/// Includes exit code and duration metadata; truncates large bodies safely. +pub fn format_exec_output_for_model(exec_output: &ExecToolCallOutput) -> String { let ExecToolCallOutput { exit_code, duration, @@ -233,18 +297,7 @@ pub fn format_exec_output_str(exec_output: &ExecToolCallOutput) -> String { format_exec_output(content) } -fn truncate_function_error(err: FunctionCallError) -> FunctionCallError { - match err { - FunctionCallError::RespondToModel(msg) => { - FunctionCallError::RespondToModel(format_exec_output(&msg)) - } - FunctionCallError::Denied(msg) => FunctionCallError::Denied(format_exec_output(&msg)), - FunctionCallError::Fatal(msg) => FunctionCallError::Fatal(format_exec_output(&msg)), - other => other, - } -} - -fn format_exec_output(content: &str) -> String { +pub(super) fn format_exec_output(content: &str) -> String { // Head+tail truncation for the model: show the beginning and end with an elision. // Clients still receive full streams; only this formatted summary is capped. let total_lines = content.lines().count(); @@ -315,6 +368,17 @@ mod tests { use super::*; use regex_lite::Regex; + fn truncate_function_error(err: FunctionCallError) -> FunctionCallError { + match err { + FunctionCallError::RespondToModel(msg) => { + FunctionCallError::RespondToModel(format_exec_output(&msg)) + } + FunctionCallError::Denied(msg) => FunctionCallError::Denied(format_exec_output(&msg)), + FunctionCallError::Fatal(msg) => FunctionCallError::Fatal(format_exec_output(&msg)), + other => other, + } + } + fn assert_truncated_message_matches(message: &str, line: &str, total_lines: usize) { let pattern = truncated_message_pattern(line, total_lines); let regex = Regex::new(&pattern).unwrap_or_else(|err| { diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs new file mode 100644 index 0000000000..4d06163323 --- /dev/null +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -0,0 +1,172 @@ +/* +Module: orchestrator + +Central place for approvals + sandbox selection + retry semantics. Drives a +simple sequence for any ToolRuntime: approval → select sandbox → attempt → +retry without sandbox on denial (no re‑approval thanks to caching). +*/ +use crate::error::CodexErr; +use crate::error::SandboxErr; +use crate::exec::ExecToolCallOutput; +use crate::sandboxing::SandboxManager; +use crate::tools::sandboxing::ApprovalCtx; +use crate::tools::sandboxing::SandboxAttempt; +use crate::tools::sandboxing::ToolCtx; +use crate::tools::sandboxing::ToolError; +use crate::tools::sandboxing::ToolRuntime; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::ReviewDecision; + +pub(crate) struct ToolOrchestrator { + sandbox: SandboxManager, +} + +impl ToolOrchestrator { + pub fn new() -> Self { + Self { + sandbox: SandboxManager::new(), + } + } + + pub async fn run( + &mut self, + tool: &mut T, + req: &Rq, + tool_ctx: &ToolCtx<'_>, + turn_ctx: &crate::codex::TurnContext, + approval_policy: AskForApproval, + ) -> Result + where + T: ToolRuntime, + { + let otel = turn_ctx.client.get_otel_event_manager(); + let otel_tn = &tool_ctx.tool_name; + let otel_ci = &tool_ctx.call_id; + let otel_user = codex_otel::otel_event_manager::ToolDecisionSource::User; + let otel_cfg = codex_otel::otel_event_manager::ToolDecisionSource::Config; + + // 1) Approval + let needs_initial_approval = + tool.wants_initial_approval(req, approval_policy, &turn_ctx.sandbox_policy); + let mut already_approved = false; + + if needs_initial_approval { + let approval_ctx = ApprovalCtx { + session: tool_ctx.session, + sub_id: &tool_ctx.sub_id, + call_id: &tool_ctx.call_id, + retry_reason: None, + }; + let decision = tool.start_approval_async(req, approval_ctx).await; + + otel.tool_decision(otel_tn, otel_ci, decision, otel_user.clone()); + + match decision { + ReviewDecision::Denied | ReviewDecision::Abort => { + return Err(ToolError::Rejected("rejected by user".to_string())); + } + ReviewDecision::Approved | ReviewDecision::ApprovedForSession => {} + } + already_approved = true; + } else { + otel.tool_decision(otel_tn, otel_ci, ReviewDecision::Approved, otel_cfg); + } + + // 2) First attempt under the selected sandbox. + let mut initial_sandbox = self + .sandbox + .select_initial(&turn_ctx.sandbox_policy, tool.sandbox_preference()); + if tool.wants_escalated_first_attempt(req) { + initial_sandbox = crate::exec::SandboxType::None; + } + let initial_attempt = SandboxAttempt { + sandbox: initial_sandbox, + policy: &turn_ctx.sandbox_policy, + manager: &self.sandbox, + sandbox_cwd: &turn_ctx.cwd, + codex_linux_sandbox_exe: turn_ctx.codex_linux_sandbox_exe.as_ref(), + }; + + match tool.run(req, &initial_attempt, tool_ctx).await { + Ok(out) => { + // We have a successful initial result + Ok(out) + } + Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output }))) => { + if !tool.escalate_on_failure() { + return Err(ToolError::SandboxDenied( + "sandbox denied and no retry".to_string(), + )); + } + // Under `Never`, do not retry without sandbox; surface a concise message + // derived from the actual output (platform-agnostic). + if matches!(approval_policy, AskForApproval::Never) { + let msg = build_never_denied_message_from_output(output.as_ref()); + return Err(ToolError::SandboxDenied(msg)); + } + + // Ask for approval before retrying without sandbox. + if !tool.should_bypass_approval(approval_policy, already_approved) { + let reason_msg = build_denial_reason_from_output(output.as_ref()); + let approval_ctx = ApprovalCtx { + session: tool_ctx.session, + sub_id: &tool_ctx.sub_id, + call_id: &tool_ctx.call_id, + retry_reason: Some(reason_msg), + }; + + let decision = tool.start_approval_async(req, approval_ctx).await; + otel.tool_decision(otel_tn, otel_ci, decision, otel_user); + + match decision { + ReviewDecision::Denied | ReviewDecision::Abort => { + return Err(ToolError::Rejected("rejected by user".to_string())); + } + ReviewDecision::Approved | ReviewDecision::ApprovedForSession => {} + } + } + + let escalated_attempt = SandboxAttempt { + sandbox: crate::exec::SandboxType::None, + policy: &turn_ctx.sandbox_policy, + manager: &self.sandbox, + sandbox_cwd: &turn_ctx.cwd, + codex_linux_sandbox_exe: None, + }; + + // Second attempt. + (*tool).run(req, &escalated_attempt, tool_ctx).await + } + other => other, + } + } +} + +fn build_never_denied_message_from_output(output: &ExecToolCallOutput) -> String { + let body = format!( + "{}\n{}\n{}", + output.stderr.text, output.stdout.text, output.aggregated_output.text + ) + .to_lowercase(); + + let detail = if body.contains("permission denied") { + Some("Permission denied") + } else if body.contains("operation not permitted") { + Some("Operation not permitted") + } else if body.contains("read-only file system") { + Some("Read-only file system") + } else { + None + }; + + match detail { + Some(tag) => format!("failed in sandbox: {tag}"), + None => "failed in sandbox".to_string(), + } +} + +fn build_denial_reason_from_output(_output: &ExecToolCallOutput) -> String { + // Keep approval reason terse and stable for UX/tests, but accept the + // output so we can evolve heuristics later without touching call sites. + "command failed; retry without sandbox?".to_string() +} diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs new file mode 100644 index 0000000000..f6062a3a9c --- /dev/null +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -0,0 +1,148 @@ +//! Apply Patch runtime: executes verified patches under the orchestrator. +//! +//! Assumes `apply_patch` verification/approval happened upstream. Reuses that +//! decision to avoid re-prompting, builds the self-invocation command for +//! `codex --codex-run-as-apply-patch`, and runs under the current +//! `SandboxAttempt` with a minimal environment. +use crate::CODEX_APPLY_PATCH_ARG1; +use crate::exec::ExecToolCallOutput; +use crate::sandboxing::CommandSpec; +use crate::sandboxing::execute_env; +use crate::tools::sandboxing::Approvable; +use crate::tools::sandboxing::ApprovalCtx; +use crate::tools::sandboxing::SandboxAttempt; +use crate::tools::sandboxing::Sandboxable; +use crate::tools::sandboxing::SandboxablePreference; +use crate::tools::sandboxing::ToolCtx; +use crate::tools::sandboxing::ToolError; +use crate::tools::sandboxing::ToolRuntime; +use crate::tools::sandboxing::with_cached_approval; +use codex_protocol::protocol::ReviewDecision; +use futures::future::BoxFuture; +use std::collections::HashMap; +use std::path::PathBuf; + +#[derive(Clone, Debug)] +pub struct ApplyPatchRequest { + pub patch: String, + pub cwd: PathBuf, + pub timeout_ms: Option, + pub user_explicitly_approved: bool, + pub codex_exe: Option, +} + +#[derive(Default)] +pub struct ApplyPatchRuntime; + +#[derive(serde::Serialize, Clone, Debug, Eq, PartialEq, Hash)] +pub(crate) struct ApprovalKey { + patch: String, + cwd: PathBuf, +} + +impl ApplyPatchRuntime { + pub fn new() -> Self { + Self + } + + fn build_command_spec(req: &ApplyPatchRequest) -> Result { + use std::env; + let exe = if let Some(path) = &req.codex_exe { + path.clone() + } else { + env::current_exe() + .map_err(|e| ToolError::Rejected(format!("failed to determine codex exe: {e}")))? + }; + let program = exe.to_string_lossy().to_string(); + Ok(CommandSpec { + program, + args: vec![CODEX_APPLY_PATCH_ARG1.to_string(), req.patch.clone()], + cwd: req.cwd.clone(), + timeout_ms: req.timeout_ms, + // Run apply_patch with a minimal environment for determinism and to avoid leaks. + env: HashMap::new(), + with_escalated_permissions: None, + justification: None, + }) + } + + fn stdout_stream(ctx: &ToolCtx<'_>) -> Option { + Some(crate::exec::StdoutStream { + sub_id: ctx.sub_id.clone(), + call_id: ctx.call_id.clone(), + tx_event: ctx.session.get_tx_event(), + }) + } +} + +impl Sandboxable for ApplyPatchRuntime { + fn sandbox_preference(&self) -> SandboxablePreference { + SandboxablePreference::Auto + } + fn escalate_on_failure(&self) -> bool { + true + } +} + +impl Approvable for ApplyPatchRuntime { + type ApprovalKey = ApprovalKey; + + fn approval_key(&self, req: &ApplyPatchRequest) -> Self::ApprovalKey { + ApprovalKey { + patch: req.patch.clone(), + cwd: req.cwd.clone(), + } + } + + fn start_approval_async<'a>( + &'a mut self, + req: &'a ApplyPatchRequest, + ctx: ApprovalCtx<'a>, + ) -> BoxFuture<'a, ReviewDecision> { + let key = self.approval_key(req); + let session = ctx.session; + let sub_id = ctx.sub_id.to_string(); + let call_id = ctx.call_id.to_string(); + let cwd = req.cwd.clone(); + let retry_reason = ctx.retry_reason.clone(); + let user_explicitly_approved = req.user_explicitly_approved; + Box::pin(async move { + with_cached_approval(&session.services, key, || async move { + if let Some(reason) = retry_reason { + session + .request_command_approval( + sub_id, + call_id, + vec!["apply_patch".to_string()], + cwd, + Some(reason), + ) + .await + } else if user_explicitly_approved { + ReviewDecision::ApprovedForSession + } else { + ReviewDecision::Approved + } + }) + .await + }) + } +} + +impl ToolRuntime for ApplyPatchRuntime { + async fn run( + &mut self, + req: &ApplyPatchRequest, + attempt: &SandboxAttempt<'_>, + ctx: &ToolCtx<'_>, + ) -> Result { + let spec = Self::build_command_spec(req)?; + let env = attempt + .env_for(&spec) + .map_err(|err| ToolError::Codex(err.into()))?; + let out = execute_env(&env, attempt.policy, Self::stdout_stream(ctx)) + .await + .map_err(ToolError::Codex)?; + Ok(out) + } +} diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs new file mode 100644 index 0000000000..212163d72c --- /dev/null +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -0,0 +1,38 @@ +/* +Module: runtimes + +Concrete ToolRuntime implementations for specific tools. Each runtime stays +small and focused and reuses the orchestrator for approvals + sandbox + retry. +*/ +use crate::sandboxing::CommandSpec; +use crate::tools::sandboxing::ToolError; +use std::collections::HashMap; +use std::path::Path; + +pub mod apply_patch; +pub mod shell; +pub mod unified_exec; + +/// Shared helper to construct a CommandSpec from a tokenized command line. +/// Validates that at least a program is present. +pub(crate) fn build_command_spec( + command: &[String], + cwd: &Path, + env: &HashMap, + timeout_ms: Option, + with_escalated_permissions: Option, + justification: Option, +) -> Result { + let (program, args) = command + .split_first() + .ok_or_else(|| ToolError::Rejected("command args are empty".to_string()))?; + Ok(CommandSpec { + program: program.clone(), + args: args.to_vec(), + cwd: cwd.to_path_buf(), + env: env.clone(), + timeout_ms, + with_escalated_permissions, + justification, + }) +} diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs new file mode 100644 index 0000000000..313e9e0700 --- /dev/null +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -0,0 +1,164 @@ +/* +Runtime: shell + +Executes shell requests under the orchestrator: asks for approval when needed, +builds a CommandSpec, and runs it under the current SandboxAttempt. +*/ +use crate::command_safety::is_dangerous_command::command_might_be_dangerous; +use crate::command_safety::is_safe_command::is_known_safe_command; +use crate::exec::ExecToolCallOutput; +use crate::protocol::SandboxPolicy; +use crate::sandboxing::execute_env; +use crate::tools::runtimes::build_command_spec; +use crate::tools::sandboxing::Approvable; +use crate::tools::sandboxing::ApprovalCtx; +use crate::tools::sandboxing::SandboxAttempt; +use crate::tools::sandboxing::Sandboxable; +use crate::tools::sandboxing::SandboxablePreference; +use crate::tools::sandboxing::ToolCtx; +use crate::tools::sandboxing::ToolError; +use crate::tools::sandboxing::ToolRuntime; +use crate::tools::sandboxing::with_cached_approval; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::ReviewDecision; +use futures::future::BoxFuture; +use std::path::PathBuf; + +#[derive(Clone, Debug)] +pub struct ShellRequest { + pub command: Vec, + pub cwd: PathBuf, + pub timeout_ms: Option, + pub env: std::collections::HashMap, + pub with_escalated_permissions: Option, + pub justification: Option, +} + +#[derive(Default)] +pub struct ShellRuntime; + +#[derive(serde::Serialize, Clone, Debug, Eq, PartialEq, Hash)] +pub(crate) struct ApprovalKey { + command: Vec, + cwd: PathBuf, + escalated: bool, +} + +impl ShellRuntime { + pub fn new() -> Self { + Self + } + + fn stdout_stream(ctx: &ToolCtx<'_>) -> Option { + Some(crate::exec::StdoutStream { + sub_id: ctx.sub_id.clone(), + call_id: ctx.call_id.clone(), + tx_event: ctx.session.get_tx_event(), + }) + } +} + +impl Sandboxable for ShellRuntime { + fn sandbox_preference(&self) -> SandboxablePreference { + SandboxablePreference::Auto + } + fn escalate_on_failure(&self) -> bool { + true + } +} + +impl Approvable for ShellRuntime { + type ApprovalKey = ApprovalKey; + + fn approval_key(&self, req: &ShellRequest) -> Self::ApprovalKey { + ApprovalKey { + command: req.command.clone(), + cwd: req.cwd.clone(), + escalated: req.with_escalated_permissions.unwrap_or(false), + } + } + + fn start_approval_async<'a>( + &'a mut self, + req: &'a ShellRequest, + ctx: ApprovalCtx<'a>, + ) -> BoxFuture<'a, ReviewDecision> { + let key = self.approval_key(req); + let command = req.command.clone(); + let cwd = req.cwd.clone(); + let reason = ctx + .retry_reason + .clone() + .or_else(|| req.justification.clone()); + let session = ctx.session; + let sub_id = ctx.sub_id.to_string(); + let call_id = ctx.call_id.to_string(); + Box::pin(async move { + with_cached_approval(&session.services, key, || async move { + session + .request_command_approval(sub_id, call_id, command, cwd, reason) + .await + }) + .await + }) + } + + fn wants_initial_approval( + &self, + req: &ShellRequest, + policy: AskForApproval, + sandbox_policy: &SandboxPolicy, + ) -> bool { + if is_known_safe_command(&req.command) { + return false; + } + match policy { + AskForApproval::Never | AskForApproval::OnFailure => false, + AskForApproval::OnRequest => { + // In DangerFullAccess, only prompt if the command looks dangerous. + if matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) { + return command_might_be_dangerous(&req.command); + } + + // In restricted sandboxes (ReadOnly/WorkspaceWrite), do not prompt for + // non‑escalated, non‑dangerous commands — let the sandbox enforce + // restrictions (e.g., block network/write) without a user prompt. + let wants_escalation = req.with_escalated_permissions.unwrap_or(false); + if wants_escalation { + return true; + } + command_might_be_dangerous(&req.command) + } + AskForApproval::UnlessTrusted => !is_known_safe_command(&req.command), + } + } + + fn wants_escalated_first_attempt(&self, req: &ShellRequest) -> bool { + req.with_escalated_permissions.unwrap_or(false) + } +} + +impl ToolRuntime for ShellRuntime { + async fn run( + &mut self, + req: &ShellRequest, + attempt: &SandboxAttempt<'_>, + ctx: &ToolCtx<'_>, + ) -> Result { + let spec = build_command_spec( + &req.command, + &req.cwd, + &req.env, + req.timeout_ms, + req.with_escalated_permissions, + req.justification.clone(), + )?; + let env = attempt + .env_for(&spec) + .map_err(|err| ToolError::Codex(err.into()))?; + let out = execute_env(&env, attempt.policy, Self::stdout_stream(ctx)) + .await + .map_err(ToolError::Codex)?; + Ok(out) + } +} diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs new file mode 100644 index 0000000000..e642105551 --- /dev/null +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -0,0 +1,123 @@ +/* +Runtime: unified exec + +Handles approval + sandbox orchestration for unified exec requests, delegating to +the session manager to spawn PTYs once an ExecEnv is prepared. +*/ +use crate::error::CodexErr; +use crate::error::SandboxErr; +use crate::tools::runtimes::build_command_spec; +use crate::tools::sandboxing::Approvable; +use crate::tools::sandboxing::ApprovalCtx; +use crate::tools::sandboxing::SandboxAttempt; +use crate::tools::sandboxing::Sandboxable; +use crate::tools::sandboxing::SandboxablePreference; +use crate::tools::sandboxing::ToolCtx; +use crate::tools::sandboxing::ToolError; +use crate::tools::sandboxing::ToolRuntime; +use crate::tools::sandboxing::with_cached_approval; +use crate::unified_exec::UnifiedExecError; +use crate::unified_exec::UnifiedExecSession; +use crate::unified_exec::UnifiedExecSessionManager; +use codex_protocol::protocol::ReviewDecision; +use futures::future::BoxFuture; +use std::collections::HashMap; +use std::path::PathBuf; + +#[derive(Clone, Debug)] +pub struct UnifiedExecRequest { + pub command: Vec, + pub cwd: PathBuf, + pub env: HashMap, +} + +#[derive(serde::Serialize, Clone, Debug, Eq, PartialEq, Hash)] +pub struct UnifiedExecApprovalKey { + pub command: Vec, + pub cwd: PathBuf, +} + +pub struct UnifiedExecRuntime<'a> { + manager: &'a UnifiedExecSessionManager, +} + +impl UnifiedExecRequest { + pub fn new(command: Vec, cwd: PathBuf, env: HashMap) -> Self { + Self { command, cwd, env } + } +} + +impl<'a> UnifiedExecRuntime<'a> { + pub fn new(manager: &'a UnifiedExecSessionManager) -> Self { + Self { manager } + } +} + +impl Sandboxable for UnifiedExecRuntime<'_> { + fn sandbox_preference(&self) -> SandboxablePreference { + SandboxablePreference::Auto + } + + fn escalate_on_failure(&self) -> bool { + true + } +} + +impl Approvable for UnifiedExecRuntime<'_> { + type ApprovalKey = UnifiedExecApprovalKey; + + fn approval_key(&self, req: &UnifiedExecRequest) -> Self::ApprovalKey { + UnifiedExecApprovalKey { + command: req.command.clone(), + cwd: req.cwd.clone(), + } + } + + fn start_approval_async<'b>( + &'b mut self, + req: &'b UnifiedExecRequest, + ctx: ApprovalCtx<'b>, + ) -> BoxFuture<'b, ReviewDecision> { + let key = self.approval_key(req); + let session = ctx.session; + let sub_id = ctx.sub_id.to_string(); + let call_id = ctx.call_id.to_string(); + let command = req.command.clone(); + let cwd = req.cwd.clone(); + let reason = ctx.retry_reason.clone(); + Box::pin(async move { + with_cached_approval(&session.services, key, || async move { + session + .request_command_approval(sub_id, call_id, command, cwd, reason) + .await + }) + .await + }) + } +} + +impl<'a> ToolRuntime for UnifiedExecRuntime<'a> { + async fn run( + &mut self, + req: &UnifiedExecRequest, + attempt: &SandboxAttempt<'_>, + _ctx: &ToolCtx<'_>, + ) -> Result { + let spec = build_command_spec(&req.command, &req.cwd, &req.env, None, None, None) + .map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?; + let exec_env = attempt + .env_for(&spec) + .map_err(|err| ToolError::Codex(err.into()))?; + self.manager + .open_session_with_exec_env(&exec_env) + .await + .map_err(|err| match err { + UnifiedExecError::SandboxDenied { output, .. } => { + ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { + output: Box::new(output), + })) + } + other => ToolError::Rejected(other.to_string()), + }) + } +} diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs new file mode 100644 index 0000000000..ed142da46c --- /dev/null +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -0,0 +1,190 @@ +//! Shared approvals and sandboxing traits used by tool runtimes. +//! +//! Consolidates the approval flow primitives (`ApprovalDecision`, `ApprovalStore`, +//! `ApprovalCtx`, `Approvable`) together with the sandbox orchestration traits +//! and helpers (`Sandboxable`, `ToolRuntime`, `SandboxAttempt`, etc.). + +use crate::codex::Session; +use crate::error::CodexErr; +use crate::protocol::SandboxPolicy; +use crate::sandboxing::CommandSpec; +use crate::sandboxing::SandboxManager; +use crate::sandboxing::SandboxTransformError; +use crate::state::SessionServices; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::ReviewDecision; +use std::collections::HashMap; +use std::fmt::Debug; +use std::hash::Hash; +use std::path::Path; + +use futures::Future; +use futures::future::BoxFuture; +use serde::Serialize; + +#[derive(Clone, Default, Debug)] +pub(crate) struct ApprovalStore { + // Store serialized keys for generic caching across requests. + map: HashMap, +} + +impl ApprovalStore { + pub fn get(&self, key: &K) -> Option + where + K: Serialize, + { + let s = serde_json::to_string(key).ok()?; + self.map.get(&s).cloned() + } + + pub fn put(&mut self, key: K, value: ReviewDecision) + where + K: Serialize, + { + if let Ok(s) = serde_json::to_string(&key) { + self.map.insert(s, value); + } + } +} + +pub(crate) async fn with_cached_approval( + services: &SessionServices, + key: K, + fetch: F, +) -> ReviewDecision +where + K: Serialize + Clone, + F: FnOnce() -> Fut, + Fut: Future, +{ + { + let store = services.tool_approvals.lock().await; + if let Some(decision) = store.get(&key) { + return decision; + } + } + + let decision = fetch().await; + + if matches!(decision, ReviewDecision::ApprovedForSession) { + let mut store = services.tool_approvals.lock().await; + store.put(key, ReviewDecision::ApprovedForSession); + } + + decision +} + +#[derive(Clone)] +pub(crate) struct ApprovalCtx<'a> { + pub session: &'a Session, + pub sub_id: &'a str, + pub call_id: &'a str, + pub retry_reason: Option, +} + +pub(crate) trait Approvable { + type ApprovalKey: Hash + Eq + Clone + Debug + Serialize; + + fn approval_key(&self, req: &Req) -> Self::ApprovalKey; + + /// Some tools may request to skip the sandbox on the first attempt + /// (e.g., when the request explicitly asks for escalated permissions). + /// Defaults to `false`. + fn wants_escalated_first_attempt(&self, _req: &Req) -> bool { + false + } + + fn should_bypass_approval(&self, policy: AskForApproval, already_approved: bool) -> bool { + if already_approved { + // We do not ask one more time + return true; + } + matches!(policy, AskForApproval::Never) + } + + /// Decide whether an initial user approval should be requested before the + /// first attempt. Defaults to the orchestrator's behavior (pre‑refactor): + /// - Never, OnFailure: do not ask + /// - OnRequest: ask unless sandbox policy is DangerFullAccess + /// - UnlessTrusted: always ask + fn wants_initial_approval( + &self, + _req: &Req, + policy: AskForApproval, + sandbox_policy: &SandboxPolicy, + ) -> bool { + match policy { + AskForApproval::Never | AskForApproval::OnFailure => false, + AskForApproval::OnRequest => !matches!(sandbox_policy, SandboxPolicy::DangerFullAccess), + AskForApproval::UnlessTrusted => true, + } + } + + fn start_approval_async<'a>( + &'a mut self, + req: &'a Req, + ctx: ApprovalCtx<'a>, + ) -> BoxFuture<'a, ReviewDecision>; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SandboxablePreference { + Auto, + #[allow(dead_code)] // Will be used by later tools. + Require, + #[allow(dead_code)] // Will be used by later tools. + Forbid, +} + +pub(crate) trait Sandboxable { + fn sandbox_preference(&self) -> SandboxablePreference; + fn escalate_on_failure(&self) -> bool { + true + } +} + +pub(crate) struct ToolCtx<'a> { + pub session: &'a Session, + pub sub_id: String, + pub call_id: String, + pub tool_name: String, +} + +#[derive(Debug)] +pub(crate) enum ToolError { + Rejected(String), + SandboxDenied(String), + Codex(CodexErr), +} + +pub(crate) trait ToolRuntime: Approvable + Sandboxable { + async fn run( + &mut self, + req: &Req, + attempt: &SandboxAttempt<'_>, + ctx: &ToolCtx, + ) -> Result; +} + +pub(crate) struct SandboxAttempt<'a> { + pub sandbox: crate::exec::SandboxType, + pub policy: &'a crate::protocol::SandboxPolicy, + pub(crate) manager: &'a SandboxManager, + pub(crate) sandbox_cwd: &'a Path, + pub codex_linux_sandbox_exe: Option<&'a std::path::PathBuf>, +} + +impl<'a> SandboxAttempt<'a> { + pub fn env_for( + &self, + spec: &CommandSpec, + ) -> Result { + self.manager.transform( + spec, + self.policy, + self.sandbox, + self.sandbox_cwd, + self.codex_linux_sandbox_exe, + ) + } +} diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 5352051917..c70da247ba 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -815,12 +815,7 @@ pub(crate) fn build_specs( config: &ToolsConfig, mcp_tools: Option>, ) -> ToolRegistryBuilder { - use crate::exec_command::EXEC_COMMAND_TOOL_NAME; - use crate::exec_command::WRITE_STDIN_TOOL_NAME; - use crate::exec_command::create_exec_command_tool_for_responses_api; - use crate::exec_command::create_write_stdin_tool_for_responses_api; use crate::tools::handlers::ApplyPatchHandler; - use crate::tools::handlers::ExecStreamHandler; use crate::tools::handlers::GrepFilesHandler; use crate::tools::handlers::ListDirHandler; use crate::tools::handlers::McpHandler; @@ -836,7 +831,6 @@ pub(crate) fn build_specs( let mut builder = ToolRegistryBuilder::new(); let shell_handler = Arc::new(ShellHandler); - let exec_stream_handler = Arc::new(ExecStreamHandler); let unified_exec_handler = Arc::new(UnifiedExecHandler); let plan_handler = Arc::new(PlanHandler); let apply_patch_handler = Arc::new(ApplyPatchHandler); @@ -844,7 +838,10 @@ pub(crate) fn build_specs( let mcp_handler = Arc::new(McpHandler); let mcp_resource_handler = Arc::new(McpResourceHandler); - if config.experimental_unified_exec_tool { + let use_unified_exec = config.experimental_unified_exec_tool + || matches!(config.shell_type, ConfigShellToolType::Streamable); + + if use_unified_exec { builder.push_spec(create_unified_exec_tool()); builder.register_handler("unified_exec", unified_exec_handler); } else { @@ -856,14 +853,7 @@ pub(crate) fn build_specs( builder.push_spec(ToolSpec::LocalShell {}); } ConfigShellToolType::Streamable => { - builder.push_spec(ToolSpec::Function( - create_exec_command_tool_for_responses_api(), - )); - builder.push_spec(ToolSpec::Function( - create_write_stdin_tool_for_responses_api(), - )); - builder.register_handler(EXEC_COMMAND_TOOL_NAME, exec_stream_handler.clone()); - builder.register_handler(WRITE_STDIN_TOOL_NAME, exec_stream_handler); + // Already handled by use_unified_exec. } } } diff --git a/codex-rs/core/src/unified_exec/errors.rs b/codex-rs/core/src/unified_exec/errors.rs index 6bf5bf7ec5..bfbef93572 100644 --- a/codex-rs/core/src/unified_exec/errors.rs +++ b/codex-rs/core/src/unified_exec/errors.rs @@ -1,22 +1,29 @@ +use crate::exec::ExecToolCallOutput; use thiserror::Error; #[derive(Debug, Error)] pub(crate) enum UnifiedExecError { - #[error("Failed to create unified exec session: {pty_error}")] - CreateSession { - #[source] - pty_error: anyhow::Error, - }, + #[error("Failed to create unified exec session: {message}")] + CreateSession { message: String }, #[error("Unknown session id {session_id}")] UnknownSessionId { session_id: i32 }, #[error("failed to write to stdin")] WriteToStdin, #[error("missing command line for unified exec request")] MissingCommandLine, + #[error("Command denied by sandbox: {message}")] + SandboxDenied { + message: String, + output: ExecToolCallOutput, + }, } impl UnifiedExecError { - pub(crate) fn create_session(error: anyhow::Error) -> Self { - Self::CreateSession { pty_error: error } + pub(crate) fn create_session(message: String) -> Self { + Self::CreateSession { message } + } + + pub(crate) fn sandbox_denied(message: String, output: ExecToolCallOutput) -> Self { + Self::SandboxDenied { message, output } } } diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index a8f754c92e..fb791fe89f 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -1,36 +1,55 @@ -use portable_pty::CommandBuilder; -use portable_pty::PtySize; -use portable_pty::native_pty_system; -use std::collections::HashMap; -use std::collections::VecDeque; -use std::io::ErrorKind; -use std::io::Read; -use std::sync::Arc; -use std::sync::Mutex as StdMutex; -use std::sync::atomic::AtomicBool; -use std::sync::atomic::AtomicI32; -use std::sync::atomic::Ordering; -use tokio::sync::Mutex; -use tokio::sync::Notify; -use tokio::sync::mpsc; -use tokio::task::JoinHandle; -use tokio::time::Duration; -use tokio::time::Instant; +//! Unified Exec: interactive PTY execution orchestrated with approvals + sandboxing. +//! +//! Responsibilities +//! - Manages interactive PTY sessions (create, reuse, buffer output with caps). +//! - Uses the shared ToolOrchestrator to handle approval, sandbox selection, and +//! retry semantics in a single, descriptive flow. +//! - Spawns the PTY from a sandbox‑transformed `ExecEnv`; on sandbox denial, +//! retries without sandbox when policy allows (no re‑prompt thanks to caching). +//! - Uses the shared `is_likely_sandbox_denied` heuristic to keep denial messages +//! consistent with other exec paths. +//! +//! Flow at a glance (open session) +//! 1) Build a small request `{ command, cwd }`. +//! 2) Orchestrator: approval (bypass/cache/prompt) → select sandbox → run. +//! 3) Runtime: transform `CommandSpec` → `ExecEnv` → spawn PTY. +//! 4) If denial, orchestrator retries with `SandboxType::None`. +//! 5) Session is returned with streaming output + metadata. +//! +//! This keeps policy logic and user interaction centralized while the PTY/session +//! concerns remain isolated here. The implementation is split between: +//! - `session.rs`: PTY session lifecycle + output buffering. +//! - `session_manager.rs`: orchestration (approvals, sandboxing, reuse) and request handling. -use crate::exec_command::ExecCommandSession; -use crate::truncate::truncate_middle; +use std::collections::HashMap; +use std::sync::atomic::AtomicI32; + +use tokio::sync::Mutex; + +use crate::codex::Session; +use crate::codex::TurnContext; mod errors; +mod session; +mod session_manager; pub(crate) use errors::UnifiedExecError; +pub(crate) use session::UnifiedExecSession; const DEFAULT_TIMEOUT_MS: u64 = 1_000; const MAX_TIMEOUT_MS: u64 = 60_000; const UNIFIED_EXEC_OUTPUT_MAX_BYTES: usize = 128 * 1024; // 128 KiB +pub(crate) struct UnifiedExecContext<'a> { + pub session: &'a Session, + pub turn: &'a TurnContext, + pub sub_id: &'a str, + pub call_id: &'a str, + pub session_id: Option, +} + #[derive(Debug)] pub(crate) struct UnifiedExecRequest<'a> { - pub session_id: Option, pub input_chunks: &'a [String], pub timeout_ms: Option, } @@ -44,379 +63,60 @@ pub(crate) struct UnifiedExecResult { #[derive(Debug, Default)] pub(crate) struct UnifiedExecSessionManager { next_session_id: AtomicI32, - sessions: Mutex>, -} - -#[derive(Debug)] -struct ManagedUnifiedExecSession { - session: ExecCommandSession, - output_buffer: OutputBuffer, - /// Notifies waiters whenever new output has been appended to - /// `output_buffer`, allowing clients to poll for fresh data. - output_notify: Arc, - output_task: JoinHandle<()>, -} - -#[derive(Debug, Default)] -struct OutputBufferState { - chunks: VecDeque>, - total_bytes: usize, -} - -impl OutputBufferState { - fn push_chunk(&mut self, chunk: Vec) { - self.total_bytes = self.total_bytes.saturating_add(chunk.len()); - self.chunks.push_back(chunk); - - let mut excess = self - .total_bytes - .saturating_sub(UNIFIED_EXEC_OUTPUT_MAX_BYTES); - - while excess > 0 { - match self.chunks.front_mut() { - Some(front) if excess >= front.len() => { - excess -= front.len(); - self.total_bytes = self.total_bytes.saturating_sub(front.len()); - self.chunks.pop_front(); - } - Some(front) => { - front.drain(..excess); - self.total_bytes = self.total_bytes.saturating_sub(excess); - break; - } - None => break, - } - } - } - - fn drain(&mut self) -> Vec> { - let drained: Vec> = self.chunks.drain(..).collect(); - self.total_bytes = 0; - drained - } -} - -type OutputBuffer = Arc>; -type OutputHandles = (OutputBuffer, Arc); - -impl ManagedUnifiedExecSession { - fn new( - session: ExecCommandSession, - initial_output_rx: tokio::sync::broadcast::Receiver>, - ) -> Self { - let output_buffer = Arc::new(Mutex::new(OutputBufferState::default())); - let output_notify = Arc::new(Notify::new()); - let mut receiver = initial_output_rx; - let buffer_clone = Arc::clone(&output_buffer); - let notify_clone = Arc::clone(&output_notify); - let output_task = tokio::spawn(async move { - loop { - match receiver.recv().await { - Ok(chunk) => { - let mut guard = buffer_clone.lock().await; - guard.push_chunk(chunk); - drop(guard); - notify_clone.notify_waiters(); - } - // If we lag behind the broadcast buffer, skip missed - // messages but keep the task alive to continue streaming. - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { - continue; - } - // When the sender closes, exit the task. - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, - } - } - }); - - Self { - session, - output_buffer, - output_notify, - output_task, - } - } - - fn writer_sender(&self) -> mpsc::Sender> { - self.session.writer_sender() - } - - fn output_handles(&self) -> OutputHandles { - ( - Arc::clone(&self.output_buffer), - Arc::clone(&self.output_notify), - ) - } - - fn has_exited(&self) -> bool { - self.session.has_exited() - } -} - -impl Drop for ManagedUnifiedExecSession { - fn drop(&mut self) { - self.output_task.abort(); - } -} - -impl UnifiedExecSessionManager { - pub async fn handle_request( - &self, - request: UnifiedExecRequest<'_>, - ) -> Result { - let (timeout_ms, timeout_warning) = match request.timeout_ms { - Some(requested) if requested > MAX_TIMEOUT_MS => ( - MAX_TIMEOUT_MS, - Some(format!( - "Warning: requested timeout {requested}ms exceeds maximum of {MAX_TIMEOUT_MS}ms; clamping to {MAX_TIMEOUT_MS}ms.\n" - )), - ), - Some(requested) => (requested, None), - None => (DEFAULT_TIMEOUT_MS, None), - }; - - let mut new_session: Option = None; - let session_id; - let writer_tx; - let output_buffer; - let output_notify; - - if let Some(existing_id) = request.session_id { - let mut sessions = self.sessions.lock().await; - match sessions.get(&existing_id) { - Some(session) => { - if session.has_exited() { - sessions.remove(&existing_id); - return Err(UnifiedExecError::UnknownSessionId { - session_id: existing_id, - }); - } - let (buffer, notify) = session.output_handles(); - session_id = existing_id; - writer_tx = session.writer_sender(); - output_buffer = buffer; - output_notify = notify; - } - None => { - return Err(UnifiedExecError::UnknownSessionId { - session_id: existing_id, - }); - } - } - drop(sessions); - } else { - let command = request.input_chunks.to_vec(); - let new_id = self.next_session_id.fetch_add(1, Ordering::SeqCst); - let (session, initial_output_rx) = create_unified_exec_session(&command).await?; - let managed_session = ManagedUnifiedExecSession::new(session, initial_output_rx); - let (buffer, notify) = managed_session.output_handles(); - writer_tx = managed_session.writer_sender(); - output_buffer = buffer; - output_notify = notify; - session_id = new_id; - new_session = Some(managed_session); - }; - - if request.session_id.is_some() { - let joined_input = request.input_chunks.join(" "); - if !joined_input.is_empty() && writer_tx.send(joined_input.into_bytes()).await.is_err() - { - return Err(UnifiedExecError::WriteToStdin); - } - } - - let mut collected: Vec = Vec::with_capacity(4096); - let start = Instant::now(); - let deadline = start + Duration::from_millis(timeout_ms); - - loop { - let drained_chunks; - let mut wait_for_output = None; - { - let mut guard = output_buffer.lock().await; - drained_chunks = guard.drain(); - if drained_chunks.is_empty() { - wait_for_output = Some(output_notify.notified()); - } - } - - if drained_chunks.is_empty() { - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining == Duration::ZERO { - break; - } - - let notified = wait_for_output.unwrap_or_else(|| output_notify.notified()); - tokio::pin!(notified); - tokio::select! { - _ = &mut notified => {} - _ = tokio::time::sleep(remaining) => break, - } - continue; - } - - for chunk in drained_chunks { - collected.extend_from_slice(&chunk); - } - - if Instant::now() >= deadline { - break; - } - } - - let (output, _maybe_tokens) = truncate_middle( - &String::from_utf8_lossy(&collected), - UNIFIED_EXEC_OUTPUT_MAX_BYTES, - ); - let output = if let Some(warning) = timeout_warning { - format!("{warning}{output}") - } else { - output - }; - - let should_store_session = if let Some(session) = new_session.as_ref() { - !session.has_exited() - } else if request.session_id.is_some() { - let mut sessions = self.sessions.lock().await; - if let Some(existing) = sessions.get(&session_id) { - if existing.has_exited() { - sessions.remove(&session_id); - false - } else { - true - } - } else { - false - } - } else { - true - }; - - if should_store_session { - if let Some(session) = new_session { - self.sessions.lock().await.insert(session_id, session); - } - Ok(UnifiedExecResult { - session_id: Some(session_id), - output, - }) - } else { - Ok(UnifiedExecResult { - session_id: None, - output, - }) - } - } -} - -async fn create_unified_exec_session( - command: &[String], -) -> Result< - ( - ExecCommandSession, - tokio::sync::broadcast::Receiver>, - ), - UnifiedExecError, -> { - if command.is_empty() { - return Err(UnifiedExecError::MissingCommandLine); - } - - let pty_system = native_pty_system(); - - let pair = pty_system - .openpty(PtySize { - rows: 24, - cols: 80, - pixel_width: 0, - pixel_height: 0, - }) - .map_err(UnifiedExecError::create_session)?; - - // Safe thanks to the check at the top of the function. - let mut command_builder = CommandBuilder::new(command[0].clone()); - for arg in &command[1..] { - command_builder.arg(arg); - } - - let mut child = pair - .slave - .spawn_command(command_builder) - .map_err(UnifiedExecError::create_session)?; - let killer = child.clone_killer(); - - let (writer_tx, mut writer_rx) = mpsc::channel::>(128); - let (output_tx, _) = tokio::sync::broadcast::channel::>(256); - - let mut reader = pair - .master - .try_clone_reader() - .map_err(UnifiedExecError::create_session)?; - let output_tx_clone = output_tx.clone(); - let reader_handle = tokio::task::spawn_blocking(move || { - let mut buf = [0u8; 8192]; - loop { - match reader.read(&mut buf) { - Ok(0) => break, - Ok(n) => { - let _ = output_tx_clone.send(buf[..n].to_vec()); - } - Err(ref e) if e.kind() == ErrorKind::Interrupted => continue, - Err(ref e) if e.kind() == ErrorKind::WouldBlock => { - std::thread::sleep(Duration::from_millis(5)); - continue; - } - Err(_) => break, - } - } - }); - - let writer = pair - .master - .take_writer() - .map_err(UnifiedExecError::create_session)?; - let writer = Arc::new(StdMutex::new(writer)); - let writer_handle = tokio::spawn({ - let writer = writer.clone(); - async move { - while let Some(bytes) = writer_rx.recv().await { - let writer = writer.clone(); - let _ = tokio::task::spawn_blocking(move || { - if let Ok(mut guard) = writer.lock() { - use std::io::Write; - let _ = guard.write_all(&bytes); - let _ = guard.flush(); - } - }) - .await; - } - } - }); - - let exit_status = Arc::new(AtomicBool::new(false)); - let wait_exit_status = Arc::clone(&exit_status); - let wait_handle = tokio::task::spawn_blocking(move || { - let _ = child.wait(); - wait_exit_status.store(true, Ordering::SeqCst); - }); - - let (session, initial_output_rx) = ExecCommandSession::new( - writer_tx, - output_tx, - killer, - reader_handle, - writer_handle, - wait_handle, - exit_status, - ); - Ok((session, initial_output_rx)) + sessions: Mutex>, } #[cfg(test)] +#[cfg(unix)] mod tests { use super::*; - #[cfg(unix)] + + use crate::codex::Session; + use crate::codex::TurnContext; + use crate::codex::make_session_and_context; + use crate::protocol::AskForApproval; + use crate::protocol::SandboxPolicy; use core_test_support::skip_if_sandbox; + use std::sync::Arc; + use tokio::time::Duration; + + use super::session::OutputBufferState; + + fn test_session_and_turn() -> (Arc, Arc) { + let (session, mut turn) = make_session_and_context(); + turn.approval_policy = AskForApproval::Never; + turn.sandbox_policy = SandboxPolicy::DangerFullAccess; + (Arc::new(session), Arc::new(turn)) + } + + async fn run_unified_exec_request( + session: &Arc, + turn: &Arc, + session_id: Option, + input: Vec, + timeout_ms: Option, + ) -> Result { + let request_input = input; + let request = UnifiedExecRequest { + input_chunks: &request_input, + timeout_ms, + }; + + session + .services + .unified_exec_manager + .handle_request( + request, + UnifiedExecContext { + session, + turn: turn.as_ref(), + sub_id: "sub", + call_id: "call", + session_id, + }, + ) + .await + } #[test] fn push_chunk_trims_only_excess_bytes() { @@ -426,167 +126,170 @@ mod tests { buffer.push_chunk(vec![b'c']); assert_eq!(buffer.total_bytes, UNIFIED_EXEC_OUTPUT_MAX_BYTES); - assert_eq!(buffer.chunks.len(), 3); + let snapshot = buffer.snapshot(); + assert_eq!(snapshot.len(), 3); assert_eq!( - buffer.chunks.front().unwrap().len(), + snapshot.first().unwrap().len(), UNIFIED_EXEC_OUTPUT_MAX_BYTES - 2 ); - assert_eq!(buffer.chunks.pop_back().unwrap(), vec![b'c']); - assert_eq!(buffer.chunks.pop_back().unwrap(), vec![b'b']); + assert_eq!(snapshot.get(2).unwrap(), &vec![b'c']); + assert_eq!(snapshot.get(1).unwrap(), &vec![b'b']); } - #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn unified_exec_persists_across_requests_jif() -> Result<(), UnifiedExecError> { + async fn unified_exec_persists_across_requests() -> anyhow::Result<()> { skip_if_sandbox!(Ok(())); - let manager = UnifiedExecSessionManager::default(); + let (session, turn) = test_session_and_turn(); - let open_shell = manager - .handle_request(UnifiedExecRequest { - session_id: None, - input_chunks: &["bash".to_string(), "-i".to_string()], - timeout_ms: Some(2_500), - }) - .await?; + let open_shell = run_unified_exec_request( + &session, + &turn, + None, + vec!["bash".to_string(), "-i".to_string()], + Some(2_500), + ) + .await?; let session_id = open_shell.session_id.expect("expected session_id"); - manager - .handle_request(UnifiedExecRequest { - session_id: Some(session_id), - input_chunks: &[ - "export".to_string(), - "CODEX_INTERACTIVE_SHELL_VAR=codex\n".to_string(), - ], - timeout_ms: Some(2_500), - }) - .await?; + run_unified_exec_request( + &session, + &turn, + Some(session_id), + vec![ + "export".to_string(), + "CODEX_INTERACTIVE_SHELL_VAR=codex\n".to_string(), + ], + Some(2_500), + ) + .await?; - let out_2 = manager - .handle_request(UnifiedExecRequest { - session_id: Some(session_id), - input_chunks: &["echo $CODEX_INTERACTIVE_SHELL_VAR\n".to_string()], - timeout_ms: Some(2_500), - }) - .await?; + let out_2 = run_unified_exec_request( + &session, + &turn, + Some(session_id), + vec!["echo $CODEX_INTERACTIVE_SHELL_VAR\n".to_string()], + Some(2_500), + ) + .await?; assert!(out_2.output.contains("codex")); Ok(()) } - #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn multi_unified_exec_sessions() -> Result<(), UnifiedExecError> { + async fn multi_unified_exec_sessions() -> anyhow::Result<()> { skip_if_sandbox!(Ok(())); - let manager = UnifiedExecSessionManager::default(); + let (session, turn) = test_session_and_turn(); - let shell_a = manager - .handle_request(UnifiedExecRequest { - session_id: None, - input_chunks: &["/bin/bash".to_string(), "-i".to_string()], - timeout_ms: Some(2_500), - }) - .await?; + let shell_a = run_unified_exec_request( + &session, + &turn, + None, + vec!["/bin/bash".to_string(), "-i".to_string()], + Some(2_500), + ) + .await?; let session_a = shell_a.session_id.expect("expected session id"); - manager - .handle_request(UnifiedExecRequest { - session_id: Some(session_a), - input_chunks: &["export CODEX_INTERACTIVE_SHELL_VAR=codex\n".to_string()], - timeout_ms: Some(2_500), - }) - .await?; + run_unified_exec_request( + &session, + &turn, + Some(session_a), + vec!["export CODEX_INTERACTIVE_SHELL_VAR=codex\n".to_string()], + Some(2_500), + ) + .await?; - let out_2 = manager - .handle_request(UnifiedExecRequest { - session_id: None, - input_chunks: &[ - "echo".to_string(), - "$CODEX_INTERACTIVE_SHELL_VAR\n".to_string(), - ], - timeout_ms: Some(2_500), - }) - .await?; + let out_2 = run_unified_exec_request( + &session, + &turn, + None, + vec![ + "echo".to_string(), + "$CODEX_INTERACTIVE_SHELL_VAR\n".to_string(), + ], + Some(2_500), + ) + .await?; assert!(!out_2.output.contains("codex")); - let out_3 = manager - .handle_request(UnifiedExecRequest { - session_id: Some(session_a), - input_chunks: &["echo $CODEX_INTERACTIVE_SHELL_VAR\n".to_string()], - timeout_ms: Some(2_500), - }) - .await?; + let out_3 = run_unified_exec_request( + &session, + &turn, + Some(session_a), + vec!["echo $CODEX_INTERACTIVE_SHELL_VAR\n".to_string()], + Some(2_500), + ) + .await?; assert!(out_3.output.contains("codex")); Ok(()) } - #[cfg(unix)] #[tokio::test] - async fn unified_exec_timeouts() -> Result<(), UnifiedExecError> { + async fn unified_exec_timeouts() -> anyhow::Result<()> { skip_if_sandbox!(Ok(())); - let manager = UnifiedExecSessionManager::default(); + let (session, turn) = test_session_and_turn(); - let open_shell = manager - .handle_request(UnifiedExecRequest { - session_id: None, - input_chunks: &["bash".to_string(), "-i".to_string()], - timeout_ms: Some(2_500), - }) - .await?; + let open_shell = run_unified_exec_request( + &session, + &turn, + None, + vec!["bash".to_string(), "-i".to_string()], + Some(2_500), + ) + .await?; let session_id = open_shell.session_id.expect("expected session id"); - manager - .handle_request(UnifiedExecRequest { - session_id: Some(session_id), - input_chunks: &[ - "export".to_string(), - "CODEX_INTERACTIVE_SHELL_VAR=codex\n".to_string(), - ], - timeout_ms: Some(2_500), - }) - .await?; + run_unified_exec_request( + &session, + &turn, + Some(session_id), + vec![ + "export".to_string(), + "CODEX_INTERACTIVE_SHELL_VAR=codex\n".to_string(), + ], + Some(2_500), + ) + .await?; - let out_2 = manager - .handle_request(UnifiedExecRequest { - session_id: Some(session_id), - input_chunks: &["sleep 5 && echo $CODEX_INTERACTIVE_SHELL_VAR\n".to_string()], - timeout_ms: Some(10), - }) - .await?; + let out_2 = run_unified_exec_request( + &session, + &turn, + Some(session_id), + vec!["sleep 5 && echo $CODEX_INTERACTIVE_SHELL_VAR\n".to_string()], + Some(10), + ) + .await?; assert!(!out_2.output.contains("codex")); tokio::time::sleep(Duration::from_secs(7)).await; - let empty = Vec::new(); - let out_3 = manager - .handle_request(UnifiedExecRequest { - session_id: Some(session_id), - input_chunks: &empty, - timeout_ms: Some(100), - }) - .await?; + let out_3 = + run_unified_exec_request(&session, &turn, Some(session_id), Vec::new(), Some(100)) + .await?; assert!(out_3.output.contains("codex")); Ok(()) } - #[cfg(unix)] #[tokio::test] #[ignore] // Ignored while we have a better way to test this. - async fn requests_with_large_timeout_are_capped() -> Result<(), UnifiedExecError> { - let manager = UnifiedExecSessionManager::default(); + async fn requests_with_large_timeout_are_capped() -> anyhow::Result<()> { + let (session, turn) = test_session_and_turn(); - let result = manager - .handle_request(UnifiedExecRequest { - session_id: None, - input_chunks: &["echo".to_string(), "codex".to_string()], - timeout_ms: Some(120_000), - }) - .await?; + let result = run_unified_exec_request( + &session, + &turn, + None, + vec!["echo".to_string(), "codex".to_string()], + Some(120_000), + ) + .await?; assert!(result.output.starts_with( "Warning: requested timeout 120000ms exceeds maximum of 60000ms; clamping to 60000ms.\n" @@ -596,61 +299,66 @@ mod tests { Ok(()) } - #[cfg(unix)] #[tokio::test] #[ignore] // Ignored while we have a better way to test this. - async fn completed_commands_do_not_persist_sessions() -> Result<(), UnifiedExecError> { - let manager = UnifiedExecSessionManager::default(); - let result = manager - .handle_request(UnifiedExecRequest { - session_id: None, - input_chunks: &["/bin/echo".to_string(), "codex".to_string()], - timeout_ms: Some(2_500), - }) - .await?; + async fn completed_commands_do_not_persist_sessions() -> anyhow::Result<()> { + let (session, turn) = test_session_and_turn(); + let result = run_unified_exec_request( + &session, + &turn, + None, + vec!["/bin/echo".to_string(), "codex".to_string()], + Some(2_500), + ) + .await?; assert!(result.session_id.is_none()); assert!(result.output.contains("codex")); - assert!(manager.sessions.lock().await.is_empty()); + assert!( + session + .services + .unified_exec_manager + .sessions + .lock() + .await + .is_empty() + ); Ok(()) } - #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn reusing_completed_session_returns_unknown_session() -> Result<(), UnifiedExecError> { + async fn reusing_completed_session_returns_unknown_session() -> anyhow::Result<()> { skip_if_sandbox!(Ok(())); - let manager = UnifiedExecSessionManager::default(); + let (session, turn) = test_session_and_turn(); - let open_shell = manager - .handle_request(UnifiedExecRequest { - session_id: None, - input_chunks: &["/bin/bash".to_string(), "-i".to_string()], - timeout_ms: Some(2_500), - }) - .await?; + let open_shell = run_unified_exec_request( + &session, + &turn, + None, + vec!["/bin/bash".to_string(), "-i".to_string()], + Some(2_500), + ) + .await?; let session_id = open_shell.session_id.expect("expected session id"); - manager - .handle_request(UnifiedExecRequest { - session_id: Some(session_id), - input_chunks: &["exit\n".to_string()], - timeout_ms: Some(2_500), - }) - .await?; + run_unified_exec_request( + &session, + &turn, + Some(session_id), + vec!["exit\n".to_string()], + Some(2_500), + ) + .await?; tokio::time::sleep(Duration::from_millis(200)).await; - let err = manager - .handle_request(UnifiedExecRequest { - session_id: Some(session_id), - input_chunks: &[], - timeout_ms: Some(100), - }) - .await - .expect_err("expected unknown session error"); + let err = + run_unified_exec_request(&session, &turn, Some(session_id), Vec::new(), Some(100)) + .await + .expect_err("expected unknown session error"); match err { UnifiedExecError::UnknownSessionId { session_id: err_id } => { @@ -659,7 +367,15 @@ mod tests { other => panic!("expected UnknownSessionId, got {other:?}"), } - assert!(!manager.sessions.lock().await.contains_key(&session_id)); + assert!( + !session + .services + .unified_exec_manager + .sessions + .lock() + .await + .contains_key(&session_id) + ); Ok(()) } diff --git a/codex-rs/core/src/unified_exec/session.rs b/codex-rs/core/src/unified_exec/session.rs new file mode 100644 index 0000000000..bdb935f171 --- /dev/null +++ b/codex-rs/core/src/unified_exec/session.rs @@ -0,0 +1,217 @@ +#![allow(clippy::module_inception)] + +use std::collections::VecDeque; +use std::sync::Arc; + +use tokio::sync::Mutex; +use tokio::sync::Notify; +use tokio::sync::mpsc; +use tokio::sync::oneshot::error::TryRecvError; +use tokio::task::JoinHandle; +use tokio::time::Duration; + +use crate::exec::ExecToolCallOutput; +use crate::exec::SandboxType; +use crate::exec::StreamOutput; +use crate::exec::is_likely_sandbox_denied; +use crate::truncate::truncate_middle; +use codex_utils_pty::ExecCommandSession; +use codex_utils_pty::SpawnedPty; + +use super::UNIFIED_EXEC_OUTPUT_MAX_BYTES; +use super::UnifiedExecError; + +#[derive(Debug, Default)] +pub(crate) struct OutputBufferState { + chunks: VecDeque>, + pub(crate) total_bytes: usize, +} + +impl OutputBufferState { + pub(super) fn push_chunk(&mut self, chunk: Vec) { + self.total_bytes = self.total_bytes.saturating_add(chunk.len()); + self.chunks.push_back(chunk); + + let mut excess = self + .total_bytes + .saturating_sub(UNIFIED_EXEC_OUTPUT_MAX_BYTES); + + while excess > 0 { + match self.chunks.front_mut() { + Some(front) if excess >= front.len() => { + excess -= front.len(); + self.total_bytes = self.total_bytes.saturating_sub(front.len()); + self.chunks.pop_front(); + } + Some(front) => { + front.drain(..excess); + self.total_bytes = self.total_bytes.saturating_sub(excess); + break; + } + None => break, + } + } + } + + pub(super) fn drain(&mut self) -> Vec> { + let drained: Vec> = self.chunks.drain(..).collect(); + self.total_bytes = 0; + drained + } + + pub(super) fn snapshot(&self) -> Vec> { + self.chunks.iter().cloned().collect() + } +} + +pub(crate) type OutputBuffer = Arc>; +pub(crate) type OutputHandles = (OutputBuffer, Arc); + +#[derive(Debug)] +pub(crate) struct UnifiedExecSession { + session: ExecCommandSession, + output_buffer: OutputBuffer, + output_notify: Arc, + output_task: JoinHandle<()>, + sandbox_type: SandboxType, +} + +impl UnifiedExecSession { + pub(super) fn new( + session: ExecCommandSession, + initial_output_rx: tokio::sync::broadcast::Receiver>, + sandbox_type: SandboxType, + ) -> Self { + let output_buffer = Arc::new(Mutex::new(OutputBufferState::default())); + let output_notify = Arc::new(Notify::new()); + let mut receiver = initial_output_rx; + let buffer_clone = Arc::clone(&output_buffer); + let notify_clone = Arc::clone(&output_notify); + let output_task = tokio::spawn(async move { + loop { + match receiver.recv().await { + Ok(chunk) => { + let mut guard = buffer_clone.lock().await; + guard.push_chunk(chunk); + drop(guard); + notify_clone.notify_waiters(); + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + }); + + Self { + session, + output_buffer, + output_notify, + output_task, + sandbox_type, + } + } + + pub(super) fn writer_sender(&self) -> mpsc::Sender> { + self.session.writer_sender() + } + + pub(super) fn output_handles(&self) -> OutputHandles { + ( + Arc::clone(&self.output_buffer), + Arc::clone(&self.output_notify), + ) + } + + pub(super) fn has_exited(&self) -> bool { + self.session.has_exited() + } + + pub(super) fn exit_code(&self) -> Option { + self.session.exit_code() + } + + async fn snapshot_output(&self) -> Vec> { + let guard = self.output_buffer.lock().await; + guard.snapshot() + } + + fn sandbox_type(&self) -> SandboxType { + self.sandbox_type + } + + pub(super) async fn check_for_sandbox_denial(&self) -> Result<(), UnifiedExecError> { + if self.sandbox_type() == SandboxType::None || !self.has_exited() { + return Ok(()); + } + + let _ = + tokio::time::timeout(Duration::from_millis(20), self.output_notify.notified()).await; + + let collected_chunks = self.snapshot_output().await; + let mut aggregated: Vec = Vec::new(); + for chunk in collected_chunks { + aggregated.extend_from_slice(&chunk); + } + let aggregated_text = String::from_utf8_lossy(&aggregated).to_string(); + let exit_code = self.exit_code().unwrap_or(-1); + + let exec_output = ExecToolCallOutput { + exit_code, + stdout: StreamOutput::new(aggregated_text.clone()), + stderr: StreamOutput::new(String::new()), + aggregated_output: StreamOutput::new(aggregated_text.clone()), + duration: Duration::ZERO, + timed_out: false, + }; + + if is_likely_sandbox_denied(self.sandbox_type(), &exec_output) { + let (snippet, _) = truncate_middle(&aggregated_text, UNIFIED_EXEC_OUTPUT_MAX_BYTES); + let message = if snippet.is_empty() { + format!("exit code {exit_code}") + } else { + snippet + }; + return Err(UnifiedExecError::sandbox_denied(message, exec_output)); + } + + Ok(()) + } + + pub(super) async fn from_spawned( + spawned: SpawnedPty, + sandbox_type: SandboxType, + ) -> Result { + let SpawnedPty { + session, + output_rx, + mut exit_rx, + } = spawned; + let managed = Self::new(session, output_rx, sandbox_type); + + let exit_ready = match exit_rx.try_recv() { + Ok(_) | Err(TryRecvError::Closed) => true, + Err(TryRecvError::Empty) => false, + }; + + if exit_ready { + managed.check_for_sandbox_denial().await?; + return Ok(managed); + } + + tokio::pin!(exit_rx); + if tokio::time::timeout(Duration::from_millis(50), &mut exit_rx) + .await + .is_ok() + { + managed.check_for_sandbox_denial().await?; + } + + Ok(managed) + } +} + +impl Drop for UnifiedExecSession { + fn drop(&mut self) { + self.output_task.abort(); + } +} diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs new file mode 100644 index 0000000000..8bc8cb293e --- /dev/null +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -0,0 +1,288 @@ +use std::sync::Arc; + +use tokio::sync::Notify; +use tokio::sync::mpsc; +use tokio::time::Duration; +use tokio::time::Instant; + +use crate::exec_env::create_env; +use crate::sandboxing::ExecEnv; +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::ToolCtx; +use crate::truncate::truncate_middle; + +use super::DEFAULT_TIMEOUT_MS; +use super::MAX_TIMEOUT_MS; +use super::UNIFIED_EXEC_OUTPUT_MAX_BYTES; +use super::UnifiedExecContext; +use super::UnifiedExecError; +use super::UnifiedExecRequest; +use super::UnifiedExecResult; +use super::UnifiedExecSessionManager; +use super::session::OutputBuffer; +use super::session::UnifiedExecSession; + +pub(super) struct SessionAcquisition { + pub(super) session_id: i32, + pub(super) writer_tx: mpsc::Sender>, + pub(super) output_buffer: OutputBuffer, + pub(super) output_notify: Arc, + pub(super) new_session: Option, + pub(super) reuse_requested: bool, +} + +impl UnifiedExecSessionManager { + pub(super) async fn acquire_session( + &self, + request: &UnifiedExecRequest<'_>, + context: &UnifiedExecContext<'_>, + ) -> Result { + if let Some(existing_id) = context.session_id { + let mut sessions = self.sessions.lock().await; + match sessions.get(&existing_id) { + Some(session) => { + if session.has_exited() { + sessions.remove(&existing_id); + return Err(UnifiedExecError::UnknownSessionId { + session_id: existing_id, + }); + } + let (buffer, notify) = session.output_handles(); + let writer_tx = session.writer_sender(); + Ok(SessionAcquisition { + session_id: existing_id, + writer_tx, + output_buffer: buffer, + output_notify: notify, + new_session: None, + reuse_requested: true, + }) + } + None => Err(UnifiedExecError::UnknownSessionId { + session_id: existing_id, + }), + } + } else { + let new_id = self + .next_session_id + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let managed_session = self + .open_session_with_sandbox(request.input_chunks.to_vec(), context) + .await?; + let (buffer, notify) = managed_session.output_handles(); + let writer_tx = managed_session.writer_sender(); + Ok(SessionAcquisition { + session_id: new_id, + writer_tx, + output_buffer: buffer, + output_notify: notify, + new_session: Some(managed_session), + reuse_requested: false, + }) + } + } + + pub(crate) async fn open_session_with_exec_env( + &self, + env: &ExecEnv, + ) -> Result { + let (program, args) = env + .command + .split_first() + .ok_or(UnifiedExecError::MissingCommandLine)?; + let spawned = + codex_utils_pty::spawn_pty_process(program, args, env.cwd.as_path(), &env.env) + .await + .map_err(|err| UnifiedExecError::create_session(err.to_string()))?; + UnifiedExecSession::from_spawned(spawned, env.sandbox).await + } + + pub(super) async fn open_session_with_sandbox( + &self, + command: Vec, + context: &UnifiedExecContext<'_>, + ) -> Result { + let mut orchestrator = ToolOrchestrator::new(); + let mut runtime = UnifiedExecRuntime::new(self); + let req = UnifiedExecToolRequest::new( + command, + context.turn.cwd.clone(), + create_env(&context.turn.shell_environment_policy), + ); + let tool_ctx = ToolCtx { + session: context.session, + sub_id: context.sub_id.to_string(), + call_id: context.call_id.to_string(), + tool_name: "unified_exec".to_string(), + }; + orchestrator + .run( + &mut runtime, + &req, + &tool_ctx, + context.turn, + context.turn.approval_policy, + ) + .await + .map_err(|e| UnifiedExecError::create_session(format!("{e:?}"))) + } + + pub(super) async fn collect_output_until_deadline( + output_buffer: &OutputBuffer, + output_notify: &Arc, + deadline: Instant, + ) -> Vec { + let mut collected: Vec = Vec::with_capacity(4096); + loop { + let drained_chunks; + let mut wait_for_output = None; + { + let mut guard = output_buffer.lock().await; + drained_chunks = guard.drain(); + if drained_chunks.is_empty() { + wait_for_output = Some(output_notify.notified()); + } + } + + if drained_chunks.is_empty() { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining == Duration::ZERO { + break; + } + + let notified = wait_for_output.unwrap_or_else(|| output_notify.notified()); + tokio::pin!(notified); + tokio::select! { + _ = &mut notified => {} + _ = tokio::time::sleep(remaining) => break, + } + continue; + } + + for chunk in drained_chunks { + collected.extend_from_slice(&chunk); + } + + if Instant::now() >= deadline { + break; + } + } + + collected + } + + pub(super) async fn should_store_session(&self, acquisition: &SessionAcquisition) -> bool { + if let Some(session) = acquisition.new_session.as_ref() { + !session.has_exited() + } else if acquisition.reuse_requested { + let mut sessions = self.sessions.lock().await; + if let Some(existing) = sessions.get(&acquisition.session_id) { + if existing.has_exited() { + sessions.remove(&acquisition.session_id); + false + } else { + true + } + } else { + false + } + } else { + true + } + } + + pub(super) async fn send_input_chunks( + writer_tx: &mpsc::Sender>, + chunks: &[String], + ) -> Result<(), UnifiedExecError> { + let mut trailing_whitespace = true; + for chunk in chunks { + if chunk.is_empty() { + continue; + } + + let leading_whitespace = chunk + .chars() + .next() + .map(char::is_whitespace) + .unwrap_or(true); + + if !trailing_whitespace + && !leading_whitespace + && writer_tx.send(vec![b' ']).await.is_err() + { + return Err(UnifiedExecError::WriteToStdin); + } + + if writer_tx.send(chunk.as_bytes().to_vec()).await.is_err() { + return Err(UnifiedExecError::WriteToStdin); + } + + trailing_whitespace = chunk + .chars() + .next_back() + .map(char::is_whitespace) + .unwrap_or(trailing_whitespace); + } + + Ok(()) + } + + pub async fn handle_request( + &self, + request: UnifiedExecRequest<'_>, + context: UnifiedExecContext<'_>, + ) -> Result { + let (timeout_ms, timeout_warning) = match request.timeout_ms { + Some(requested) if requested > MAX_TIMEOUT_MS => ( + MAX_TIMEOUT_MS, + Some(format!( + "Warning: requested timeout {requested}ms exceeds maximum of {MAX_TIMEOUT_MS}ms; clamping to {MAX_TIMEOUT_MS}ms.\n" + )), + ), + Some(requested) => (requested, None), + None => (DEFAULT_TIMEOUT_MS, None), + }; + + let mut acquisition = self.acquire_session(&request, &context).await?; + + if acquisition.reuse_requested { + Self::send_input_chunks(&acquisition.writer_tx, request.input_chunks).await?; + } + + let deadline = Instant::now() + Duration::from_millis(timeout_ms); + let collected = Self::collect_output_until_deadline( + &acquisition.output_buffer, + &acquisition.output_notify, + deadline, + ) + .await; + + let (output, _maybe_tokens) = truncate_middle( + &String::from_utf8_lossy(&collected), + UNIFIED_EXEC_OUTPUT_MAX_BYTES, + ); + let output = if let Some(warning) = timeout_warning { + format!("{warning}{output}") + } else { + output + }; + + let should_store_session = self.should_store_session(&acquisition).await; + let session_id = if should_store_session { + if let Some(session) = acquisition.new_session.take() { + self.sessions + .lock() + .await + .insert(acquisition.session_id, session); + } + Some(acquisition.session_id) + } else { + None + }; + + Ok(UnifiedExecResult { session_id, output }) + } +} diff --git a/codex-rs/core/tests/suite/exec.rs b/codex-rs/core/tests/suite/exec.rs index 2d1e99b0b7..ea5ab84879 100644 --- a/codex-rs/core/tests/suite/exec.rs +++ b/codex-rs/core/tests/suite/exec.rs @@ -36,6 +36,7 @@ async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result) -> Vec { - let mut out = Vec::new(); - while let Ok(ev) = rx.try_recv() { - if let EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent { - stream: ExecOutputStream::Stdout, - chunk, - .. - }) = ev.msg - { - out.extend_from_slice(&chunk); - } - } - out -} - -#[tokio::test] -async fn test_exec_stdout_stream_events_echo() { - let (tx, rx) = async_channel::unbounded::(); - - let stdout_stream = StdoutStream { - sub_id: "test-sub".to_string(), - call_id: "call-1".to_string(), - tx_event: tx, - }; - - let cmd = vec![ - "/bin/sh".to_string(), - "-c".to_string(), - // Use printf for predictable behavior across shells - "printf 'hello-world\n'".to_string(), - ]; - - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let params = ExecParams { - command: cmd, - cwd: cwd.clone(), - timeout_ms: Some(5_000), - env: HashMap::new(), - with_escalated_permissions: None, - justification: None, - }; - - let policy = SandboxPolicy::new_read_only_policy(); - - let result = process_exec_tool_call( - params, - SandboxType::None, - &policy, - cwd.as_path(), - &None, - Some(stdout_stream), - ) - .await; - - let result = match result { - Ok(r) => r, - Err(e) => panic!("process_exec_tool_call failed: {e}"), - }; - - assert_eq!(result.exit_code, 0); - assert_eq!(result.stdout.text, "hello-world\n"); - - let streamed = collect_stdout_events(rx); - // We should have received at least the same contents (possibly in one chunk) - assert_eq!(String::from_utf8_lossy(&streamed), "hello-world\n"); -} - -#[tokio::test] -async fn test_exec_stderr_stream_events_echo() { - let (tx, rx) = async_channel::unbounded::(); - - let stdout_stream = StdoutStream { - sub_id: "test-sub".to_string(), - call_id: "call-2".to_string(), - tx_event: tx, - }; - - let cmd = vec![ - "/bin/sh".to_string(), - "-c".to_string(), - // Write to stderr explicitly - "printf 'oops\n' 1>&2".to_string(), - ]; - - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let params = ExecParams { - command: cmd, - cwd: cwd.clone(), - timeout_ms: Some(5_000), - env: HashMap::new(), - with_escalated_permissions: None, - justification: None, - }; - - let policy = SandboxPolicy::new_read_only_policy(); - - let result = process_exec_tool_call( - params, - SandboxType::None, - &policy, - cwd.as_path(), - &None, - Some(stdout_stream), - ) - .await; - - let result = match result { - Ok(r) => r, - Err(e) => panic!("process_exec_tool_call failed: {e}"), - }; - - assert_eq!(result.exit_code, 0); - assert_eq!(result.stdout.text, ""); - assert_eq!(result.stderr.text, "oops\n"); - - // Collect only stderr delta events - let mut err = Vec::new(); - while let Ok(ev) = rx.try_recv() { - if let EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent { - stream: ExecOutputStream::Stderr, - chunk, - .. - }) = ev.msg - { - err.extend_from_slice(&chunk); - } - } - assert_eq!(String::from_utf8_lossy(&err), "oops\n"); -} - -#[tokio::test] -async fn test_aggregated_output_interleaves_in_order() { - // Spawn a shell that alternates stdout and stderr with sleeps to enforce order. - let cmd = vec![ - "/bin/sh".to_string(), - "-c".to_string(), - "printf 'O1\\n'; sleep 0.01; printf 'E1\\n' 1>&2; sleep 0.01; printf 'O2\\n'; sleep 0.01; printf 'E2\\n' 1>&2".to_string(), - ]; - - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let params = ExecParams { - command: cmd, - cwd: cwd.clone(), - timeout_ms: Some(5_000), - env: HashMap::new(), - with_escalated_permissions: None, - justification: None, - }; - - let policy = SandboxPolicy::new_read_only_policy(); - - let result = process_exec_tool_call( - params, - SandboxType::None, - &policy, - cwd.as_path(), - &None, - None, - ) - .await - .expect("process_exec_tool_call"); - - assert_eq!(result.exit_code, 0); - assert_eq!(result.stdout.text, "O1\nO2\n"); - assert_eq!(result.stderr.text, "E1\nE2\n"); - assert_eq!(result.aggregated_output.text, "O1\nE1\nO2\nE2\n"); - assert_eq!(result.aggregated_output.truncated_after_lines, None); -} - -#[tokio::test] -async fn test_exec_timeout_returns_partial_output() { - let cmd = vec![ - "/bin/sh".to_string(), - "-c".to_string(), - "printf 'before\\n'; sleep 2; printf 'after\\n'".to_string(), - ]; - - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let params = ExecParams { - command: cmd, - cwd: cwd.clone(), - timeout_ms: Some(200), - env: HashMap::new(), - with_escalated_permissions: None, - justification: None, - }; - - let policy = SandboxPolicy::new_read_only_policy(); - - let result = process_exec_tool_call( - params, - SandboxType::None, - &policy, - cwd.as_path(), - &None, - None, - ) - .await; - - let Err(CodexErr::Sandbox(SandboxErr::Timeout { output })) = result else { - panic!("expected timeout error"); - }; - - assert_eq!(output.exit_code, 124); - assert_eq!(output.stdout.text, "before\n"); - assert!(output.stderr.text.is_empty()); - assert_eq!(output.aggregated_output.text, "before\n"); - assert!(output.duration >= Duration::from_millis(200)); - assert!(output.timed_out); -} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 1a08c6df78..440eb1e893 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -9,7 +9,6 @@ mod client; mod compact; mod compact_resume_fork; mod exec; -mod exec_stream_events; mod fork_conversation; mod grep_files; mod json_result; diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 2e7fe31794..63ad3ccaea 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -815,11 +815,7 @@ async fn handle_container_exec_user_approved_records_tool_decision() { mount_sse( &server, sse(vec![ - ev_local_shell_call( - "user_approved_call", - "completed", - vec!["/bin/echo", "approved"], - ), + ev_local_shell_call("user_approved_call", "completed", vec!["/bin/date"]), ev_completed("done"), ]), ) @@ -881,11 +877,7 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision() mount_sse( &server, sse(vec![ - ev_local_shell_call( - "user_approved_session_call", - "completed", - vec!["/bin/echo", "persist"], - ), + ev_local_shell_call("user_approved_session_call", "completed", vec!["/bin/date"]), ev_completed("done"), ]), ) @@ -947,11 +939,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { mount_sse( &server, sse(vec![ - ev_local_shell_call( - "sandbox_retry_call", - "completed", - vec!["/bin/echo", "retry"], - ), + ev_local_shell_call("sandbox_retry_call", "completed", vec!["/bin/date"]), ev_completed("done"), ]), ) @@ -1013,7 +1001,7 @@ async fn handle_container_exec_user_denies_records_tool_decision() { mount_sse( &server, sse(vec![ - ev_local_shell_call("user_denied_call", "completed", vec!["/bin/echo", "deny"]), + ev_local_shell_call("user_denied_call", "completed", vec!["/bin/date"]), ev_completed("done"), ]), ) @@ -1075,11 +1063,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() mount_sse( &server, sse(vec![ - ev_local_shell_call( - "sandbox_session_call", - "completed", - vec!["/bin/echo", "persist"], - ), + ev_local_shell_call("sandbox_session_call", "completed", vec!["/bin/date"]), ev_completed("done"), ]), ) @@ -1141,7 +1125,7 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() { mount_sse( &server, sse(vec![ - ev_local_shell_call("sandbox_deny_call", "completed", vec!["/bin/echo", "deny"]), + ev_local_shell_call("sandbox_deny_call", "completed", vec!["/bin/date"]), ev_completed("done"), ]), ) diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index ac6ac445e4..c4b0767fb6 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -8,8 +8,10 @@ use crate::landlock::apply_sandbox_policy_to_current_thread; pub struct LandlockCommand { /// It is possible that the cwd used in the context of the sandbox policy /// is different from the cwd of the process to spawn. + #[arg(long = "sandbox-policy-cwd")] pub sandbox_policy_cwd: PathBuf, + #[arg(long = "sandbox-policy")] pub sandbox_policy: codex_core::protocol::SandboxPolicy, /// Full command args to run under landlock. diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index 6af59ef0fa..a1a3758307 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -44,6 +44,7 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) { env: create_env_from_core_vars(), with_escalated_permissions: None, justification: None, + arg0: None, }; let sandbox_policy = SandboxPolicy::WorkspaceWrite { @@ -146,6 +147,7 @@ async fn assert_network_blocked(cmd: &[&str]) { env: create_env_from_core_vars(), with_escalated_permissions: None, justification: None, + arg0: None, }; let sandbox_policy = SandboxPolicy::new_read_only_policy(); diff --git a/codex-rs/mcp-server/tests/suite/codex_tool.rs b/codex-rs/mcp-server/tests/suite/codex_tool.rs index a26892e5d4..d7cd200f07 100644 --- a/codex-rs/mcp-server/tests/suite/codex_tool.rs +++ b/codex-rs/mcp-server/tests/suite/codex_tool.rs @@ -445,7 +445,7 @@ fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<() r#" model = "mock-model" approval_policy = "untrusted" -sandbox_policy = "read-only" +sandbox_policy = "workspace-write" model_provider = "mock_provider" diff --git a/codex-rs/utils/pty/Cargo.toml b/codex-rs/utils/pty/Cargo.toml new file mode 100644 index 0000000000..117d626318 --- /dev/null +++ b/codex-rs/utils/pty/Cargo.toml @@ -0,0 +1,16 @@ +[package] +edition = "2021" +name = "codex-utils-pty" +version = { workspace = true } + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +portable-pty = { workspace = true } +tokio = { workspace = true, features = [ + "macros", + "rt-multi-thread", + "sync", +] } diff --git a/codex-rs/utils/pty/src/lib.rs b/codex-rs/utils/pty/src/lib.rs new file mode 100644 index 0000000000..740a502ba9 --- /dev/null +++ b/codex-rs/utils/pty/src/lib.rs @@ -0,0 +1,210 @@ +use std::collections::HashMap; +use std::io::ErrorKind; +use std::path::Path; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; +use std::sync::Mutex as StdMutex; +use std::time::Duration; + +use anyhow::Result; +use portable_pty::native_pty_system; +use portable_pty::CommandBuilder; +use portable_pty::PtySize; +use tokio::sync::broadcast; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::sync::Mutex as TokioMutex; +use tokio::task::JoinHandle; + +#[derive(Debug)] +pub struct ExecCommandSession { + writer_tx: mpsc::Sender>, + output_tx: broadcast::Sender>, + killer: StdMutex>>, + reader_handle: StdMutex>>, + writer_handle: StdMutex>>, + wait_handle: StdMutex>>, + exit_status: Arc, + exit_code: Arc>>, +} + +impl ExecCommandSession { + #[allow(clippy::too_many_arguments)] + pub fn new( + writer_tx: mpsc::Sender>, + output_tx: broadcast::Sender>, + killer: Box, + reader_handle: JoinHandle<()>, + writer_handle: JoinHandle<()>, + wait_handle: JoinHandle<()>, + exit_status: Arc, + exit_code: Arc>>, + ) -> (Self, broadcast::Receiver>) { + let initial_output_rx = output_tx.subscribe(); + ( + Self { + writer_tx, + output_tx, + killer: StdMutex::new(Some(killer)), + reader_handle: StdMutex::new(Some(reader_handle)), + writer_handle: StdMutex::new(Some(writer_handle)), + wait_handle: StdMutex::new(Some(wait_handle)), + exit_status, + exit_code, + }, + initial_output_rx, + ) + } + + pub fn writer_sender(&self) -> mpsc::Sender> { + self.writer_tx.clone() + } + + pub fn output_receiver(&self) -> broadcast::Receiver> { + self.output_tx.subscribe() + } + + pub fn has_exited(&self) -> bool { + self.exit_status.load(std::sync::atomic::Ordering::SeqCst) + } + + pub fn exit_code(&self) -> Option { + self.exit_code.lock().ok().and_then(|guard| *guard) + } +} + +impl Drop for ExecCommandSession { + fn drop(&mut self) { + if let Ok(mut killer_opt) = self.killer.lock() { + if let Some(mut killer) = killer_opt.take() { + let _ = killer.kill(); + } + } + + if let Ok(mut h) = self.reader_handle.lock() { + if let Some(handle) = h.take() { + handle.abort(); + } + } + if let Ok(mut h) = self.writer_handle.lock() { + if let Some(handle) = h.take() { + handle.abort(); + } + } + if let Ok(mut h) = self.wait_handle.lock() { + if let Some(handle) = h.take() { + handle.abort(); + } + } + } +} + +#[derive(Debug)] +pub struct SpawnedPty { + pub session: ExecCommandSession, + pub output_rx: broadcast::Receiver>, + pub exit_rx: oneshot::Receiver, +} + +pub async fn spawn_pty_process( + program: &str, + args: &[String], + cwd: &Path, + env: &HashMap, +) -> Result { + if program.is_empty() { + anyhow::bail!("missing program for PTY spawn"); + } + + let pty_system = native_pty_system(); + let pair = pty_system.openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + })?; + + let mut command_builder = CommandBuilder::new(program); + command_builder.cwd(cwd); + command_builder.env_clear(); + for arg in args { + command_builder.arg(arg); + } + for (key, value) in env { + command_builder.env(key, value); + } + + let mut child = pair.slave.spawn_command(command_builder)?; + let killer = child.clone_killer(); + + let (writer_tx, mut writer_rx) = mpsc::channel::>(128); + let (output_tx, _) = broadcast::channel::>(256); + + let mut reader = pair.master.try_clone_reader()?; + let output_tx_clone = output_tx.clone(); + let reader_handle: JoinHandle<()> = tokio::task::spawn_blocking(move || { + let mut buf = [0u8; 8_192]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + let _ = output_tx_clone.send(buf[..n].to_vec()); + } + Err(ref e) if e.kind() == ErrorKind::Interrupted => continue, + Err(ref e) if e.kind() == ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + continue; + } + Err(_) => break, + } + } + }); + + let writer = pair.master.take_writer()?; + let writer = Arc::new(TokioMutex::new(writer)); + let writer_handle: JoinHandle<()> = tokio::spawn({ + let writer = Arc::clone(&writer); + async move { + while let Some(bytes) = writer_rx.recv().await { + let mut guard = writer.lock().await; + use std::io::Write; + let _ = guard.write_all(&bytes); + let _ = guard.flush(); + } + } + }); + + let (exit_tx, exit_rx) = oneshot::channel::(); + let exit_status = Arc::new(AtomicBool::new(false)); + let wait_exit_status = Arc::clone(&exit_status); + let exit_code = Arc::new(StdMutex::new(None)); + let wait_exit_code = Arc::clone(&exit_code); + let wait_handle: JoinHandle<()> = tokio::task::spawn_blocking(move || { + let code = match child.wait() { + Ok(status) => status.exit_code() as i32, + Err(_) => -1, + }; + wait_exit_status.store(true, std::sync::atomic::Ordering::SeqCst); + if let Ok(mut guard) = wait_exit_code.lock() { + *guard = Some(code); + } + let _ = exit_tx.send(code); + }); + + let (session, output_rx) = ExecCommandSession::new( + writer_tx, + output_tx, + killer, + reader_handle, + writer_handle, + wait_handle, + exit_status, + exit_code, + ); + + Ok(SpawnedPty { + session, + output_rx, + exit_rx, + }) +} From 9c903c4716b72d2b2f887dab04235976f772ee28 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 20 Oct 2025 13:34:44 -0700 Subject: [PATCH 042/287] Add ItemStarted/ItemCompleted events for UserInputItem (#5306) Adds a new ItemStarted event and delivers UserMessage as the first item type (more to come). Renames `InputItem` to `UserInput` considering we're using the `Item` suffix for actual items. --- .../app-server/src/codex_message_processor.rs | 2 +- codex-rs/core/src/codex.rs | 67 +++++++++++------- codex-rs/core/src/codex/compact.rs | 8 +-- codex-rs/core/src/rollout/policy.rs | 4 +- codex-rs/core/src/state/item_collector.rs | 68 +++++++++++++++++++ codex-rs/core/src/state/mod.rs | 2 + codex-rs/core/src/tasks/compact.rs | 4 +- codex-rs/core/src/tasks/mod.rs | 6 +- codex-rs/core/src/tasks/regular.rs | 4 +- codex-rs/core/src/tasks/review.rs | 4 +- .../core/src/tools/handlers/view_image.rs | 4 +- codex-rs/core/tests/common/lib.rs | 8 +++ codex-rs/core/tests/suite/abort_tasks.rs | 4 +- codex-rs/core/tests/suite/approvals.rs | 4 +- codex-rs/core/tests/suite/client.rs | 32 ++++----- codex-rs/core/tests/suite/compact.rs | 22 +++--- .../core/tests/suite/compact_resume_fork.rs | 4 +- .../core/tests/suite/fork_conversation.rs | 4 +- codex-rs/core/tests/suite/grep_files.rs | 4 +- codex-rs/core/tests/suite/items.rs | 68 +++++++++++++++++++ codex-rs/core/tests/suite/json_result.rs | 4 +- codex-rs/core/tests/suite/list_dir.rs | 10 +-- codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/model_tools.rs | 4 +- codex-rs/core/tests/suite/otel.rs | 42 ++++++------ codex-rs/core/tests/suite/prompt_caching.rs | 30 ++++---- codex-rs/core/tests/suite/read_file.rs | 4 +- codex-rs/core/tests/suite/review.rs | 4 +- codex-rs/core/tests/suite/rmcp_client.rs | 10 +-- .../core/tests/suite/shell_serialization.rs | 4 +- .../suite/stream_error_allows_next_turn.rs | 6 +- .../core/tests/suite/stream_no_completed.rs | 4 +- codex-rs/core/tests/suite/tool_harness.rs | 12 ++-- codex-rs/core/tests/suite/tool_parallelism.rs | 4 +- codex-rs/core/tests/suite/tools.rs | 4 +- codex-rs/core/tests/suite/unified_exec.rs | 8 +-- .../core/tests/suite/user_notification.rs | 4 +- codex-rs/core/tests/suite/view_image.rs | 8 +-- .../src/event_processor_with_human_output.rs | 2 + codex-rs/exec/src/lib.rs | 8 +-- codex-rs/mcp-server/src/codex_tool_runner.rs | 8 ++- codex-rs/otel/src/otel_event_manager.rs | 6 +- codex-rs/protocol/Cargo.toml | 2 +- codex-rs/protocol/src/items.rs | 33 +++++++++ codex-rs/protocol/src/lib.rs | 2 + codex-rs/protocol/src/models.rs | 12 ++-- codex-rs/protocol/src/protocol.rs | 45 ++++++------ codex-rs/protocol/src/user_input.rs | 24 +++++++ codex-rs/tui/src/chatwidget.rs | 9 +-- 49 files changed, 435 insertions(+), 202 deletions(-) create mode 100644 codex-rs/core/src/state/item_collector.rs create mode 100644 codex-rs/core/tests/suite/items.rs create mode 100644 codex-rs/protocol/src/items.rs create mode 100644 codex-rs/protocol/src/user_input.rs diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 0d9fe4b089..90ec928375 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -77,7 +77,6 @@ use codex_core::protocol::ApplyPatchApprovalRequestEvent; use codex_core::protocol::Event; use codex_core::protocol::EventMsg; use codex_core::protocol::ExecApprovalRequestEvent; -use codex_core::protocol::InputItem as CoreInputItem; use codex_core::protocol::Op; use codex_core::protocol::ReviewDecision; use codex_login::ServerOptions as LoginServerOptions; @@ -89,6 +88,7 @@ use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::InputMessageKind; use codex_protocol::protocol::USER_MESSAGE_BEGIN; +use codex_protocol::user_input::UserInput as CoreInputItem; use codex_utils_json_to_toml::json_to_toml; use std::collections::HashMap; use std::ffi::OsStr; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 170e00c71e..6c3b6ee472 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -10,12 +10,15 @@ use crate::event_mapping::map_response_item_to_event_messages; use crate::function_tool::FunctionCallError; use crate::parse_command::parse_command; use crate::review_format::format_review_findings_block; +use crate::state::ItemCollector; use crate::terminal; use crate::user_notification::UserNotifier; use async_channel::Receiver; use async_channel::Sender; use codex_apply_patch::ApplyPatchAction; use codex_protocol::ConversationId; +use codex_protocol::items::TurnItem; +use codex_protocol::items::UserMessageItem; use codex_protocol::protocol::ConversationPathResponseEvent; use codex_protocol::protocol::ExitedReviewModeEvent; use codex_protocol::protocol::McpAuthStatus; @@ -77,7 +80,6 @@ use crate::protocol::ErrorEvent; use crate::protocol::Event; use crate::protocol::EventMsg; use crate::protocol::ExecApprovalRequestEvent; -use crate::protocol::InputItem; use crate::protocol::ListCustomPromptsResponseEvent; use crate::protocol::Op; use crate::protocol::RateLimitSnapshot; @@ -122,6 +124,7 @@ use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::InitialHistory; +use codex_protocol::user_input::UserInput; pub mod compact; use self::compact::build_compacted_history; @@ -264,6 +267,7 @@ pub(crate) struct TurnContext { pub(crate) is_review_mode: bool, pub(crate) final_output_json_schema: Option, pub(crate) codex_linux_sandbox_exe: Option, + pub(crate) item_collector: ItemCollector, } impl TurnContext { @@ -352,6 +356,7 @@ impl Session { provider: ModelProviderInfo, session_configuration: &SessionConfiguration, conversation_id: ConversationId, + tx_event: Sender, ) -> TurnContext { let config = session_configuration.original_config_do_not_use.clone(); let model_family = find_family_for_model(&session_configuration.model) @@ -397,6 +402,7 @@ impl Session { is_review_mode: false, final_output_json_schema: None, codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), + item_collector: ItemCollector::new(tx_event, conversation_id, "turn_id".to_string()), } } @@ -656,6 +662,7 @@ impl Session { session_configuration.provider.clone(), &session_configuration, self.conversation_id, + self.get_tx_event(), ); if let Some(final_schema) = updates.final_output_json_schema { turn_context.final_output_json_schema = final_schema; @@ -986,7 +993,7 @@ impl Session { } /// Returns the input if there was no task running to inject into - pub async fn inject_input(&self, input: Vec) -> Result<(), Vec> { + pub async fn inject_input(&self, input: Vec) -> Result<(), Vec> { let mut active = self.active_turn.lock().await; match active.as_mut() { Some(at) => { @@ -1157,6 +1164,11 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv } } + current_context + .item_collector + .started_completed(TurnItem::UserMessage(UserMessageItem::new(&items))) + .await; + sess.spawn_task(Arc::clone(¤t_context), sub.id, items, RegularTask) .await; previous_context = Some(current_context); @@ -1268,7 +1280,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv let turn_context = sess.new_turn(SessionSettingsUpdate::default()).await; // Attempt to inject input into current task if let Err(items) = sess - .inject_input(vec![InputItem::Text { + .inject_input(vec![UserInput::Text { text: compact::SUMMARIZATION_PROMPT.to_string(), }]) .await @@ -1422,10 +1434,15 @@ async fn spawn_review_thread( is_review_mode: true, final_output_json_schema: None, codex_linux_sandbox_exe: parent_turn_context.codex_linux_sandbox_exe.clone(), + item_collector: ItemCollector::new( + sess.get_tx_event(), + sess.conversation_id, + sub_id.to_string(), + ), }; // Seed the child task with the review prompt as the initial user message. - let input: Vec = vec![InputItem::Text { + let input: Vec = vec![UserInput::Text { text: review_prompt, }]; let tc = Arc::new(review_turn_context); @@ -1463,7 +1480,7 @@ pub(crate) async fn run_task( sess: Arc, turn_context: Arc, sub_id: String, - input: Vec, + input: Vec, task_kind: TaskKind, cancellation_token: CancellationToken, ) -> Option { @@ -2624,6 +2641,15 @@ mod tests { tool_approvals: Mutex::new(ApprovalStore::default()), }; + let turn_context = Session::make_turn_context( + Some(Arc::clone(&auth_manager)), + &otel_event_manager, + session_configuration.provider.clone(), + &session_configuration, + conversation_id, + tx_event.clone(), + ); + let session = Session { conversation_id, tx_event, @@ -2633,13 +2659,6 @@ mod tests { next_internal_sub_id: AtomicU64::new(0), }; - let turn_context = Session::make_turn_context( - Some(Arc::clone(&auth_manager)), - &otel_event_manager, - session_configuration.provider.clone(), - &session_configuration, - conversation_id, - ); (session, turn_context) } @@ -2690,6 +2709,15 @@ mod tests { tool_approvals: Mutex::new(ApprovalStore::default()), }; + let turn_context = Arc::new(Session::make_turn_context( + Some(Arc::clone(&auth_manager)), + &otel_event_manager, + session_configuration.provider.clone(), + &session_configuration, + conversation_id, + tx_event.clone(), + )); + let session = Arc::new(Session { conversation_id, tx_event, @@ -2699,13 +2727,6 @@ mod tests { next_internal_sub_id: AtomicU64::new(0), }); - let turn_context = Arc::new(Session::make_turn_context( - Some(Arc::clone(&auth_manager)), - &otel_event_manager, - session_configuration.provider.clone(), - &session_configuration, - conversation_id, - )); (session, turn_context, rx_event) } @@ -2726,7 +2747,7 @@ mod tests { _session: Arc, _ctx: Arc, _sub_id: String, - _input: Vec, + _input: Vec, cancellation_token: CancellationToken, ) -> Option { if self.listen_to_cancellation_token { @@ -2750,7 +2771,7 @@ mod tests { async fn abort_regular_task_emits_turn_aborted_only() { let (sess, tc, rx) = make_session_and_context_with_rx(); let sub_id = "sub-regular".to_string(); - let input = vec![InputItem::Text { + let input = vec![UserInput::Text { text: "hello".to_string(), }]; sess.spawn_task( @@ -2781,7 +2802,7 @@ mod tests { async fn abort_gracefuly_emits_turn_aborted_only() { let (sess, tc, rx) = make_session_and_context_with_rx(); let sub_id = "sub-regular".to_string(); - let input = vec![InputItem::Text { + let input = vec![UserInput::Text { text: "hello".to_string(), }]; sess.spawn_task( @@ -2809,7 +2830,7 @@ mod tests { async fn abort_review_task_emits_exited_then_aborted_and_records_history() { let (sess, tc, rx) = make_session_and_context_with_rx(); let sub_id = "sub-review".to_string(); - let input = vec![InputItem::Text { + let input = vec![UserInput::Text { text: "start review".to_string(), }]; sess.spawn_task( diff --git a/codex-rs/core/src/codex/compact.rs b/codex-rs/core/src/codex/compact.rs index 93bbfa79c6..a6bb8a8fb4 100644 --- a/codex-rs/core/src/codex/compact.rs +++ b/codex-rs/core/src/codex/compact.rs @@ -12,7 +12,6 @@ use crate::protocol::CompactedItem; use crate::protocol::ErrorEvent; use crate::protocol::Event; use crate::protocol::EventMsg; -use crate::protocol::InputItem; use crate::protocol::InputMessageKind; use crate::protocol::TaskStartedEvent; use crate::protocol::TurnContextItem; @@ -24,6 +23,7 @@ use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::RolloutItem; +use codex_protocol::user_input::UserInput; use futures::prelude::*; pub const SUMMARIZATION_PROMPT: &str = include_str!("../../templates/compact/prompt.md"); @@ -41,7 +41,7 @@ pub(crate) async fn run_inline_auto_compact_task( turn_context: Arc, ) { let sub_id = sess.next_internal_sub_id(); - let input = vec![InputItem::Text { + let input = vec![UserInput::Text { text: SUMMARIZATION_PROMPT.to_string(), }]; run_compact_task_inner(sess, turn_context, sub_id, input).await; @@ -51,7 +51,7 @@ pub(crate) async fn run_compact_task( sess: Arc, turn_context: Arc, sub_id: String, - input: Vec, + input: Vec, ) -> Option { let start_event = Event { id: sub_id.clone(), @@ -68,7 +68,7 @@ async fn run_compact_task_inner( sess: Arc, turn_context: Arc, sub_id: String, - input: Vec, + input: Vec, ) { let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input); let mut turn_input = sess diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 8fc39e79f5..fdf1f5ccb2 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -71,6 +71,8 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::PlanUpdate(_) | EventMsg::ShutdownComplete | EventMsg::ViewImageToolCall(_) - | EventMsg::ConversationPath(_) => false, + | EventMsg::ConversationPath(_) + | EventMsg::ItemStarted(_) + | EventMsg::ItemCompleted(_) => false, } } diff --git a/codex-rs/core/src/state/item_collector.rs b/codex-rs/core/src/state/item_collector.rs new file mode 100644 index 0000000000..bd1c4329d8 --- /dev/null +++ b/codex-rs/core/src/state/item_collector.rs @@ -0,0 +1,68 @@ +use async_channel::Sender; +use codex_protocol::ConversationId; +use codex_protocol::items::TurnItem; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ItemCompletedEvent; +use codex_protocol::protocol::ItemStartedEvent; +use tracing::error; + +#[derive(Debug)] +pub(crate) struct ItemCollector { + thread_id: ConversationId, + turn_id: String, + tx_event: Sender, +} + +impl ItemCollector { + pub fn new( + tx_event: Sender, + thread_id: ConversationId, + turn_id: String, + ) -> ItemCollector { + ItemCollector { + tx_event, + thread_id, + turn_id, + } + } + + pub async fn started(&self, item: TurnItem) { + let err = self + .tx_event + .send(Event { + id: self.turn_id.clone(), + msg: EventMsg::ItemStarted(ItemStartedEvent { + thread_id: self.thread_id, + turn_id: self.turn_id.clone(), + item, + }), + }) + .await; + if let Err(e) = err { + error!("failed to send item started event: {e}"); + } + } + + pub async fn completed(&self, item: TurnItem) { + let err = self + .tx_event + .send(Event { + id: self.turn_id.clone(), + msg: EventMsg::ItemCompleted(ItemCompletedEvent { + thread_id: self.thread_id, + turn_id: self.turn_id.clone(), + item, + }), + }) + .await; + if let Err(e) = err { + error!("failed to send item completed event: {e}"); + } + } + + pub async fn started_completed(&self, item: TurnItem) { + self.started(item.clone()).await; + self.completed(item).await; + } +} diff --git a/codex-rs/core/src/state/mod.rs b/codex-rs/core/src/state/mod.rs index 642433a786..7ba5f37083 100644 --- a/codex-rs/core/src/state/mod.rs +++ b/codex-rs/core/src/state/mod.rs @@ -1,7 +1,9 @@ +mod item_collector; mod service; mod session; mod turn; +pub(crate) use item_collector::ItemCollector; pub(crate) use service::SessionServices; pub(crate) use session::SessionState; pub(crate) use turn::ActiveTurn; diff --git a/codex-rs/core/src/tasks/compact.rs b/codex-rs/core/src/tasks/compact.rs index 12e61b1f65..a27e68ddd8 100644 --- a/codex-rs/core/src/tasks/compact.rs +++ b/codex-rs/core/src/tasks/compact.rs @@ -5,8 +5,8 @@ use tokio_util::sync::CancellationToken; use crate::codex::TurnContext; use crate::codex::compact; -use crate::protocol::InputItem; use crate::state::TaskKind; +use codex_protocol::user_input::UserInput; use super::SessionTask; use super::SessionTaskContext; @@ -25,7 +25,7 @@ impl SessionTask for CompactTask { session: Arc, ctx: Arc, sub_id: String, - input: Vec, + input: Vec, _cancellation_token: CancellationToken, ) -> Option { compact::run_compact_task(session.clone_session(), ctx, sub_id, input).await diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 15ec419f9b..6a5dff6ab9 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -17,13 +17,13 @@ use crate::codex::Session; use crate::codex::TurnContext; use crate::protocol::Event; use crate::protocol::EventMsg; -use crate::protocol::InputItem; use crate::protocol::TaskCompleteEvent; use crate::protocol::TurnAbortReason; use crate::protocol::TurnAbortedEvent; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_protocol::user_input::UserInput; pub(crate) use compact::CompactTask; pub(crate) use regular::RegularTask; @@ -56,7 +56,7 @@ pub(crate) trait SessionTask: Send + Sync + 'static { session: Arc, ctx: Arc, sub_id: String, - input: Vec, + input: Vec, cancellation_token: CancellationToken, ) -> Option; @@ -70,7 +70,7 @@ impl Session { self: &Arc, turn_context: Arc, sub_id: String, - input: Vec, + input: Vec, task: T, ) { self.abort_all_tasks(TurnAbortReason::Replaced).await; diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index 3a3fa267ce..a79d842c40 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -5,8 +5,8 @@ use tokio_util::sync::CancellationToken; use crate::codex::TurnContext; use crate::codex::run_task; -use crate::protocol::InputItem; use crate::state::TaskKind; +use codex_protocol::user_input::UserInput; use super::SessionTask; use super::SessionTaskContext; @@ -25,7 +25,7 @@ impl SessionTask for RegularTask { session: Arc, ctx: Arc, sub_id: String, - input: Vec, + input: Vec, cancellation_token: CancellationToken, ) -> Option { let sess = session.clone_session(); diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index 6b4b2175db..e4dfeec69f 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -6,8 +6,8 @@ use tokio_util::sync::CancellationToken; use crate::codex::TurnContext; use crate::codex::exit_review_mode; use crate::codex::run_task; -use crate::protocol::InputItem; use crate::state::TaskKind; +use codex_protocol::user_input::UserInput; use super::SessionTask; use super::SessionTaskContext; @@ -26,7 +26,7 @@ impl SessionTask for ReviewTask { session: Arc, ctx: Arc, sub_id: String, - input: Vec, + input: Vec, cancellation_token: CancellationToken, ) -> Option { let sess = session.clone_session(); diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index 2396e19ced..43cada47b2 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -5,13 +5,13 @@ use tokio::fs; use crate::function_tool::FunctionCallError; use crate::protocol::Event; use crate::protocol::EventMsg; -use crate::protocol::InputItem; use crate::protocol::ViewImageToolCallEvent; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; +use codex_protocol::user_input::UserInput; pub struct ViewImageHandler; @@ -67,7 +67,7 @@ impl ToolHandler for ViewImageHandler { let event_path = abs_path.clone(); session - .inject_input(vec![InputItem::LocalImage { path: abs_path }]) + .inject_input(vec![UserInput::LocalImage { path: abs_path }]) .await .map_err(|_| { FunctionCallError::RespondToModel( diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 5944fa9481..0064d0e5a4 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -134,6 +134,14 @@ where wait_for_event_with_timeout(codex, predicate, Duration::from_secs(1)).await } +pub async fn wait_for_event_match(codex: &CodexConversation, matcher: F) -> T +where + F: Fn(&codex_core::protocol::EventMsg) -> Option, +{ + let ev = wait_for_event(codex, |ev| matcher(ev).is_some()).await; + matcher(&ev).unwrap() +} + pub async fn wait_for_event_with_timeout( codex: &CodexConversation, mut predicate: F, diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs index 5122f6611e..dcc65fc742 100644 --- a/codex-rs/core/tests/suite/abort_tasks.rs +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -1,8 +1,8 @@ use std::time::Duration; use codex_core::protocol::EventMsg; -use codex_core::protocol::InputItem; use codex_core::protocol::Op; +use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; use core_test_support::responses::mount_sse_once; @@ -42,7 +42,7 @@ async fn interrupt_long_running_tool_emits_turn_aborted() { // Kick off a turn that triggers the function call. codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "start sleep".into(), }], }) diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 0ce1183edd..2d90d98f3f 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -6,11 +6,11 @@ use codex_core::protocol::ApplyPatchApprovalRequestEvent; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; use codex_core::protocol::ExecApprovalRequestEvent; -use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::ReviewDecision; +use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_apply_patch_function_call; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -374,7 +374,7 @@ async fn submit_turn( test.codex .submit(Op::UserTurn { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: prompt.into(), }], final_output_json_schema: None, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 7bc22449a8..ed16cdb35d 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -17,13 +17,13 @@ use codex_core::built_in_model_providers; use codex_core::error::CodexErr; use codex_core::model_family::find_family_for_model; use codex_core::protocol::EventMsg; -use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::SessionSource; use codex_otel::otel_event_manager::OtelEventManager; use codex_protocol::ConversationId; use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::models::WebSearchAction; +use codex_protocol::user_input::UserInput; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id; use core_test_support::responses; @@ -263,7 +263,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { // 2) Submit new input; the request body must include the prior item followed by the new user input. codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello".into(), }], }) @@ -335,7 +335,7 @@ async fn includes_conversation_id_and_model_headers_in_request() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello".into(), }], }) @@ -390,7 +390,7 @@ async fn includes_base_instructions_override_in_request() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello".into(), }], }) @@ -450,7 +450,7 @@ async fn chatgpt_auth_sends_correct_request() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello".into(), }], }) @@ -540,7 +540,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello".into(), }], }) @@ -579,7 +579,7 @@ async fn includes_user_instructions_message_in_request() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello".into(), }], }) @@ -795,7 +795,7 @@ async fn token_count_includes_rate_limits_snapshot() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello".into(), }], }) @@ -946,7 +946,7 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { let submission_id = codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello".into(), }], }) @@ -1016,7 +1016,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "seed turn".into(), }], }) @@ -1026,7 +1026,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "trigger context window".into(), }], }) @@ -1146,7 +1146,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello".into(), }], }) @@ -1223,7 +1223,7 @@ async fn env_var_overrides_loaded_auth() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello".into(), }], }) @@ -1301,7 +1301,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { // Turn 1: user sends U1; wait for completion. codex .submit(Op::UserInput { - items: vec![InputItem::Text { text: "U1".into() }], + items: vec![UserInput::Text { text: "U1".into() }], }) .await .unwrap(); @@ -1310,7 +1310,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { // Turn 2: user sends U2; wait for completion. codex .submit(Op::UserInput { - items: vec![InputItem::Text { text: "U2".into() }], + items: vec![UserInput::Text { text: "U2".into() }], }) .await .unwrap(); @@ -1319,7 +1319,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { // Turn 3: user sends U3; wait for completion. codex .submit(Op::UserInput { - items: vec![InputItem::Text { text: "U3".into() }], + items: vec![UserInput::Text { text: "U3".into() }], }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index bd760b8749..17d72abe7e 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -5,10 +5,10 @@ use codex_core::NewConversation; use codex_core::built_in_model_providers; use codex_core::protocol::ErrorEvent; use codex_core::protocol::EventMsg; -use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::RolloutItem; use codex_core::protocol::RolloutLine; +use codex_protocol::user_input::UserInput; use core_test_support::load_default_config_for_test; use core_test_support::skip_if_no_network; use core_test_support::wait_for_event; @@ -108,7 +108,7 @@ async fn summarize_context_three_requests_and_instructions() { // 1) Normal user input – should hit server once. codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello world".into(), }], }) @@ -123,7 +123,7 @@ async fn summarize_context_three_requests_and_instructions() { // 3) Next user input – third hit; history should include only the summary. codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: THIRD_USER_MSG.into(), }], }) @@ -324,7 +324,7 @@ async fn auto_compact_runs_after_token_limit_hit() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: FIRST_AUTO_MSG.into(), }], }) @@ -335,7 +335,7 @@ async fn auto_compact_runs_after_token_limit_hit() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: SECOND_AUTO_MSG.into(), }], }) @@ -468,7 +468,7 @@ async fn auto_compact_persists_rollout_entries() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: FIRST_AUTO_MSG.into(), }], }) @@ -478,7 +478,7 @@ async fn auto_compact_persists_rollout_entries() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: SECOND_AUTO_MSG.into(), }], }) @@ -580,7 +580,7 @@ async fn auto_compact_stops_after_failed_attempt() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: FIRST_AUTO_MSG.into(), }], }) @@ -674,7 +674,7 @@ async fn manual_compact_retries_after_context_window_error() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "first turn".into(), }], }) @@ -802,7 +802,7 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: MULTI_AUTO_MSG.into(), }], }) @@ -913,7 +913,7 @@ async fn auto_compact_triggers_after_function_call_over_95_percent_usage() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: FUNCTION_CALL_LIMIT_MSG.into(), }], }) diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 8197bed10f..4261a30510 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -20,9 +20,9 @@ use codex_core::config::Config; use codex_core::config::OPENAI_DEFAULT_MODEL; use codex_core::protocol::ConversationPathResponseEvent; use codex_core::protocol::EventMsg; -use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; +use codex_protocol::user_input::UserInput; use core_test_support::load_default_config_for_test; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -777,7 +777,7 @@ async fn start_test_conversation( async fn user_turn(conversation: &Arc, text: &str) { conversation .submit(Op::UserInput { - items: vec![InputItem::Text { text: text.into() }], + items: vec![UserInput::Text { text: text.into() }], }) .await .expect("submit user turn"); diff --git a/codex-rs/core/tests/suite/fork_conversation.rs b/codex-rs/core/tests/suite/fork_conversation.rs index 44c0cd8195..28f5d0175d 100644 --- a/codex-rs/core/tests/suite/fork_conversation.rs +++ b/codex-rs/core/tests/suite/fork_conversation.rs @@ -9,10 +9,10 @@ use codex_core::content_items_to_text; use codex_core::is_session_prefix_message; use codex_core::protocol::ConversationPathResponseEvent; use codex_core::protocol::EventMsg; -use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::RolloutItem; use codex_core::protocol::RolloutLine; +use codex_protocol::user_input::UserInput; use core_test_support::load_default_config_for_test; use core_test_support::skip_if_no_network; use core_test_support::wait_for_event; @@ -71,7 +71,7 @@ async fn fork_conversation_twice_drops_to_first_message() { for text in ["first", "second", "third"] { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: text.to_string(), }], }) diff --git a/codex-rs/core/tests/suite/grep_files.rs b/codex-rs/core/tests/suite/grep_files.rs index 31195f7e3b..f8097558d9 100644 --- a/codex-rs/core/tests/suite/grep_files.rs +++ b/codex-rs/core/tests/suite/grep_files.rs @@ -4,10 +4,10 @@ use anyhow::Result; use codex_core::model_family::find_family_for_model; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; -use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::user_input::UserInput; use core_test_support::responses; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -149,7 +149,7 @@ async fn submit_turn(test: &TestCodex, prompt: &str) -> Result<()> { test.codex .submit(Op::UserTurn { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: prompt.into(), }], final_output_json_schema: None, diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs new file mode 100644 index 0000000000..a64f51187a --- /dev/null +++ b/codex-rs/core/tests/suite/items.rs @@ -0,0 +1,68 @@ +#![cfg(not(target_os = "windows"))] + +use anyhow::Ok; +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; +use codex_protocol::items::TurnItem; +use codex_protocol::user_input::UserInput; +use core_test_support::responses; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; +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::TestCodex; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event_match; +use pretty_assertions::assert_eq; +use wiremock::matchers::any; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn user_message_item_is_emitted() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + + let TestCodex { codex, .. } = test_codex().build(&server).await?; + + let first_response = sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]); + responses::mount_sse_once_match(&server, any(), first_response).await; + + codex + .submit(Op::UserInput { + items: (vec![UserInput::Text { + text: "please inspect sample.txt".into(), + }]), + }) + .await?; + + let started = wait_for_event_match(&codex, |ev| match ev { + EventMsg::ItemStarted(e) => Some(e.clone()), + _ => None, + }) + .await; + + let completed = wait_for_event_match(&codex, |ev| match ev { + EventMsg::ItemCompleted(e) => Some(e.clone()), + _ => None, + }) + .await; + + let TurnItem::UserMessage(started_item) = started.item; + let TurnItem::UserMessage(completed_item) = completed.item; + + assert_eq!(started_item.id, completed_item.id); + assert_eq!( + started_item.content, + vec![UserInput::Text { + text: "please inspect sample.txt".into(), + }] + ); + assert_eq!( + completed_item.content, + vec![UserInput::Text { + text: "please inspect sample.txt".into(), + }] + ); + Ok(()) +} diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index e8d91fae8b..cdfa28e0ae 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -2,10 +2,10 @@ use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; -use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::user_input::UserInput; use core_test_support::responses; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; @@ -74,7 +74,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { // 1) Normal user input – should hit server once. codex .submit(Op::UserTurn { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello world".into(), }], final_output_json_schema: Some(serde_json::from_str(SCHEMA)?), diff --git a/codex-rs/core/tests/suite/list_dir.rs b/codex-rs/core/tests/suite/list_dir.rs index 1aa5a6484b..2a04d3075d 100644 --- a/codex-rs/core/tests/suite/list_dir.rs +++ b/codex-rs/core/tests/suite/list_dir.rs @@ -2,10 +2,10 @@ use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; -use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::user_input::UserInput; use core_test_support::responses; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -66,7 +66,7 @@ async fn list_dir_tool_returns_entries() -> anyhow::Result<()> { codex .submit(Op::UserTurn { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "list directory contents".into(), }], final_output_json_schema: None, @@ -171,7 +171,7 @@ async fn list_dir_tool_depth_one_omits_children() -> anyhow::Result<()> { codex .submit(Op::UserTurn { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "list directory contents depth one".into(), }], final_output_json_schema: None, @@ -283,7 +283,7 @@ async fn list_dir_tool_depth_two_includes_children_only() -> anyhow::Result<()> codex .submit(Op::UserTurn { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "list directory contents depth two".into(), }], final_output_json_schema: None, @@ -398,7 +398,7 @@ async fn list_dir_tool_depth_three_includes_grandchildren() -> anyhow::Result<() codex .submit(Op::UserTurn { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "list directory contents depth three".into(), }], final_output_json_schema: None, diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 440eb1e893..22442d10d7 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -11,6 +11,7 @@ mod compact_resume_fork; mod exec; mod fork_conversation; mod grep_files; +mod items; mod json_result; mod list_dir; mod live_cli; diff --git a/codex-rs/core/tests/suite/model_tools.rs b/codex-rs/core/tests/suite/model_tools.rs index 71e64f9e3a..0b749d4741 100644 --- a/codex-rs/core/tests/suite/model_tools.rs +++ b/codex-rs/core/tests/suite/model_tools.rs @@ -7,8 +7,8 @@ use codex_core::built_in_model_providers; use codex_core::features::Feature; use codex_core::model_family::find_family_for_model; use codex_core::protocol::EventMsg; -use codex_core::protocol::InputItem; use codex_core::protocol::Op; +use codex_protocol::user_input::UserInput; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id; use core_test_support::responses; @@ -74,7 +74,7 @@ async fn collect_tool_identifiers_for_model(model: &str) -> Vec { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello tools".into(), }], }) diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 63ad3ccaea..ec6d567cb6 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -1,9 +1,9 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::InputItem; use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_custom_tool_call; @@ -31,7 +31,7 @@ async fn responses_api_emits_api_request_event() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello".into(), }], }) @@ -77,7 +77,7 @@ async fn process_sse_emits_tracing_for_output_item() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello".into(), }], }) @@ -121,7 +121,7 @@ async fn process_sse_emits_failed_event_on_parse_error() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello".into(), }], }) @@ -166,7 +166,7 @@ async fn process_sse_records_failed_event_when_stream_closes_without_completed() codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello".into(), }], }) @@ -223,7 +223,7 @@ async fn process_sse_failed_event_records_response_error_message() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello".into(), }], }) @@ -278,7 +278,7 @@ async fn process_sse_failed_event_logs_parse_error() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello".into(), }], }) @@ -328,7 +328,7 @@ async fn process_sse_failed_event_logs_missing_error() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello".into(), }], }) @@ -378,7 +378,7 @@ async fn process_sse_failed_event_logs_response_completed_parse_error() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello".into(), }], }) @@ -433,7 +433,7 @@ async fn process_sse_emits_completed_telemetry() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello".into(), }], }) @@ -493,7 +493,7 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello".into(), }], }) @@ -557,7 +557,7 @@ async fn handle_response_item_records_tool_result_for_function_call() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello".into(), }], }) @@ -631,7 +631,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_missing_ids() codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello".into(), }], }) @@ -689,7 +689,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello".into(), }], }) @@ -787,7 +787,7 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello".into(), }], }) @@ -833,7 +833,7 @@ async fn handle_container_exec_user_approved_records_tool_decision() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "approved".into(), }], }) @@ -895,7 +895,7 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision() codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "persist".into(), }], }) @@ -957,7 +957,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "retry".into(), }], }) @@ -1019,7 +1019,7 @@ async fn handle_container_exec_user_denies_records_tool_decision() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "deny".into(), }], }) @@ -1081,7 +1081,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "persist".into(), }], }) @@ -1143,7 +1143,7 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "deny".into(), }], }) diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index caa4cb68de..3add7bbfaa 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -9,13 +9,13 @@ use codex_core::features::Feature; use codex_core::model_family::find_family_for_model; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; -use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; use codex_core::protocol_config_types::ReasoningEffort; use codex_core::protocol_config_types::ReasoningSummary; use codex_core::shell::Shell; use codex_core::shell::default_user_shell; +use codex_protocol::user_input::UserInput; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id; use core_test_support::skip_if_no_network; @@ -115,7 +115,7 @@ async fn codex_mini_latest_tools() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello 1".into(), }], }) @@ -125,7 +125,7 @@ async fn codex_mini_latest_tools() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello 2".into(), }], }) @@ -199,7 +199,7 @@ async fn prompt_tools_are_consistent_across_requests() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello 1".into(), }], }) @@ -209,7 +209,7 @@ async fn prompt_tools_are_consistent_across_requests() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello 2".into(), }], }) @@ -319,7 +319,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello 1".into(), }], }) @@ -329,7 +329,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello 2".into(), }], }) @@ -439,7 +439,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() { // First turn codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello 1".into(), }], }) @@ -468,7 +468,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() { // Second turn after overrides codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello 2".into(), }], }) @@ -567,7 +567,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() { // First turn codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello 1".into(), }], }) @@ -580,7 +580,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() { let writable = TempDir::new().unwrap(); codex .submit(Op::UserTurn { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello 2".into(), }], cwd: new_cwd.path().to_path_buf(), @@ -696,7 +696,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() { codex .submit(Op::UserTurn { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello 1".into(), }], cwd: default_cwd.clone(), @@ -713,7 +713,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() { codex .submit(Op::UserTurn { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello 2".into(), }], cwd: default_cwd.clone(), @@ -810,7 +810,7 @@ async fn send_user_turn_with_changes_sends_environment_context() { codex .submit(Op::UserTurn { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello 1".into(), }], cwd: default_cwd.clone(), @@ -827,7 +827,7 @@ async fn send_user_turn_with_changes_sends_environment_context() { codex .submit(Op::UserTurn { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello 2".into(), }], cwd: default_cwd.clone(), diff --git a/codex-rs/core/tests/suite/read_file.rs b/codex-rs/core/tests/suite/read_file.rs index fc5a94f91b..a74bd8b2a9 100644 --- a/codex-rs/core/tests/suite/read_file.rs +++ b/codex-rs/core/tests/suite/read_file.rs @@ -2,10 +2,10 @@ use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; -use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::user_input::UserInput; use core_test_support::responses; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -64,7 +64,7 @@ async fn read_file_tool_returns_requested_lines() -> anyhow::Result<()> { codex .submit(Op::UserTurn { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "please inspect sample.txt".into(), }], final_output_json_schema: None, diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index f3eeb3a310..422acd922f 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -11,7 +11,6 @@ use codex_core::protocol::ConversationPathResponseEvent; use codex_core::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; use codex_core::protocol::EventMsg; use codex_core::protocol::ExitedReviewModeEvent; -use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::ReviewCodeLocation; use codex_core::protocol::ReviewFinding; @@ -20,6 +19,7 @@ use codex_core::protocol::ReviewOutputEvent; use codex_core::protocol::ReviewRequest; use codex_core::protocol::RolloutItem; use codex_core::protocol::RolloutLine; +use codex_protocol::user_input::UserInput; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id_from_str; use core_test_support::skip_if_no_network; @@ -566,7 +566,7 @@ async fn review_history_does_not_leak_into_parent_session() { let followup = "back to parent".to_string(); codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: followup.clone(), }], }) diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index ab702b5d91..609ae87118 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -14,10 +14,10 @@ use codex_core::features::Feature; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; -use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::user_input::UserInput; use core_test_support::responses; use core_test_support::responses::mount_sse_once_match; use core_test_support::skip_if_no_network; @@ -104,7 +104,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { fixture .codex .submit(Op::UserTurn { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "call the rmcp echo tool".into(), }], final_output_json_schema: None, @@ -240,7 +240,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { fixture .codex .submit(Op::UserTurn { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "call the rmcp echo tool".into(), }], final_output_json_schema: None, @@ -391,7 +391,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { fixture .codex .submit(Op::UserTurn { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "call the rmcp streamable http echo tool".into(), }], final_output_json_schema: None, @@ -574,7 +574,7 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> { fixture .codex .submit(Op::UserTurn { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "call the rmcp streamable http oauth echo tool".into(), }], final_output_json_schema: None, diff --git a/codex-rs/core/tests/suite/shell_serialization.rs b/codex-rs/core/tests/suite/shell_serialization.rs index 21141ec535..50d0c99980 100644 --- a/codex-rs/core/tests/suite/shell_serialization.rs +++ b/codex-rs/core/tests/suite/shell_serialization.rs @@ -5,10 +5,10 @@ use codex_core::features::Feature; use codex_core::model_family::find_family_for_model; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; -use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::user_input::UserInput; use core_test_support::assert_regex_match; use core_test_support::responses::ev_apply_patch_function_call; use core_test_support::responses::ev_assistant_message; @@ -35,7 +35,7 @@ async fn submit_turn(test: &TestCodex, prompt: &str, sandbox_policy: SandboxPoli test.codex .submit(Op::UserTurn { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: prompt.into(), }], final_output_json_schema: None, diff --git a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs index 9c32c351e0..03f1be0f18 100644 --- a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs +++ b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs @@ -3,8 +3,8 @@ use std::time::Duration; use codex_core::ModelProviderInfo; use codex_core::WireApi; use codex_core::protocol::EventMsg; -use codex_core::protocol::InputItem; use codex_core::protocol::Op; +use codex_protocol::user_input::UserInput; use core_test_support::load_sse_fixture_with_id; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; @@ -87,7 +87,7 @@ async fn continue_after_stream_error() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "first message".into(), }], }) @@ -114,7 +114,7 @@ async fn continue_after_stream_error() { // error above, this submission would be rejected/queued indefinitely. codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "follow up".into(), }], }) diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index 4ef2b04fa1..19c2e24f0f 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -6,8 +6,8 @@ use std::time::Duration; use codex_core::ModelProviderInfo; use codex_core::WireApi; use codex_core::protocol::EventMsg; -use codex_core::protocol::InputItem; use codex_core::protocol::Op; +use codex_protocol::user_input::UserInput; use core_test_support::load_sse_fixture; use core_test_support::load_sse_fixture_with_id; use core_test_support::skip_if_no_network; @@ -94,7 +94,7 @@ async fn retries_on_early_close() { codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello".into(), }], }) diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 68bd76bd8e..d9a7b0ead7 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -7,11 +7,11 @@ use codex_core::features::Feature; use codex_core::model_family::find_family_for_model; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; -use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::plan_tool::StepStatus; +use codex_protocol::user_input::UserInput; use core_test_support::assert_regex_match; use core_test_support::responses; use core_test_support::responses::ev_apply_patch_function_call; @@ -74,7 +74,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> codex .submit(Op::UserTurn { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "please run the shell command".into(), }], final_output_json_schema: None, @@ -143,7 +143,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { codex .submit(Op::UserTurn { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "please update the plan".into(), }], final_output_json_schema: None, @@ -226,7 +226,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { codex .submit(Op::UserTurn { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "please update the plan".into(), }], final_output_json_schema: None, @@ -324,7 +324,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() codex .submit(Op::UserTurn { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "please apply a patch".into(), }], final_output_json_schema: None, @@ -425,7 +425,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { codex .submit(Op::UserTurn { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "please apply a patch".into(), }], final_output_json_schema: None, diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index b4e3d1c9ad..a1e96fa02f 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -7,10 +7,10 @@ use std::time::Instant; use codex_core::model_family::find_family_for_model; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; -use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; @@ -28,7 +28,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { test.codex .submit(Op::UserTurn { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: prompt.into(), }], final_output_json_schema: None, diff --git a/codex-rs/core/tests/suite/tools.rs b/codex-rs/core/tests/suite/tools.rs index ec07b0cdbd..46dd0ba811 100644 --- a/codex-rs/core/tests/suite/tools.rs +++ b/codex-rs/core/tests/suite/tools.rs @@ -6,10 +6,10 @@ use codex_core::features::Feature; use codex_core::model_family::find_family_for_model; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; -use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::user_input::UserInput; use core_test_support::assert_regex_match; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -38,7 +38,7 @@ async fn submit_turn( test.codex .submit(Op::UserTurn { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: prompt.into(), }], final_output_json_schema: None, diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 6298ab06de..78a1abf16b 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -6,10 +6,10 @@ use anyhow::Result; use codex_core::features::Feature; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; -use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; @@ -118,7 +118,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { codex .submit(Op::UserTurn { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "run unified exec".into(), }], final_output_json_schema: None, @@ -254,7 +254,7 @@ PY codex .submit(Op::UserTurn { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "exercise lag handling".into(), }], final_output_json_schema: None, @@ -360,7 +360,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { codex .submit(Op::UserTurn { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "check timeout".into(), }], final_output_json_schema: None, diff --git a/codex-rs/core/tests/suite/user_notification.rs b/codex-rs/core/tests/suite/user_notification.rs index 3390f4a65a..11deb70f3d 100644 --- a/codex-rs/core/tests/suite/user_notification.rs +++ b/codex-rs/core/tests/suite/user_notification.rs @@ -3,8 +3,8 @@ use std::os::unix::fs::PermissionsExt; use codex_core::protocol::EventMsg; -use codex_core::protocol::InputItem; use codex_core::protocol::Op; +use codex_protocol::user_input::UserInput; use core_test_support::fs_wait; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -52,7 +52,7 @@ echo -n "${@: -1}" > $(dirname "${0}")/notify.txt"#, // 1) Normal user input – should hit server once. codex .submit(Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "hello world".into(), }], }) diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index bdb67ad631..e0dd2ab051 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -4,10 +4,10 @@ use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; -use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::user_input::UserInput; use core_test_support::responses; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -90,7 +90,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { codex .submit(Op::UserTurn { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "please add the screenshot".into(), }], final_output_json_schema: None, @@ -189,7 +189,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { codex .submit(Op::UserTurn { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "please attach the folder".into(), }], final_output_json_schema: None, @@ -254,7 +254,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { codex .submit(Op::UserTurn { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: "please attach the missing image".into(), }], final_output_json_schema: None, 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 f9aa3f8598..9a85c5de71 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -517,6 +517,8 @@ impl EventProcessor for EventProcessorWithHumanOutput { EventMsg::AgentMessageDelta(_) => {} EventMsg::AgentReasoningDelta(_) => {} EventMsg::AgentReasoningRawContentDelta(_) => {} + EventMsg::ItemStarted(_) => {} + EventMsg::ItemCompleted(_) => {} } CodexStatus::Running } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index afd9d44a73..f66c1db2df 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -23,12 +23,12 @@ use codex_core::git_info::get_git_repo_root; use codex_core::protocol::AskForApproval; use codex_core::protocol::Event; use codex_core::protocol::EventMsg; -use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::SessionSource; use codex_core::protocol::TaskCompleteEvent; use codex_ollama::DEFAULT_OSS_MODEL; use codex_protocol::config_types::SandboxMode; +use codex_protocol::user_input::UserInput; use event_processor_with_human_output::EventProcessorWithHumanOutput; use event_processor_with_jsonl_output::EventProcessorWithJsonOutput; use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge; @@ -328,9 +328,9 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any // Send images first, if any. if !images.is_empty() { - let items: Vec = images + let items: Vec = images .into_iter() - .map(|path| InputItem::LocalImage { path }) + .map(|path| UserInput::LocalImage { path }) .collect(); let initial_images_event_id = conversation.submit(Op::UserInput { items }).await?; info!("Sent images with event ID: {initial_images_event_id}"); @@ -349,7 +349,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any } // Send the prompt. - let items: Vec = vec![InputItem::Text { text: prompt }]; + let items: Vec = vec![UserInput::Text { text: prompt }]; let initial_prompt_task_id = conversation .submit(Op::UserTurn { items, diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 370dde0e82..a59755008d 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -18,11 +18,11 @@ use codex_core::protocol::ApplyPatchApprovalRequestEvent; use codex_core::protocol::Event; use codex_core::protocol::EventMsg; use codex_core::protocol::ExecApprovalRequestEvent; -use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::Submission; use codex_core::protocol::TaskCompleteEvent; use codex_protocol::ConversationId; +use codex_protocol::user_input::UserInput; use mcp_types::CallToolResult; use mcp_types::ContentBlock; use mcp_types::RequestId; @@ -91,7 +91,7 @@ pub async fn run_codex_tool_session( let submission = Submission { id: sub_id.clone(), op: Op::UserInput { - items: vec![InputItem::Text { + items: vec![UserInput::Text { text: initial_prompt.clone(), }], }, @@ -127,7 +127,7 @@ pub async fn run_codex_tool_session_reply( .insert(request_id.clone(), conversation_id); if let Err(e) = conversation .submit(Op::UserInput { - items: vec![InputItem::Text { text: prompt }], + items: vec![UserInput::Text { text: prompt }], }) .await { @@ -284,6 +284,8 @@ async fn run_codex_tool_session_inner( | EventMsg::ShutdownComplete | EventMsg::ViewImageToolCall(_) | EventMsg::EnteredReviewMode(_) + | EventMsg::ItemStarted(_) + | EventMsg::ItemCompleted(_) | EventMsg::ExitedReviewMode(_) => { // For now, we do not do anything extra for these // events. Note that diff --git a/codex-rs/otel/src/otel_event_manager.rs b/codex-rs/otel/src/otel_event_manager.rs index 666d83f822..486683dae0 100644 --- a/codex-rs/otel/src/otel_event_manager.rs +++ b/codex-rs/otel/src/otel_event_manager.rs @@ -6,9 +6,9 @@ use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::InputItem; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::user_input::UserInput; use eventsource_stream::Event as StreamEvent; use eventsource_stream::EventStreamError as StreamError; use reqwest::Error; @@ -308,11 +308,11 @@ impl OtelEventManager { ); } - pub fn user_prompt(&self, items: &[InputItem]) { + pub fn user_prompt(&self, items: &[UserInput]) { let prompt = items .iter() .flat_map(|item| match item { - InputItem::Text { text } => Some(text.as_str()), + UserInput::Text { text } => Some(text.as_str()), _ => None, }) .collect::(); diff --git a/codex-rs/protocol/Cargo.toml b/codex-rs/protocol/Cargo.toml index f21d326610..0393c85427 100644 --- a/codex-rs/protocol/Cargo.toml +++ b/codex-rs/protocol/Cargo.toml @@ -29,7 +29,7 @@ ts-rs = { workspace = true, features = [ "serde-json-impl", "no-serde-warnings", ] } -uuid = { workspace = true, features = ["serde", "v7"] } +uuid = { workspace = true, features = ["serde", "v7", "v4"] } [dev-dependencies] anyhow = { workspace = true } diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs new file mode 100644 index 0000000000..e1efc9525b --- /dev/null +++ b/codex-rs/protocol/src/items.rs @@ -0,0 +1,33 @@ +use crate::user_input::UserInput; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] +pub enum TurnItem { + UserMessage(UserMessageItem), +} + +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] +pub struct UserMessageItem { + pub id: String, + pub content: Vec, +} + +impl UserMessageItem { + pub fn new(content: &[UserInput]) -> Self { + Self { + id: uuid::Uuid::new_v4().to_string(), + content: content.to_vec(), + } + } +} + +impl TurnItem { + pub fn id(&self) -> String { + match self { + TurnItem::UserMessage(item) => item.id.clone(), + } + } +} diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index 11ab0b3fd6..e79eff3f56 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -2,9 +2,11 @@ mod conversation_id; pub use conversation_id::ConversationId; pub mod config_types; pub mod custom_prompts; +pub mod items; pub mod message_history; pub mod models; pub mod num_format; pub mod parse_command; pub mod plan_tool; pub mod protocol; +pub mod user_input; diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 285ae666f9..4e99455bd2 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -8,7 +8,7 @@ use serde::Serialize; use serde::ser::Serializer; use ts_rs::TS; -use crate::protocol::InputItem; +use crate::user_input::UserInput; use schemars::JsonSchema; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] @@ -206,16 +206,16 @@ pub enum ReasoningItemContent { Text { text: String }, } -impl From> for ResponseInputItem { - fn from(items: Vec) -> Self { +impl From> for ResponseInputItem { + fn from(items: Vec) -> Self { Self::Message { role: "user".to_string(), content: items .into_iter() .filter_map(|c| match c { - InputItem::Text { text } => Some(ContentItem::InputText { text }), - InputItem::Image { image_url } => Some(ContentItem::InputImage { image_url }), - InputItem::LocalImage { path } => match std::fs::read(&path) { + UserInput::Text { text } => Some(ContentItem::InputText { text }), + UserInput::Image { image_url } => Some(ContentItem::InputImage { image_url }), + UserInput::LocalImage { path } => match std::fs::read(&path) { Ok(bytes) => { let mime = mime_guess::from_path(&path) .first() diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index e22981745c..c683bb235c 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -14,12 +14,14 @@ use crate::ConversationId; use crate::config_types::ReasoningEffort as ReasoningEffortConfig; use crate::config_types::ReasoningSummary as ReasoningSummaryConfig; use crate::custom_prompts::CustomPrompt; +use crate::items::TurnItem; use crate::message_history::HistoryEntry; use crate::models::ContentItem; use crate::models::ResponseItem; use crate::num_format::format_with_separators; use crate::parse_command::ParsedCommand; use crate::plan_tool::UpdatePlanArgs; +use crate::user_input::UserInput; use mcp_types::CallToolResult; use mcp_types::Resource as McpResource; use mcp_types::ResourceTemplate as McpResourceTemplate; @@ -62,14 +64,14 @@ pub enum Op { /// Input from the user UserInput { /// User input items, see `InputItem` - items: Vec, + items: Vec, }, /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_conversation::CodexConversation`]. UserTurn { /// User input items, see `InputItem` - items: Vec, + items: Vec, /// `cwd` to use with the [`SandboxPolicy`] and potentially tool calls /// such as `local_shell`. @@ -403,28 +405,8 @@ impl SandboxPolicy { } } -/// User input -#[non_exhaustive] -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum InputItem { - Text { - text: String, - }, - /// Pre‑encoded data: URI image. - Image { - image_url: String, - }, - - /// Local image path provided by the user. This will be converted to an - /// `Image` variant (base64 data URL) during request serialization. - LocalImage { - path: std::path::PathBuf, - }, -} - /// Event Queue Entry - events from agent -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct Event { /// Submission `id` that this event is correlated with. pub id: String, @@ -538,6 +520,23 @@ pub enum EventMsg { /// Exited review mode with an optional final result to apply. ExitedReviewMode(ExitedReviewModeEvent), + + ItemStarted(ItemStartedEvent), + ItemCompleted(ItemCompletedEvent), +} + +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] +pub struct ItemStartedEvent { + pub thread_id: ConversationId, + pub turn_id: String, + pub item: TurnItem, +} + +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] +pub struct ItemCompletedEvent { + pub thread_id: ConversationId, + pub turn_id: String, + pub item: TurnItem, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] diff --git a/codex-rs/protocol/src/user_input.rs b/codex-rs/protocol/src/user_input.rs new file mode 100644 index 0000000000..881b996514 --- /dev/null +++ b/codex-rs/protocol/src/user_input.rs @@ -0,0 +1,24 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +/// User input +#[non_exhaustive] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum UserInput { + Text { + text: String, + }, + /// Pre‑encoded data: URI image. + Image { + image_url: String, + }, + + /// Local image path provided by the user. This will be converted to an + /// `Image` variant (base64 data URL) during request serialization. + LocalImage { + path: std::path::PathBuf, + }, +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 8dc6454e4b..ff1fee20dc 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -23,7 +23,6 @@ use codex_core::protocol::ExecApprovalRequestEvent; use codex_core::protocol::ExecCommandBeginEvent; use codex_core::protocol::ExecCommandEndEvent; use codex_core::protocol::ExitedReviewModeEvent; -use codex_core::protocol::InputItem; use codex_core::protocol::InputMessageKind; use codex_core::protocol::ListCustomPromptsResponseEvent; use codex_core::protocol::McpListToolsResponseEvent; @@ -45,6 +44,7 @@ use codex_core::protocol::WebSearchBeginEvent; use codex_core::protocol::WebSearchEndEvent; use codex_protocol::ConversationId; use codex_protocol::parse_command::ParsedCommand; +use codex_protocol::user_input::UserInput; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -1316,14 +1316,14 @@ impl ChatWidget { self.capture_ghost_snapshot(); - let mut items: Vec = Vec::new(); + let mut items: Vec = Vec::new(); if !text.is_empty() { - items.push(InputItem::Text { text: text.clone() }); + items.push(UserInput::Text { text: text.clone() }); } for path in image_paths { - items.push(InputItem::LocalImage { path }); + items.push(UserInput::LocalImage { path }); } self.codex_op_tx @@ -1510,6 +1510,7 @@ impl ChatWidget { self.on_entered_review_mode(review_request) } EventMsg::ExitedReviewMode(review) => self.on_exited_review_mode(review), + EventMsg::ItemStarted(_) | EventMsg::ItemCompleted(_) => {} } } From 39a24467162c72b5e6de39a0cb4a5d1e6dc9d8ce Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Mon, 20 Oct 2025 14:08:19 -0700 Subject: [PATCH 043/287] tui: drop citation rendering (#4855) We don't instruct the model to use citations, so it never emits them. Further, ratatui [doesn't currently support rendering links into the terminal with OSC 8](https://github.com/ratatui/ratatui/issues/1028), so even if we did parse citations, we can't correctly render them. So, remove all the code related to rendering them. --- codex-rs/Cargo.lock | 7 - codex-rs/Cargo.toml | 1 - codex-rs/tui/Cargo.toml | 1 - codex-rs/tui/src/chatwidget.rs | 5 +- codex-rs/tui/src/citation_regex.rs | 22 --- codex-rs/tui/src/history_cell.rs | 13 +- codex-rs/tui/src/lib.rs | 1 - codex-rs/tui/src/markdown.rs | 175 ++++------------------- codex-rs/tui/src/markdown_render.rs | 146 ++----------------- codex-rs/tui/src/markdown_stream.rs | 97 ++++--------- codex-rs/tui/src/streaming/controller.rs | 29 +--- 11 files changed, 79 insertions(+), 418 deletions(-) delete mode 100644 codex-rs/tui/src/citation_regex.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 46955c4342..d8e86be338 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1446,7 +1446,6 @@ dependencies = [ "libc", "mcp-types", "opentelemetry-appender-tracing", - "path-clean", "pathdiff", "pretty_assertions", "pulldown-cmark", @@ -4277,12 +4276,6 @@ dependencies = [ "path-dedot", ] -[[package]] -name = "path-clean" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17359afc20d7ab31fdb42bb844c8b3bb1dabd7dcf7e68428492da7f16966fcef" - [[package]] name = "path-dedot" version = "3.1.1" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index c0493c39d6..d2700a621f 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -142,7 +142,6 @@ os_info = "3.12.0" owo-colors = "4.2.0" paste = "1.0.15" path-absolutize = "3.1.1" -path-clean = "1.0.1" pathdiff = "0.2" portable-pty = "0.9.0" predicates = "3" diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 41ffa31030..e8cb0fa5e0 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -50,7 +50,6 @@ image = { workspace = true, features = ["jpeg", "png"] } itertools = { workspace = true } lazy_static = { workspace = true } mcp-types = { workspace = true } -path-clean = { workspace = true } pathdiff = { workspace = true } pulldown-cmark = { workspace = true } rand = { workspace = true } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ff1fee20dc..18bda8837c 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -715,7 +715,6 @@ impl ChatWidget { self.needs_final_message_separator = false; } self.stream_controller = Some(StreamController::new( - self.config.clone(), self.last_rendered_width.get().map(|w| w.saturating_sub(2)), )); } @@ -1539,7 +1538,7 @@ impl ChatWidget { } else { // Show explanation when there are no structured findings. let mut rendered: Vec> = vec!["".into()]; - append_markdown(&explanation, None, &mut rendered, &self.config); + append_markdown(&explanation, None, &mut rendered); let body_cell = AgentMessageCell::new(rendered, false); self.app_event_tx .send(AppEvent::InsertHistoryCell(Box::new(body_cell))); @@ -1548,7 +1547,7 @@ impl ChatWidget { let message_text = codex_core::review_format::format_review_findings_block(&output.findings, None); let mut message_lines: Vec> = Vec::new(); - append_markdown(&message_text, None, &mut message_lines, &self.config); + append_markdown(&message_text, None, &mut message_lines); let body_cell = AgentMessageCell::new(message_lines, true); self.app_event_tx .send(AppEvent::InsertHistoryCell(Box::new(body_cell))); diff --git a/codex-rs/tui/src/citation_regex.rs b/codex-rs/tui/src/citation_regex.rs deleted file mode 100644 index ff5147ca83..0000000000 --- a/codex-rs/tui/src/citation_regex.rs +++ /dev/null @@ -1,22 +0,0 @@ -#![expect(clippy::expect_used)] - -use regex_lite::Regex; - -// This is defined in its own file so we can limit the scope of -// `allow(clippy::expect_used)` because we cannot scope it to the `lazy_static!` -// macro. -lazy_static::lazy_static! { - /// Regular expression that matches Codex-style source file citations such as: - /// - /// ```text - /// 【F:src/main.rs†L10-L20】 - /// ``` - /// - /// Capture groups: - /// 1. file path (anything except the dagger `†` symbol) - /// 2. start line number (digits) - /// 3. optional end line (digits or `?`) - pub(crate) static ref CITATION_REGEX: Regex = Regex::new( - r"【F:([^†]+)†L(\d+)(?:-L(\d+|\?))?】" - ).expect("failed to compile citation regex"); -} diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 5df687b998..4cbf25ae15 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -7,7 +7,6 @@ use crate::exec_cell::output_lines; use crate::exec_cell::spinner; use crate::exec_command::relativize_to_home; use crate::exec_command::strip_bash_lc_and_escape; -use crate::markdown::MarkdownCitationContext; use crate::markdown::append_markdown; use crate::render::line_utils::line_to_static; use crate::render::line_utils::prefix_lines; @@ -147,21 +146,14 @@ impl HistoryCell for UserHistoryCell { pub(crate) struct ReasoningSummaryCell { _header: String, content: String, - citation_context: MarkdownCitationContext, transcript_only: bool, } impl ReasoningSummaryCell { - pub(crate) fn new( - header: String, - content: String, - citation_context: MarkdownCitationContext, - transcript_only: bool, - ) -> Self { + pub(crate) fn new(header: String, content: String, transcript_only: bool) -> Self { Self { _header: header, content, - citation_context, transcript_only, } } @@ -172,7 +164,6 @@ impl ReasoningSummaryCell { &self.content, Some((width as usize).saturating_sub(2)), &mut lines, - self.citation_context.clone(), ); let summary_style = Style::default().dim().italic(); let summary_lines = lines @@ -1303,7 +1294,6 @@ pub(crate) fn new_reasoning_summary_block( return Box::new(ReasoningSummaryCell::new( header_buffer, summary_buffer, - config.into(), false, )); } @@ -1313,7 +1303,6 @@ pub(crate) fn new_reasoning_summary_block( Box::new(ReasoningSummaryCell::new( "".to_string(), full_reasoning_buffer, - config.into(), true, )) } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 6b13d6f91f..83a35f79c8 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -36,7 +36,6 @@ mod app_event_sender; mod ascii_animation; mod bottom_pane; mod chatwidget; -mod citation_regex; mod cli; mod clipboard_paste; mod color; diff --git a/codex-rs/tui/src/markdown.rs b/codex-rs/tui/src/markdown.rs index 5ec3a27f84..2ea307066b 100644 --- a/codex-rs/tui/src/markdown.rs +++ b/codex-rs/tui/src/markdown.rs @@ -1,59 +1,10 @@ -use codex_core::config::Config; -use codex_core::config_types::UriBasedFileOpener; use ratatui::text::Line; -use std::path::Path; -use std::path::PathBuf; - -#[derive(Clone, Debug)] -pub struct MarkdownCitationContext { - file_opener: UriBasedFileOpener, - cwd: PathBuf, -} - -impl MarkdownCitationContext { - pub(crate) fn new(file_opener: UriBasedFileOpener, cwd: PathBuf) -> Self { - Self { file_opener, cwd } - } -} - -impl From<&Config> for MarkdownCitationContext { - fn from(config: &Config) -> Self { - MarkdownCitationContext::new(config.file_opener, config.cwd.clone()) - } -} - -pub(crate) fn append_markdown( +pub(crate) fn append_markdown( markdown_source: &str, width: Option, lines: &mut Vec>, - citation_context: C, -) where - C: Into, -{ - let citation_context: MarkdownCitationContext = citation_context.into(); - append_markdown_with_opener_and_cwd( - markdown_source, - width, - lines, - citation_context.file_opener, - &citation_context.cwd, - ); -} - -pub(crate) fn append_markdown_with_opener_and_cwd( - markdown_source: &str, - width: Option, - lines: &mut Vec>, - file_opener: UriBasedFileOpener, - cwd: &Path, ) { - // Render via pulldown-cmark and rewrite citations during traversal (outside code blocks). - let rendered = crate::markdown_render::render_markdown_text_with_citations( - markdown_source, - width, - file_opener.get_scheme(), - cwd, - ); + let rendered = crate::markdown_render::render_markdown_text_with_width(markdown_source, width); crate::render::line_utils::push_owned_lines(&rendered.lines, lines); } @@ -61,14 +12,10 @@ pub(crate) fn append_markdown_with_opener_and_cwd( mod tests { use super::*; use pretty_assertions::assert_eq; + use ratatui::text::Line; - #[test] - fn citations_not_rewritten_inside_code_blocks() { - let src = "Before 【F:/x.rs†L1】\n```\nInside 【F:/x.rs†L2】\n```\nAfter 【F:/x.rs†L3】\n"; - let cwd = Path::new("/"); - let mut out = Vec::new(); - append_markdown_with_opener_and_cwd(src, None, &mut out, UriBasedFileOpener::VsCode, cwd); - let rendered: Vec = out + fn lines_to_strings(lines: &[Line<'static>]) -> Vec { + lines .iter() .map(|l| { l.spans @@ -76,21 +23,21 @@ mod tests { .map(|s| s.content.clone()) .collect::() }) - .collect(); - // Expect a line containing the inside text unchanged. - assert!(rendered.iter().any(|s| s.contains("Inside 【F:/x.rs†L2】"))); - // And first/last sections rewritten. - assert!( - rendered - .first() - .map(|s| s.contains("vscode://file")) - .unwrap_or(false) - ); - assert!( - rendered - .last() - .map(|s| s.contains("vscode://file")) - .unwrap_or(false) + .collect() + } + + #[test] + fn citations_render_as_plain_text() { + let src = "Before 【F:/x.rs†L1】\nAfter 【F:/x.rs†L3】\n"; + let mut out = Vec::new(); + append_markdown(src, None, &mut out); + let rendered = lines_to_strings(&out); + assert_eq!( + rendered, + vec![ + "Before 【F:/x.rs†L1】".to_string(), + "After 【F:/x.rs†L3】".to_string() + ] ); } @@ -98,57 +45,17 @@ mod tests { fn indented_code_blocks_preserve_leading_whitespace() { // Basic sanity: indented code with surrounding blank lines should produce the indented line. let src = "Before\n\n code 1\n\nAfter\n"; - let cwd = Path::new("/"); let mut out = Vec::new(); - append_markdown_with_opener_and_cwd(src, None, &mut out, UriBasedFileOpener::None, cwd); - let lines: Vec = out - .iter() - .map(|l| { - l.spans - .iter() - .map(|s| s.content.clone()) - .collect::() - }) - .collect(); + append_markdown(src, None, &mut out); + let lines = lines_to_strings(&out); assert_eq!(lines, vec!["Before", "", " code 1", "", "After"]); } - #[test] - fn citations_not_rewritten_inside_indented_code_blocks() { - let src = "Start 【F:/x.rs†L1】\n\n Inside 【F:/x.rs†L2】\n\nEnd 【F:/x.rs†L3】\n"; - let cwd = Path::new("/"); - let mut out = Vec::new(); - append_markdown_with_opener_and_cwd(src, None, &mut out, UriBasedFileOpener::VsCode, cwd); - let rendered: Vec = out - .iter() - .map(|l| { - l.spans - .iter() - .map(|s| s.content.clone()) - .collect::() - }) - .collect(); - assert!( - rendered - .iter() - .any(|s| s.contains("Start") && s.contains("vscode://file")) - ); - assert!( - rendered - .iter() - .any(|s| s.contains("End") && s.contains("vscode://file")) - ); - assert!(rendered.iter().any(|s| s.contains("Inside 【F:/x.rs†L2】"))); - } - #[test] fn append_markdown_preserves_full_text_line() { - use codex_core::config_types::UriBasedFileOpener; - use std::path::Path; let src = "Hi! How can I help with codex-rs today? Want me to explore the repo, run tests, or work on a specific change?\n"; - let cwd = Path::new("/"); let mut out = Vec::new(); - append_markdown_with_opener_and_cwd(src, None, &mut out, UriBasedFileOpener::None, cwd); + append_markdown(src, None, &mut out); assert_eq!( out.len(), 1, @@ -168,47 +75,19 @@ mod tests { #[test] fn append_markdown_matches_tui_markdown_for_ordered_item() { - use codex_core::config_types::UriBasedFileOpener; - use std::path::Path; - let cwd = Path::new("/"); let mut out = Vec::new(); - append_markdown_with_opener_and_cwd( - "1. Tight item\n", - None, - &mut out, - UriBasedFileOpener::None, - cwd, - ); - let lines: Vec = out - .iter() - .map(|l| { - l.spans - .iter() - .map(|s| s.content.clone()) - .collect::() - }) - .collect(); + append_markdown("1. Tight item\n", None, &mut out); + let lines = lines_to_strings(&out); assert_eq!(lines, vec!["1. Tight item".to_string()]); } #[test] fn append_markdown_keeps_ordered_list_line_unsplit_in_context() { - use codex_core::config_types::UriBasedFileOpener; - use std::path::Path; let src = "Loose vs. tight list items:\n1. Tight item\n"; - let cwd = Path::new("/"); let mut out = Vec::new(); - append_markdown_with_opener_and_cwd(src, None, &mut out, UriBasedFileOpener::None, cwd); + append_markdown(src, None, &mut out); - let lines: Vec = out - .iter() - .map(|l| { - l.spans - .iter() - .map(|s| s.content.clone()) - .collect::() - }) - .collect(); + let lines = lines_to_strings(&out); // Expect to find the ordered list line rendered as a single line, // not split into a marker-only line followed by the text. diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index d945b39cb3..099d0860c2 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -1,4 +1,3 @@ -use crate::citation_regex::CITATION_REGEX; use crate::render::line_utils::line_to_static; use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; @@ -15,8 +14,6 @@ use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; use ratatui::text::Text; -use std::borrow::Cow; -use std::path::Path; #[derive(Clone, Debug)] struct IndentContext { @@ -36,29 +33,14 @@ impl IndentContext { } pub fn render_markdown_text(input: &str) -> Text<'static> { - let mut options = Options::empty(); - options.insert(Options::ENABLE_STRIKETHROUGH); - let parser = Parser::new_ext(input, options); - let mut w = Writer::new(parser, None, None, None); - w.run(); - w.text + render_markdown_text_with_width(input, None) } -pub(crate) fn render_markdown_text_with_citations( - input: &str, - width: Option, - scheme: Option<&str>, - cwd: &Path, -) -> Text<'static> { +pub(crate) fn render_markdown_text_with_width(input: &str, width: Option) -> Text<'static> { let mut options = Options::empty(); options.insert(Options::ENABLE_STRIKETHROUGH); let parser = Parser::new_ext(input, options); - let mut w = Writer::new( - parser, - scheme.map(str::to_string), - Some(cwd.to_path_buf()), - width, - ); + let mut w = Writer::new(parser, width); w.run(); w.text } @@ -76,8 +58,6 @@ where needs_newline: bool, pending_marker_line: bool, in_paragraph: bool, - scheme: Option, - cwd: Option, in_code_block: bool, wrap_width: Option, current_line_content: Option>, @@ -91,12 +71,7 @@ impl<'a, I> Writer<'a, I> where I: Iterator>, { - fn new( - iter: I, - scheme: Option, - cwd: Option, - wrap_width: Option, - ) -> Self { + fn new(iter: I, wrap_width: Option) -> Self { Self { iter, text: Text::default(), @@ -107,8 +82,6 @@ where needs_newline: false, pending_marker_line: false, in_paragraph: false, - scheme, - cwd, in_code_block: false, wrap_width, current_line_content: None, @@ -288,15 +261,7 @@ where if i > 0 { self.push_line(Line::default()); } - let mut content = line.to_string(); - if !self.in_code_block - && let (Some(scheme), Some(cwd)) = (&self.scheme, &self.cwd) - { - let cow = rewrite_file_citations_with_scheme(&content, Some(scheme.as_str()), cwd); - if let std::borrow::Cow::Owned(s) = cow { - content = s; - } - } + let content = line.to_string(); let span = Span::styled( content, self.inline_styles.last().copied().unwrap_or_default(), @@ -524,44 +489,6 @@ where } } -pub(crate) fn rewrite_file_citations_with_scheme<'a>( - src: &'a str, - scheme_opt: Option<&str>, - cwd: &Path, -) -> Cow<'a, str> { - let scheme: &str = match scheme_opt { - Some(s) => s, - None => return Cow::Borrowed(src), - }; - - CITATION_REGEX.replace_all(src, |caps: ®ex_lite::Captures<'_>| { - let file = &caps[1]; - let start_line = &caps[2]; - - // Resolve the path against `cwd` when it is relative. - let absolute_path = { - let p = Path::new(file); - let absolute_path = if p.is_absolute() { - path_clean::clean(p) - } else { - path_clean::clean(cwd.join(p)) - }; - // VS Code expects forward slashes even on Windows because URIs use - // `/` as the path separator. - absolute_path.to_string_lossy().replace('\\', "/") - }; - - // Render as a normal markdown link so the downstream renderer emits - // the hyperlink escape sequence (when supported by the terminal). - // - // In practice, sometimes multiple citations for the same file, but with a - // different line number, are shown sequentially, so we: - // - include the line number in the label to disambiguate them - // - add a space after the link to make it easier to read - format!("[{file}:{start_line}]({scheme}://file{absolute_path}:{start_line}) ") - }) -} - #[cfg(test)] mod markdown_render_tests { include!("markdown_render_tests.rs"); @@ -585,50 +512,10 @@ mod tests { .collect() } - #[test] - fn citation_is_rewritten_with_absolute_path() { - let markdown = "See 【F:/src/main.rs†L42-L50】 for details."; - let cwd = Path::new("/workspace"); - let result = rewrite_file_citations_with_scheme(markdown, Some("vscode"), cwd); - - assert_eq!( - "See [/src/main.rs:42](vscode://file/src/main.rs:42) for details.", - result - ); - } - - #[test] - fn citation_followed_by_space_so_they_do_not_run_together() { - let markdown = "References on lines 【F:src/foo.rs†L24】【F:src/foo.rs†L42】"; - let cwd = Path::new("/home/user/project"); - let result = rewrite_file_citations_with_scheme(markdown, Some("vscode"), cwd); - - assert_eq!( - "References on lines [src/foo.rs:24](vscode://file/home/user/project/src/foo.rs:24) [src/foo.rs:42](vscode://file/home/user/project/src/foo.rs:42) ", - result - ); - } - - #[test] - fn citation_unchanged_without_file_opener() { - let markdown = "Look at 【F:file.rs†L1】."; - let cwd = Path::new("/"); - let unchanged = rewrite_file_citations_with_scheme(markdown, Some("vscode"), cwd); - // The helper itself always rewrites – this test validates behaviour of - // append_markdown when `file_opener` is None. - let rendered = render_markdown_text_with_citations(markdown, None, None, cwd); - // Convert lines back to string for comparison. - let rendered: String = lines_to_strings(&rendered).join(""); - assert_eq!(markdown, rendered); - // Ensure helper rewrites. - assert_ne!(markdown, unchanged); - } - #[test] fn wraps_plain_text_when_width_provided() { let markdown = "This is a simple sentence that should wrap."; - let cwd = Path::new("/"); - let rendered = render_markdown_text_with_citations(markdown, Some(16), None, cwd); + let rendered = render_markdown_text_with_width(markdown, Some(16)); let lines = lines_to_strings(&rendered); assert_eq!( lines, @@ -643,8 +530,7 @@ mod tests { #[test] fn wraps_list_items_preserving_indent() { let markdown = "- first second third fourth"; - let cwd = Path::new("/"); - let rendered = render_markdown_text_with_citations(markdown, Some(14), None, cwd); + let rendered = render_markdown_text_with_width(markdown, Some(14)); let lines = lines_to_strings(&rendered); assert_eq!( lines, @@ -656,8 +542,7 @@ mod tests { fn wraps_nested_lists() { let markdown = "- outer item with several words to wrap\n - inner item that also needs wrapping"; - let cwd = Path::new("/"); - let rendered = render_markdown_text_with_citations(markdown, Some(20), None, cwd); + let rendered = render_markdown_text_with_width(markdown, Some(20)); let lines = lines_to_strings(&rendered); assert_eq!( lines, @@ -675,8 +560,7 @@ mod tests { #[test] fn wraps_ordered_lists() { let markdown = "1. ordered item contains many words for wrapping"; - let cwd = Path::new("/"); - let rendered = render_markdown_text_with_citations(markdown, Some(18), None, cwd); + let rendered = render_markdown_text_with_width(markdown, Some(18)); let lines = lines_to_strings(&rendered); assert_eq!( lines, @@ -692,8 +576,7 @@ mod tests { #[test] fn wraps_blockquotes() { let markdown = "> block quote with content that should wrap nicely"; - let cwd = Path::new("/"); - let rendered = render_markdown_text_with_citations(markdown, Some(22), None, cwd); + let rendered = render_markdown_text_with_width(markdown, Some(22)); let lines = lines_to_strings(&rendered); assert_eq!( lines, @@ -708,8 +591,7 @@ mod tests { #[test] fn wraps_blockquotes_inside_lists() { let markdown = "- list item\n > block quote inside list that wraps"; - let cwd = Path::new("/"); - let rendered = render_markdown_text_with_citations(markdown, Some(24), None, cwd); + let rendered = render_markdown_text_with_width(markdown, Some(24)); let lines = lines_to_strings(&rendered); assert_eq!( lines, @@ -724,8 +606,7 @@ mod tests { #[test] fn wraps_list_items_containing_blockquotes() { let markdown = "1. item with quote\n > quoted text that should wrap"; - let cwd = Path::new("/"); - let rendered = render_markdown_text_with_citations(markdown, Some(24), None, cwd); + let rendered = render_markdown_text_with_width(markdown, Some(24)); let lines = lines_to_strings(&rendered); assert_eq!( lines, @@ -740,8 +621,7 @@ mod tests { #[test] fn does_not_wrap_code_blocks() { let markdown = "````\nfn main() { println!(\"hi from a long line\"); }\n````"; - let cwd = Path::new("/"); - let rendered = render_markdown_text_with_citations(markdown, Some(10), None, cwd); + let rendered = render_markdown_text_with_width(markdown, Some(10)); let lines = lines_to_strings(&rendered); assert_eq!( lines, diff --git a/codex-rs/tui/src/markdown_stream.rs b/codex-rs/tui/src/markdown_stream.rs index 21e098c2b9..6ac457eee2 100644 --- a/codex-rs/tui/src/markdown_stream.rs +++ b/codex-rs/tui/src/markdown_stream.rs @@ -1,4 +1,3 @@ -use codex_core::config::Config; use ratatui::text::Line; use crate::markdown; @@ -33,7 +32,7 @@ impl MarkdownStreamCollector { /// Render the full buffer and return only the newly completed logical lines /// since the last commit. When the buffer does not end with a newline, the /// final rendered line is considered incomplete and is not emitted. - pub fn commit_complete_lines(&mut self, config: &Config) -> Vec> { + pub fn commit_complete_lines(&mut self) -> Vec> { let source = self.buffer.clone(); let last_newline_idx = source.rfind('\n'); let source = if let Some(last_newline_idx) = last_newline_idx { @@ -42,7 +41,7 @@ impl MarkdownStreamCollector { return Vec::new(); }; let mut rendered: Vec> = Vec::new(); - markdown::append_markdown(&source, self.width, &mut rendered, config); + markdown::append_markdown(&source, self.width, &mut rendered); let mut complete_line_count = rendered.len(); if complete_line_count > 0 && crate::render::line_utils::is_blank_line_spaces_only( @@ -67,7 +66,7 @@ impl MarkdownStreamCollector { /// If the buffer does not end with a newline, a temporary one is appended /// for rendering. Optionally unwraps ```markdown language fences in /// non-test builds. - pub fn finalize_and_drain(&mut self, config: &Config) -> Vec> { + pub fn finalize_and_drain(&mut self) -> Vec> { let raw_buffer = self.buffer.clone(); let mut source: String = raw_buffer.clone(); if !source.ends_with('\n') { @@ -83,7 +82,7 @@ impl MarkdownStreamCollector { tracing::trace!("markdown finalize (raw source):\n---\n{source}\n---"); let mut rendered: Vec> = Vec::new(); - markdown::append_markdown(&source, self.width, &mut rendered, config); + markdown::append_markdown(&source, self.width, &mut rendered); let out = if self.committed_line_count >= rendered.len() { Vec::new() @@ -101,18 +100,17 @@ impl MarkdownStreamCollector { pub(crate) fn simulate_stream_markdown_for_tests( deltas: &[&str], finalize: bool, - config: &Config, ) -> Vec> { let mut collector = MarkdownStreamCollector::new(None); let mut out = Vec::new(); for d in deltas { collector.push_delta(d); if d.contains('\n') { - out.extend(collector.commit_complete_lines(config)); + out.extend(collector.commit_complete_lines()); } } if finalize { - out.extend(collector.finalize_and_drain(config)); + out.extend(collector.finalize_and_drain()); } out } @@ -120,45 +118,30 @@ pub(crate) fn simulate_stream_markdown_for_tests( #[cfg(test)] mod tests { use super::*; - use codex_core::config::Config; - use codex_core::config::ConfigOverrides; use ratatui::style::Color; - async fn test_config() -> Config { - let overrides = ConfigOverrides { - cwd: std::env::current_dir().ok(), - ..Default::default() - }; - Config::load_with_cli_overrides(vec![], overrides) - .await - .expect("load test config") - } - #[tokio::test] async fn no_commit_until_newline() { - let cfg = test_config().await; let mut c = super::MarkdownStreamCollector::new(None); c.push_delta("Hello, world"); - let out = c.commit_complete_lines(&cfg); + let out = c.commit_complete_lines(); assert!(out.is_empty(), "should not commit without newline"); c.push_delta("!\n"); - let out2 = c.commit_complete_lines(&cfg); + let out2 = c.commit_complete_lines(); assert_eq!(out2.len(), 1, "one completed line after newline"); } #[tokio::test] async fn finalize_commits_partial_line() { - let cfg = test_config().await; let mut c = super::MarkdownStreamCollector::new(None); c.push_delta("Line without newline"); - let out = c.finalize_and_drain(&cfg); + let out = c.finalize_and_drain(); assert_eq!(out.len(), 1); } #[tokio::test] async fn e2e_stream_blockquote_simple_is_green() { - let cfg = test_config().await; - let out = super::simulate_stream_markdown_for_tests(&["> Hello\n"], true, &cfg); + let out = super::simulate_stream_markdown_for_tests(&["> Hello\n"], true); assert_eq!(out.len(), 1); let l = &out[0]; assert_eq!( @@ -171,9 +154,7 @@ mod tests { #[tokio::test] async fn e2e_stream_blockquote_nested_is_green() { - let cfg = test_config().await; - let out = - super::simulate_stream_markdown_for_tests(&["> Level 1\n>> Level 2\n"], true, &cfg); + let out = super::simulate_stream_markdown_for_tests(&["> Level 1\n>> Level 2\n"], true); // Filter out any blank lines that may be inserted at paragraph starts. let non_blank: Vec<_> = out .into_iter() @@ -196,9 +177,7 @@ mod tests { #[tokio::test] async fn e2e_stream_blockquote_with_list_items_is_green() { - let cfg = test_config().await; - let out = - super::simulate_stream_markdown_for_tests(&["> - item 1\n> - item 2\n"], true, &cfg); + let out = super::simulate_stream_markdown_for_tests(&["> - item 1\n> - item 2\n"], true); assert_eq!(out.len(), 2); assert_eq!(out[0].style.fg, Some(Color::Green)); assert_eq!(out[1].style.fg, Some(Color::Green)); @@ -206,7 +185,6 @@ mod tests { #[tokio::test] async fn e2e_stream_nested_mixed_lists_ordered_marker_is_light_blue() { - let cfg = test_config().await; let md = [ "1. First\n", " - Second level\n", @@ -214,7 +192,7 @@ mod tests { " - Fourth level (bullet)\n", " - Fifth level to test indent consistency\n", ]; - let out = super::simulate_stream_markdown_for_tests(&md, true, &cfg); + let out = super::simulate_stream_markdown_for_tests(&md, true); // Find the line that contains the third-level ordered text let find_idx = out.iter().position(|l| { l.spans @@ -238,9 +216,8 @@ mod tests { #[tokio::test] async fn e2e_stream_blockquote_wrap_preserves_green_style() { - let cfg = test_config().await; let long = "> This is a very long quoted line that should wrap across multiple columns to verify style preservation."; - let out = super::simulate_stream_markdown_for_tests(&[long, "\n"], true, &cfg); + let out = super::simulate_stream_markdown_for_tests(&[long, "\n"], true); // Wrap to a narrow width to force multiple output lines. let wrapped = crate::wrapping::word_wrap_lines(out.iter(), crate::wrapping::RtOptions::new(24)); @@ -274,13 +251,11 @@ mod tests { #[tokio::test] async fn heading_starts_on_new_line_when_following_paragraph() { - let cfg = test_config().await; - // Stream a paragraph line, then a heading on the next line. // Expect two distinct rendered lines: "Hello." and "Heading". let mut c = super::MarkdownStreamCollector::new(None); c.push_delta("Hello.\n"); - let out1 = c.commit_complete_lines(&cfg); + let out1 = c.commit_complete_lines(); let s1: Vec = out1 .iter() .map(|l| { @@ -300,7 +275,7 @@ mod tests { ); c.push_delta("## Heading\n"); - let out2 = c.commit_complete_lines(&cfg); + let out2 = c.commit_complete_lines(); let s2: Vec = out2 .iter() .map(|l| { @@ -331,19 +306,17 @@ mod tests { #[tokio::test] async fn heading_not_inlined_when_split_across_chunks() { - let cfg = test_config().await; - // Paragraph without trailing newline, then a chunk that starts with the newline // and the heading text, then a final newline. The collector should first commit // only the paragraph line, and later commit the heading as its own line. let mut c = super::MarkdownStreamCollector::new(None); c.push_delta("Sounds good!"); // No commit yet - assert!(c.commit_complete_lines(&cfg).is_empty()); + assert!(c.commit_complete_lines().is_empty()); // Introduce the newline that completes the paragraph and the start of the heading. c.push_delta("\n## Adding Bird subcommand"); - let out1 = c.commit_complete_lines(&cfg); + let out1 = c.commit_complete_lines(); let s1: Vec = out1 .iter() .map(|l| { @@ -362,7 +335,7 @@ mod tests { // Now finish the heading line with the trailing newline. c.push_delta("\n"); - let out2 = c.commit_complete_lines(&cfg); + let out2 = c.commit_complete_lines(); let s2: Vec = out2 .iter() .map(|l| { @@ -381,7 +354,7 @@ mod tests { // Sanity check raw markdown rendering for a simple line does not produce spurious extras. let mut rendered: Vec> = Vec::new(); - crate::markdown::append_markdown("Hello.\n", None, &mut rendered, &cfg); + crate::markdown::append_markdown("Hello.\n", None, &mut rendered); let rendered_strings: Vec = rendered .iter() .map(|l| { @@ -423,8 +396,6 @@ mod tests { #[tokio::test] async fn utf8_boundary_safety_and_wide_chars() { - let cfg = test_config().await; - // Emoji (wide), CJK, control char, digit + combining macron sequences let input = "🙂🙂🙂\n汉字漢字\nA\u{0003}0\u{0304}\n"; let deltas = vec![ @@ -439,11 +410,11 @@ mod tests { "\n", ]; - let streamed = simulate_stream_markdown_for_tests(&deltas, true, &cfg); + let streamed = simulate_stream_markdown_for_tests(&deltas, true); let streamed_str = lines_to_plain_strings(&streamed); let mut rendered_all: Vec> = Vec::new(); - crate::markdown::append_markdown(input, None, &mut rendered_all, &cfg); + crate::markdown::append_markdown(input, None, &mut rendered_all); let rendered_all_str = lines_to_plain_strings(&rendered_all); assert_eq!( @@ -454,9 +425,8 @@ mod tests { #[tokio::test] async fn e2e_stream_deep_nested_third_level_marker_is_light_blue() { - let cfg = test_config().await; let md = "1. First\n - Second level\n 1. Third level (ordered)\n - Fourth level (bullet)\n - Fifth level to test indent consistency\n"; - let streamed = super::simulate_stream_markdown_for_tests(&[md], true, &cfg); + let streamed = super::simulate_stream_markdown_for_tests(&[md], true); let streamed_strs = lines_to_plain_strings(&streamed); // Locate the third-level line in the streamed output; avoid relying on exact indent. @@ -504,11 +474,10 @@ mod tests { #[tokio::test] async fn empty_fenced_block_is_dropped_and_separator_preserved_before_heading() { - let cfg = test_config().await; // An empty fenced code block followed by a heading should not render the fence, // but should preserve a blank separator line so the heading starts on a new line. let deltas = vec!["```bash\n```\n", "## Heading\n"]; // empty block and close in same commit - let streamed = simulate_stream_markdown_for_tests(&deltas, true, &cfg); + let streamed = simulate_stream_markdown_for_tests(&deltas, true); let texts = lines_to_plain_strings(&streamed); assert!( texts.iter().all(|s| !s.contains("```")), @@ -523,9 +492,8 @@ mod tests { #[tokio::test] async fn paragraph_then_empty_fence_then_heading_keeps_heading_on_new_line() { - let cfg = test_config().await; let deltas = vec!["Para.\n", "```\n```\n", "## Title\n"]; // empty fence block in one commit - let streamed = simulate_stream_markdown_for_tests(&deltas, true, &cfg); + let streamed = simulate_stream_markdown_for_tests(&deltas, true); let texts = lines_to_plain_strings(&streamed); let para_idx = match texts.iter().position(|s| s == "Para.") { Some(i) => i, @@ -543,17 +511,16 @@ mod tests { #[tokio::test] async fn loose_list_with_split_dashes_matches_full_render() { - let cfg = test_config().await; // Minimized failing sequence discovered by the helper: two chunks // that still reproduce the mismatch. let deltas = vec!["- item.\n\n", "-"]; - let streamed = simulate_stream_markdown_for_tests(&deltas, true, &cfg); + let streamed = simulate_stream_markdown_for_tests(&deltas, true); let streamed_strs = lines_to_plain_strings(&streamed); let full: String = deltas.iter().copied().collect(); let mut rendered_all: Vec> = Vec::new(); - crate::markdown::append_markdown(&full, None, &mut rendered_all, &cfg); + crate::markdown::append_markdown(&full, None, &mut rendered_all); let rendered_all_strs = lines_to_plain_strings(&rendered_all); assert_eq!( @@ -564,7 +531,6 @@ mod tests { #[tokio::test] async fn loose_vs_tight_list_items_streaming_matches_full() { - let cfg = test_config().await; // Deltas extracted from the session log around 2025-08-27T00:33:18.216Z let deltas = vec![ "\n\n", @@ -636,13 +602,13 @@ mod tests { "\n\n", ]; - let streamed = simulate_stream_markdown_for_tests(&deltas, true, &cfg); + let streamed = simulate_stream_markdown_for_tests(&deltas, true); let streamed_strs = lines_to_plain_strings(&streamed); // Compute a full render for diagnostics only. let full: String = deltas.iter().copied().collect(); let mut rendered_all: Vec> = Vec::new(); - crate::markdown::append_markdown(&full, None, &mut rendered_all, &cfg); + crate::markdown::append_markdown(&full, None, &mut rendered_all); // Also assert exact expected plain strings for clarity. let expected = vec![ @@ -665,12 +631,11 @@ mod tests { // Targeted tests derived from fuzz findings. Each asserts streamed == full render. async fn assert_streamed_equals_full(deltas: &[&str]) { - let cfg = test_config().await; - let streamed = simulate_stream_markdown_for_tests(deltas, true, &cfg); + let streamed = simulate_stream_markdown_for_tests(deltas, true); let streamed_strs = lines_to_plain_strings(&streamed); let full: String = deltas.iter().copied().collect(); let mut rendered: Vec> = Vec::new(); - crate::markdown::append_markdown(&full, None, &mut rendered, &cfg); + crate::markdown::append_markdown(&full, None, &mut rendered); let rendered_strs = lines_to_plain_strings(&rendered); assert_eq!(streamed_strs, rendered_strs, "full:\n---\n{full}\n---"); } diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index 319777ec73..e73ebd41e6 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -1,6 +1,5 @@ use crate::history_cell::HistoryCell; use crate::history_cell::{self}; -use codex_core::config::Config; use ratatui::text::Line; use super::StreamState; @@ -8,16 +7,14 @@ use super::StreamState; /// Controller that manages newline-gated streaming, header emission, and /// commit animation across streams. pub(crate) struct StreamController { - config: Config, state: StreamState, finishing_after_drain: bool, header_emitted: bool, } impl StreamController { - pub(crate) fn new(config: Config, width: Option) -> Self { + pub(crate) fn new(width: Option) -> Self { Self { - config, state: StreamState::new(width), finishing_after_drain: false, header_emitted: false, @@ -26,14 +23,13 @@ impl StreamController { /// Push a delta; if it contains a newline, commit completed lines and start animation. pub(crate) fn push(&mut self, delta: &str) -> bool { - let cfg = self.config.clone(); let state = &mut self.state; if !delta.is_empty() { state.has_seen_delta = true; } state.collector.push_delta(delta); if delta.contains('\n') { - let newly_completed = state.collector.commit_complete_lines(&cfg); + let newly_completed = state.collector.commit_complete_lines(); if !newly_completed.is_empty() { state.enqueue(newly_completed); return true; @@ -44,11 +40,10 @@ impl StreamController { /// Finalize the active stream. Drain and emit now. pub(crate) fn finalize(&mut self) -> Option> { - let cfg = self.config.clone(); // Finalize collector first. let remaining = { let state = &mut self.state; - state.collector.finalize_and_drain(&cfg) + state.collector.finalize_and_drain() }; // Collect all output first to avoid emitting headers when there is no content. let mut out_lines = Vec::new(); @@ -88,19 +83,6 @@ impl StreamController { #[cfg(test)] mod tests { use super::*; - use codex_core::config::Config; - use codex_core::config::ConfigOverrides; - use pretty_assertions::assert_eq; - - async fn test_config() -> Config { - let overrides = ConfigOverrides { - cwd: std::env::current_dir().ok(), - ..Default::default() - }; - Config::load_with_cli_overrides(vec![], overrides) - .await - .expect("load test config") - } fn lines_to_plain_strings(lines: &[ratatui::text::Line<'_>]) -> Vec { lines @@ -117,8 +99,7 @@ mod tests { #[tokio::test] async fn controller_loose_vs_tight_with_commit_ticks_matches_full() { - let cfg = test_config().await; - let mut ctrl = StreamController::new(cfg.clone(), None); + let mut ctrl = StreamController::new(None); let mut lines = Vec::new(); // Exact deltas from the session log (section: Loose vs. tight list items) @@ -216,7 +197,7 @@ mod tests { // Full render of the same source let source: String = deltas.iter().copied().collect(); let mut rendered: Vec> = Vec::new(); - crate::markdown::append_markdown(&source, None, &mut rendered, &cfg); + crate::markdown::append_markdown(&source, None, &mut rendered); let rendered_strs = lines_to_plain_strings(&rendered); assert_eq!(streamed, rendered_strs); From 5c680c6587e5a2c3e65b8b7b45c9df52c69bc8a6 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Mon, 20 Oct 2025 14:11:54 -0700 Subject: [PATCH 044/287] [app-server] read rate limits API (#5302) Adds a `GET account/rateLimits/read` API to app-server. This calls the codex backend to fetch the user's current rate limits. This would be helpful in checking rate limits without having to send a message. For calling the codex backend usage API, I generated the types and manually copied the relevant ones into `codex-backend-openapi-types`. It'll be nice to extend our internal openapi generator to support Rust so we don't have to run these manual steps. # External (non-OpenAI) Pull Request Requirements Before opening this Pull Request, please read the dedicated "Contributing" markdown file or your PR may be closed: https://github.com/openai/codex/blob/main/docs/contributing.md If your PR conforms to our contribution guidelines, replace this text with a detailed and high quality description of your changes. --- codex-rs/Cargo.lock | 8 + codex-rs/Cargo.toml | 1 + codex-rs/app-server-protocol/src/protocol.rs | 29 +++ codex-rs/app-server/Cargo.toml | 2 + .../app-server/src/codex_message_processor.rs | 57 +++++ codex-rs/app-server/tests/common/Cargo.toml | 3 + .../app-server/tests/common/auth_fixtures.rs | 131 +++++++++++ codex-rs/app-server/tests/common/lib.rs | 5 + .../app-server/tests/common/mcp_process.rs | 5 + codex-rs/app-server/tests/suite/mod.rs | 1 + .../app-server/tests/suite/rate_limits.rs | 215 ++++++++++++++++++ codex-rs/app-server/tests/suite/user_info.rs | 45 +--- codex-rs/backend-client/Cargo.toml | 2 + codex-rs/backend-client/src/client.rs | 73 ++++++ codex-rs/backend-client/src/types.rs | 4 + .../codex-backend-openapi-models/Cargo.toml | 1 + .../src/models/mod.rs | 12 + .../src/models/rate_limit_status_details.rs | 46 ++++ .../src/models/rate_limit_status_payload.rs | 65 ++++++ .../src/models/rate_limit_window_snapshot.rs | 40 ++++ codex-rs/core/src/client.rs | 30 +-- codex-rs/protocol/src/protocol.rs | 4 +- 22 files changed, 726 insertions(+), 53 deletions(-) create mode 100644 codex-rs/app-server/tests/common/auth_fixtures.rs create mode 100644 codex-rs/app-server/tests/suite/rate_limits.rs create mode 100644 codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_details.rs create mode 100644 codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs create mode 100644 codex-rs/codex-backend-openapi-models/src/models/rate_limit_window_snapshot.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d8e86be338..5075d37fad 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -182,7 +182,10 @@ version = "0.0.0" dependencies = [ "anyhow", "assert_cmd", + "base64", + "chrono", "codex-app-server-protocol", + "codex-core", "serde", "serde_json", "tokio", @@ -834,8 +837,10 @@ dependencies = [ "app_test_support", "assert_cmd", "base64", + "chrono", "codex-app-server-protocol", "codex-arg0", + "codex-backend-client", "codex-common", "codex-core", "codex-file-search", @@ -917,6 +922,8 @@ version = "0.0.0" dependencies = [ "anyhow", "codex-backend-openapi-models", + "codex-core", + "codex-protocol", "pretty_assertions", "reqwest", "serde", @@ -929,6 +936,7 @@ version = "0.0.0" dependencies = [ "serde", "serde_json", + "serde_with", ] [[package]] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index d2700a621f..5b7732b2b2 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -57,6 +57,7 @@ codex-app-server-protocol = { path = "app-server-protocol" } codex-apply-patch = { path = "apply-patch" } codex-arg0 = { path = "arg0" } codex-async-utils = { path = "async-utils" } +codex-backend-client = { path = "backend-client" } codex-chatgpt = { path = "chatgpt" } codex-common = { path = "common" } codex-core = { path = "core" } diff --git a/codex-rs/app-server-protocol/src/protocol.rs b/codex-rs/app-server-protocol/src/protocol.rs index 9c10d23360..3574110bd9 100644 --- a/codex-rs/app-server-protocol/src/protocol.rs +++ b/codex-rs/app-server-protocol/src/protocol.rs @@ -14,6 +14,7 @@ use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::FileChange; +use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::TurnAbortReason; @@ -183,6 +184,12 @@ client_request_definitions! { params: ExecOneOffCommandParams, response: ExecOneOffCommandResponse, }, + #[serde(rename = "account/rateLimits/read")] + #[ts(rename = "account/rateLimits/read")] + GetAccountRateLimits { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + response: GetAccountRateLimitsResponse, + }, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] @@ -420,6 +427,12 @@ pub struct ExecOneOffCommandResponse { pub stderr: String, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct GetAccountRateLimitsResponse { + pub rate_limits: RateLimitSnapshot, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct GetAuthStatusResponse { @@ -970,4 +983,20 @@ mod tests { assert_eq!(payload.request_with_id(RequestId::Integer(7)), request); Ok(()) } + + #[test] + fn serialize_get_account_rate_limits() -> Result<()> { + let request = ClientRequest::GetAccountRateLimits { + request_id: RequestId::Integer(1), + params: None, + }; + assert_eq!( + json!({ + "method": "account/rateLimits/read", + "id": 1, + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } } diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 545ef4898c..c0dd2410c7 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -19,11 +19,13 @@ anyhow = { workspace = true } codex-arg0 = { workspace = true } codex-common = { workspace = true, features = ["cli"] } codex-core = { workspace = true } +codex-backend-client = { workspace = true } codex-file-search = { workspace = true } codex-login = { workspace = true } codex-protocol = { workspace = true } codex-app-server-protocol = { workspace = true } codex-utils-json-to-toml = { workspace = true } +chrono = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tokio = { workspace = true, features = [ diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 90ec928375..04cfbcfda2 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9,6 +9,7 @@ use codex_app_server_protocol::ApplyPatchApprovalParams; use codex_app_server_protocol::ApplyPatchApprovalResponse; use codex_app_server_protocol::ArchiveConversationParams; use codex_app_server_protocol::ArchiveConversationResponse; +use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::AuthStatusChangeNotification; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConversationSummary; @@ -18,6 +19,7 @@ use codex_app_server_protocol::ExecOneOffCommandParams; use codex_app_server_protocol::ExecOneOffCommandResponse; use codex_app_server_protocol::FuzzyFileSearchParams; use codex_app_server_protocol::FuzzyFileSearchResponse; +use codex_app_server_protocol::GetAccountRateLimitsResponse; use codex_app_server_protocol::GetUserAgentResponse; use codex_app_server_protocol::GetUserSavedConfigResponse; use codex_app_server_protocol::GitDiffToRemoteResponse; @@ -49,6 +51,7 @@ use codex_app_server_protocol::SetDefaultModelParams; use codex_app_server_protocol::SetDefaultModelResponse; use codex_app_server_protocol::UserInfoResponse; use codex_app_server_protocol::UserSavedConfig; +use codex_backend_client::Client as BackendClient; use codex_core::AuthManager; use codex_core::CodexConversation; use codex_core::ConversationManager; @@ -87,6 +90,7 @@ use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::InputMessageKind; +use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::USER_MESSAGE_BEGIN; use codex_protocol::user_input::UserInput as CoreInputItem; use codex_utils_json_to_toml::json_to_toml; @@ -240,6 +244,12 @@ impl CodexMessageProcessor { ClientRequest::ExecOneOffCommand { request_id, params } => { self.exec_one_off_command(request_id, params).await; } + ClientRequest::GetAccountRateLimits { + request_id, + params: _, + } => { + self.get_account_rate_limits(request_id).await; + } } } @@ -527,6 +537,53 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } + async fn get_account_rate_limits(&self, request_id: RequestId) { + match self.fetch_account_rate_limits().await { + Ok(rate_limits) => { + let response = GetAccountRateLimitsResponse { rate_limits }; + self.outgoing.send_response(request_id, response).await; + } + Err(error) => { + self.outgoing.send_error(request_id, error).await; + } + } + } + + async fn fetch_account_rate_limits(&self) -> Result { + let Some(auth) = self.auth_manager.auth() else { + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "codex account authentication required to read rate limits".to_string(), + data: None, + }); + }; + + if auth.mode != AuthMode::ChatGPT { + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "chatgpt authentication required to read rate limits".to_string(), + data: None, + }); + } + + let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth) + .await + .map_err(|err| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to construct backend client: {err}"), + data: None, + })?; + + client + .get_rate_limits() + .await + .map_err(|err| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to fetch codex rate limits: {err}"), + data: None, + }) + } + async fn get_user_saved_config(&self, request_id: RequestId) { let toml_value = match load_config_as_toml(&self.config.codex_home).await { Ok(val) => val, diff --git a/codex-rs/app-server/tests/common/Cargo.toml b/codex-rs/app-server/tests/common/Cargo.toml index 306b1e187e..ece8174dd9 100644 --- a/codex-rs/app-server/tests/common/Cargo.toml +++ b/codex-rs/app-server/tests/common/Cargo.toml @@ -9,7 +9,10 @@ path = "lib.rs" [dependencies] anyhow = { workspace = true } assert_cmd = { workspace = true } +base64 = { workspace = true } +chrono = { workspace = true } codex-app-server-protocol = { workspace = true } +codex-core = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true, features = [ diff --git a/codex-rs/app-server/tests/common/auth_fixtures.rs b/codex-rs/app-server/tests/common/auth_fixtures.rs new file mode 100644 index 0000000000..bc1df74da2 --- /dev/null +++ b/codex-rs/app-server/tests/common/auth_fixtures.rs @@ -0,0 +1,131 @@ +use std::path::Path; + +use anyhow::Context; +use anyhow::Result; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use chrono::DateTime; +use chrono::Utc; +use codex_core::auth::AuthDotJson; +use codex_core::auth::get_auth_file; +use codex_core::auth::write_auth_json; +use codex_core::token_data::TokenData; +use codex_core::token_data::parse_id_token; +use serde_json::json; + +/// Builder for writing a fake ChatGPT auth.json in tests. +#[derive(Debug, Clone)] +pub struct ChatGptAuthFixture { + access_token: String, + refresh_token: String, + account_id: Option, + claims: ChatGptIdTokenClaims, + last_refresh: Option>>, +} + +impl ChatGptAuthFixture { + pub fn new(access_token: impl Into) -> Self { + Self { + access_token: access_token.into(), + refresh_token: "refresh-token".to_string(), + account_id: None, + claims: ChatGptIdTokenClaims::default(), + last_refresh: None, + } + } + + pub fn refresh_token(mut self, refresh_token: impl Into) -> Self { + self.refresh_token = refresh_token.into(); + self + } + + pub fn account_id(mut self, account_id: impl Into) -> Self { + self.account_id = Some(account_id.into()); + self + } + + pub fn plan_type(mut self, plan_type: impl Into) -> Self { + self.claims.plan_type = Some(plan_type.into()); + self + } + + pub fn email(mut self, email: impl Into) -> Self { + self.claims.email = Some(email.into()); + self + } + + pub fn last_refresh(mut self, last_refresh: Option>) -> Self { + self.last_refresh = Some(last_refresh); + self + } + + pub fn claims(mut self, claims: ChatGptIdTokenClaims) -> Self { + self.claims = claims; + self + } +} + +#[derive(Debug, Clone, Default)] +pub struct ChatGptIdTokenClaims { + pub email: Option, + pub plan_type: Option, +} + +impl ChatGptIdTokenClaims { + pub fn new() -> Self { + Self::default() + } + + pub fn email(mut self, email: impl Into) -> Self { + self.email = Some(email.into()); + self + } + + pub fn plan_type(mut self, plan_type: impl Into) -> Self { + self.plan_type = Some(plan_type.into()); + self + } +} + +pub fn encode_id_token(claims: &ChatGptIdTokenClaims) -> Result { + let header = json!({ "alg": "none", "typ": "JWT" }); + let mut payload = serde_json::Map::new(); + if let Some(email) = &claims.email { + payload.insert("email".to_string(), json!(email)); + } + if let Some(plan_type) = &claims.plan_type { + payload.insert( + "https://api.openai.com/auth".to_string(), + json!({ "chatgpt_plan_type": plan_type }), + ); + } + let payload = serde_json::Value::Object(payload); + + let header_b64 = + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).context("serialize jwt header")?); + let payload_b64 = + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).context("serialize jwt payload")?); + let signature_b64 = URL_SAFE_NO_PAD.encode(b"signature"); + Ok(format!("{header_b64}.{payload_b64}.{signature_b64}")) +} + +pub fn write_chatgpt_auth(codex_home: &Path, fixture: ChatGptAuthFixture) -> Result<()> { + let id_token_raw = encode_id_token(&fixture.claims)?; + let id_token = parse_id_token(&id_token_raw).context("parse id token")?; + let tokens = TokenData { + id_token, + access_token: fixture.access_token, + refresh_token: fixture.refresh_token, + account_id: fixture.account_id, + }; + + let last_refresh = fixture.last_refresh.unwrap_or_else(|| Some(Utc::now())); + + let auth = AuthDotJson { + openai_api_key: None, + tokens: Some(tokens), + last_refresh, + }; + + write_auth_json(&get_auth_file(codex_home), &auth).context("write auth.json") +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 2acb52de69..71b27190c2 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -1,7 +1,12 @@ +mod auth_fixtures; mod mcp_process; mod mock_model_server; mod responses; +pub use auth_fixtures::ChatGptAuthFixture; +pub use auth_fixtures::ChatGptIdTokenClaims; +pub use auth_fixtures::encode_id_token; +pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use mcp_process::McpProcess; pub use mock_model_server::create_mock_chat_completions_server; diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 29768df81c..2b0f432be8 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -236,6 +236,11 @@ impl McpProcess { self.send_request("getUserAgent", None).await } + /// Send an `account/rateLimits/read` JSON-RPC request. + pub async fn send_get_account_rate_limits_request(&mut self) -> anyhow::Result { + self.send_request("account/rateLimits/read", None).await + } + /// Send a `userInfo` JSON-RPC request. pub async fn send_user_info_request(&mut self) -> anyhow::Result { self.send_request("userInfo", None).await diff --git a/codex-rs/app-server/tests/suite/mod.rs b/codex-rs/app-server/tests/suite/mod.rs index 78ce310e74..507f71049c 100644 --- a/codex-rs/app-server/tests/suite/mod.rs +++ b/codex-rs/app-server/tests/suite/mod.rs @@ -7,6 +7,7 @@ mod fuzzy_file_search; mod interrupt; mod list_resume; mod login; +mod rate_limits; mod send_message; mod set_default_model; mod user_agent; diff --git a/codex-rs/app-server/tests/suite/rate_limits.rs b/codex-rs/app-server/tests/suite/rate_limits.rs new file mode 100644 index 0000000000..302fd60349 --- /dev/null +++ b/codex-rs/app-server/tests/suite/rate_limits.rs @@ -0,0 +1,215 @@ +use anyhow::Context; +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::McpProcess; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::GetAccountRateLimitsResponse; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::LoginApiKeyParams; +use codex_app_server_protocol::RequestId; +use codex_protocol::protocol::RateLimitSnapshot; +use codex_protocol::protocol::RateLimitWindow; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const INVALID_REQUEST_ERROR_CODE: i64 = -32600; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_account_rate_limits_requires_auth() -> Result<()> { + let codex_home = TempDir::new().context("create codex home tempdir")?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]) + .await + .context("spawn mcp process")?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) + .await + .context("initialize timeout")? + .context("initialize request")?; + + let request_id = mcp + .send_get_account_rate_limits_request() + .await + .context("send account/rateLimits/read")?; + + let error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await + .context("account/rateLimits/read timeout")? + .context("account/rateLimits/read error")?; + + assert_eq!(error.id, RequestId::Integer(request_id)); + assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + error.error.message, + "codex account authentication required to read rate limits" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_account_rate_limits_requires_chatgpt_auth() -> Result<()> { + let codex_home = TempDir::new().context("create codex home tempdir")?; + + let mut mcp = McpProcess::new(codex_home.path()) + .await + .context("spawn mcp process")?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) + .await + .context("initialize timeout")? + .context("initialize request")?; + + login_with_api_key(&mut mcp, "sk-test-key").await?; + + let request_id = mcp + .send_get_account_rate_limits_request() + .await + .context("send account/rateLimits/read")?; + + let error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await + .context("account/rateLimits/read timeout")? + .context("account/rateLimits/read error")?; + + assert_eq!(error.id, RequestId::Integer(request_id)); + assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + error.error.message, + "chatgpt authentication required to read rate limits" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_account_rate_limits_returns_snapshot() -> Result<()> { + let codex_home = TempDir::new().context("create codex home tempdir")?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .plan_type("pro"), + ) + .context("write chatgpt auth")?; + + let server = MockServer::start().await; + let server_url = server.uri(); + write_chatgpt_base_url(codex_home.path(), &server_url).context("write chatgpt base url")?; + + let primary_reset_timestamp = chrono::DateTime::parse_from_rfc3339("2025-01-01T00:02:00Z") + .expect("parse primary reset timestamp") + .timestamp(); + let secondary_reset_timestamp = chrono::DateTime::parse_from_rfc3339("2025-01-01T01:00:00Z") + .expect("parse secondary reset timestamp") + .timestamp(); + let response_body = json!({ + "plan_type": "pro", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 42, + "limit_window_seconds": 3600, + "reset_after_seconds": 120, + "reset_at": primary_reset_timestamp, + }, + "secondary_window": { + "used_percent": 5, + "limit_window_seconds": 86400, + "reset_after_seconds": 43200, + "reset_at": secondary_reset_timestamp, + } + } + }); + + Mock::given(method("GET")) + .and(path("/api/codex/usage")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(response_body)) + .mount(&server) + .await; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]) + .await + .context("spawn mcp process")?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) + .await + .context("initialize timeout")? + .context("initialize request")?; + + let request_id = mcp + .send_get_account_rate_limits_request() + .await + .context("send account/rateLimits/read")?; + + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await + .context("account/rateLimits/read timeout")? + .context("account/rateLimits/read response")?; + + let received: GetAccountRateLimitsResponse = + to_response(response).context("deserialize rate limit response")?; + + let expected = GetAccountRateLimitsResponse { + rate_limits: RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 42.0, + window_minutes: Some(60), + resets_at: Some(primary_reset_timestamp), + }), + secondary: Some(RateLimitWindow { + used_percent: 5.0, + window_minutes: Some(1440), + resets_at: Some(secondary_reset_timestamp), + }), + }, + }; + assert_eq!(received, expected); + + Ok(()) +} + +async fn login_with_api_key(mcp: &mut McpProcess, api_key: &str) -> Result<()> { + let request_id = mcp + .send_login_api_key_request(LoginApiKeyParams { + api_key: api_key.to_string(), + }) + .await + .context("send loginApiKey")?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await + .context("loginApiKey timeout")? + .context("loginApiKey response")?; + + Ok(()) +} + +fn write_chatgpt_base_url(codex_home: &Path, base_url: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write(config_toml, format!("chatgpt_base_url = \"{base_url}\"\n")) +} diff --git a/codex-rs/app-server/tests/suite/user_info.rs b/codex-rs/app-server/tests/suite/user_info.rs index edd041e553..b730b77fed 100644 --- a/codex-rs/app-server/tests/suite/user_info.rs +++ b/codex-rs/app-server/tests/suite/user_info.rs @@ -1,20 +1,13 @@ use std::time::Duration; -use anyhow::Context; +use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; use app_test_support::to_response; -use base64::Engine; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::UserInfoResponse; -use codex_core::auth::AuthDotJson; -use codex_core::auth::get_auth_file; -use codex_core::auth::write_auth_json; -use codex_core::token_data::IdTokenInfo; -use codex_core::token_data::TokenData; use pretty_assertions::assert_eq; -use serde_json::json; use tempfile::TempDir; use tokio::time::timeout; @@ -24,22 +17,13 @@ const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); async fn user_info_returns_email_from_auth_json() { let codex_home = TempDir::new().expect("create tempdir"); - let auth_path = get_auth_file(codex_home.path()); - let mut id_token = IdTokenInfo::default(); - id_token.email = Some("user@example.com".to_string()); - id_token.raw_jwt = encode_id_token_with_email("user@example.com").expect("encode id token"); - - let auth = AuthDotJson { - openai_api_key: None, - tokens: Some(TokenData { - id_token, - access_token: "access".to_string(), - refresh_token: "refresh".to_string(), - account_id: None, - }), - last_refresh: None, - }; - write_auth_json(&auth_path, &auth).expect("write auth.json"); + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("access") + .refresh_token("refresh") + .email("user@example.com"), + ) + .expect("write chatgpt auth"); let mut mcp = McpProcess::new(codex_home.path()) .await @@ -65,14 +49,3 @@ async fn user_info_returns_email_from_auth_json() { assert_eq!(received, expected); } - -fn encode_id_token_with_email(email: &str) -> anyhow::Result { - let header_b64 = URL_SAFE_NO_PAD.encode( - serde_json::to_vec(&json!({ "alg": "none", "typ": "JWT" })) - .context("serialize jwt header")?, - ); - let payload = - serde_json::to_vec(&json!({ "email": email })).context("serialize jwt payload")?; - let payload_b64 = URL_SAFE_NO_PAD.encode(payload); - Ok(format!("{header_b64}.{payload_b64}.signature")) -} diff --git a/codex-rs/backend-client/Cargo.toml b/codex-rs/backend-client/Cargo.toml index a8e2ee85fb..0cf802399c 100644 --- a/codex-rs/backend-client/Cargo.toml +++ b/codex-rs/backend-client/Cargo.toml @@ -13,6 +13,8 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } codex-backend-openapi-models = { path = "../codex-backend-openapi-models" } +codex-protocol = { workspace = true } +codex-core = { workspace = true } [dev-dependencies] pretty_assertions = "1" diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 06ad00cb80..28a51598eb 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -1,7 +1,13 @@ use crate::types::CodeTaskDetailsResponse; use crate::types::PaginatedListTaskListItem; +use crate::types::RateLimitStatusPayload; +use crate::types::RateLimitWindowSnapshot; use crate::types::TurnAttemptsSiblingTurnsResponse; use anyhow::Result; +use codex_core::auth::CodexAuth; +use codex_core::default_client::get_codex_user_agent; +use codex_protocol::protocol::RateLimitSnapshot; +use codex_protocol::protocol::RateLimitWindow; use reqwest::header::AUTHORIZATION; use reqwest::header::CONTENT_TYPE; use reqwest::header::HeaderMap; @@ -64,6 +70,17 @@ impl Client { }) } + pub async fn from_auth(base_url: impl Into, auth: &CodexAuth) -> Result { + let token = auth.get_token().await.map_err(anyhow::Error::from)?; + let mut client = Self::new(base_url)? + .with_user_agent(get_codex_user_agent()) + .with_bearer_token(token); + if let Some(account_id) = auth.get_account_id() { + client = client.with_chatgpt_account_id(account_id); + } + Ok(client) + } + pub fn with_bearer_token(mut self, token: impl Into) -> Self { self.bearer_token = Some(token.into()); self @@ -138,6 +155,17 @@ impl Client { } } + pub async fn get_rate_limits(&self) -> Result { + let url = match self.path_style { + PathStyle::CodexApi => format!("{}/api/codex/usage", self.base_url), + PathStyle::ChatGptApi => format!("{}/wham/usage", self.base_url), + }; + let req = self.http.get(&url).headers(self.headers()); + let (body, ct) = self.exec_request(req, "GET", &url).await?; + let payload: RateLimitStatusPayload = self.decode_json(&url, &ct, &body)?; + Ok(Self::rate_limit_snapshot_from_payload(payload)) + } + pub async fn list_tasks( &self, limit: Option, @@ -241,4 +269,49 @@ impl Client { Err(e) => anyhow::bail!("Decode error for {url}: {e}; content-type={ct}; body={body}"), } } + + // rate limit helpers + fn rate_limit_snapshot_from_payload(payload: RateLimitStatusPayload) -> RateLimitSnapshot { + let Some(details) = payload + .rate_limit + .and_then(|inner| inner.map(|boxed| *boxed)) + else { + return RateLimitSnapshot { + primary: None, + secondary: None, + }; + }; + + RateLimitSnapshot { + primary: Self::map_rate_limit_window(details.primary_window), + secondary: Self::map_rate_limit_window(details.secondary_window), + } + } + + fn map_rate_limit_window( + window: Option>>, + ) -> Option { + let snapshot = match window { + Some(Some(snapshot)) => *snapshot, + _ => return None, + }; + + let used_percent = f64::from(snapshot.used_percent); + let window_minutes = Self::window_minutes_from_seconds(snapshot.limit_window_seconds); + let resets_at = Some(i64::from(snapshot.reset_at)); + Some(RateLimitWindow { + used_percent, + window_minutes, + resets_at, + }) + } + + fn window_minutes_from_seconds(seconds: i32) -> Option { + if seconds <= 0 { + return None; + } + + let seconds_i64 = i64::from(seconds); + Some((seconds_i64 + 59) / 60) + } } diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index 8e2dfb2126..9f196f9c2f 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -1,4 +1,8 @@ pub use codex_backend_openapi_models::models::PaginatedListTaskListItem; +pub use codex_backend_openapi_models::models::PlanType; +pub use codex_backend_openapi_models::models::RateLimitStatusDetails; +pub use codex_backend_openapi_models::models::RateLimitStatusPayload; +pub use codex_backend_openapi_models::models::RateLimitWindowSnapshot; pub use codex_backend_openapi_models::models::TaskListItem; use serde::Deserialize; diff --git a/codex-rs/codex-backend-openapi-models/Cargo.toml b/codex-rs/codex-backend-openapi-models/Cargo.toml index 811ee72d8f..cdf5810812 100644 --- a/codex-rs/codex-backend-openapi-models/Cargo.toml +++ b/codex-rs/codex-backend-openapi-models/Cargo.toml @@ -15,3 +15,4 @@ path = "src/lib.rs" [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_with = "3" diff --git a/codex-rs/codex-backend-openapi-models/src/models/mod.rs b/codex-rs/codex-backend-openapi-models/src/models/mod.rs index e2cb972f10..96348d72c2 100644 --- a/codex-rs/codex-backend-openapi-models/src/models/mod.rs +++ b/codex-rs/codex-backend-openapi-models/src/models/mod.rs @@ -3,6 +3,7 @@ // Currently export only the types referenced by the workspace // The process for this will change +// Cloud Tasks pub mod code_task_details_response; pub use self::code_task_details_response::CodeTaskDetailsResponse; @@ -20,3 +21,14 @@ pub use self::task_list_item::TaskListItem; pub mod paginated_list_task_list_item_; pub use self::paginated_list_task_list_item_::PaginatedListTaskListItem; + +// Rate Limits +pub mod rate_limit_status_payload; +pub use self::rate_limit_status_payload::PlanType; +pub use self::rate_limit_status_payload::RateLimitStatusPayload; + +pub mod rate_limit_status_details; +pub use self::rate_limit_status_details::RateLimitStatusDetails; + +pub mod rate_limit_window_snapshot; +pub use self::rate_limit_window_snapshot::RateLimitWindowSnapshot; diff --git a/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_details.rs b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_details.rs new file mode 100644 index 0000000000..ca9fdfe240 --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_details.rs @@ -0,0 +1,46 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct RateLimitStatusDetails { + #[serde(rename = "allowed")] + pub allowed: bool, + #[serde(rename = "limit_reached")] + pub limit_reached: bool, + #[serde( + rename = "primary_window", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub primary_window: Option>>, + #[serde( + rename = "secondary_window", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub secondary_window: Option>>, +} + +impl RateLimitStatusDetails { + pub fn new(allowed: bool, limit_reached: bool) -> RateLimitStatusDetails { + RateLimitStatusDetails { + allowed, + limit_reached, + primary_window: None, + secondary_window: None, + } + } +} diff --git a/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs new file mode 100644 index 0000000000..d2af76f4d7 --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs @@ -0,0 +1,65 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct RateLimitStatusPayload { + #[serde(rename = "plan_type")] + pub plan_type: PlanType, + #[serde( + rename = "rate_limit", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub rate_limit: Option>>, +} + +impl RateLimitStatusPayload { + pub fn new(plan_type: PlanType) -> RateLimitStatusPayload { + RateLimitStatusPayload { + plan_type, + rate_limit: None, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum PlanType { + #[serde(rename = "free")] + Free, + #[serde(rename = "go")] + Go, + #[serde(rename = "plus")] + Plus, + #[serde(rename = "pro")] + Pro, + #[serde(rename = "team")] + Team, + #[serde(rename = "business")] + Business, + #[serde(rename = "education")] + Education, + #[serde(rename = "quorum")] + Quorum, + #[serde(rename = "enterprise")] + Enterprise, + #[serde(rename = "edu")] + Edu, +} + +impl Default for PlanType { + fn default() -> PlanType { + Self::Free + } +} diff --git a/codex-rs/codex-backend-openapi-models/src/models/rate_limit_window_snapshot.rs b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_window_snapshot.rs new file mode 100644 index 0000000000..4fc04f4be6 --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_window_snapshot.rs @@ -0,0 +1,40 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ + +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct RateLimitWindowSnapshot { + #[serde(rename = "used_percent")] + pub used_percent: i32, + #[serde(rename = "limit_window_seconds")] + pub limit_window_seconds: i32, + #[serde(rename = "reset_after_seconds")] + pub reset_after_seconds: i32, + #[serde(rename = "reset_at")] + pub reset_at: i32, +} + +impl RateLimitWindowSnapshot { + pub fn new( + used_percent: i32, + limit_window_seconds: i32, + reset_after_seconds: i32, + reset_at: i32, + ) -> RateLimitWindowSnapshot { + RateLimitWindowSnapshot { + used_percent, + limit_window_seconds, + reset_after_seconds, + reset_at, + } + } +} diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index a88ad778ed..78e7d03edd 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -1,17 +1,18 @@ use std::io::BufRead; use std::path::Path; +use std::sync::Arc; use std::sync::OnceLock; use std::time::Duration; -use crate::AuthManager; -use crate::auth::CodexAuth; -use crate::error::ConnectionFailedError; -use crate::error::ResponseStreamFailed; -use crate::error::RetryLimitReachedError; -use crate::error::UnexpectedResponseError; use bytes::Bytes; +use chrono::DateTime; +use chrono::Utc; use codex_app_server_protocol::AuthMode; +use codex_otel::otel_event_manager::OtelEventManager; use codex_protocol::ConversationId; +use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; +use codex_protocol::models::ResponseItem; use eventsource_stream::Eventsource; use futures::prelude::*; use regex_lite::Regex; @@ -27,6 +28,8 @@ use tracing::debug; use tracing::trace; use tracing::warn; +use crate::AuthManager; +use crate::auth::CodexAuth; use crate::chat_completions::AggregateStreamExt; use crate::chat_completions::stream_chat_completions; use crate::client_common::Prompt; @@ -38,7 +41,11 @@ use crate::client_common::create_text_param_for_request; use crate::config::Config; use crate::default_client::create_client; use crate::error::CodexErr; +use crate::error::ConnectionFailedError; +use crate::error::ResponseStreamFailed; use crate::error::Result; +use crate::error::RetryLimitReachedError; +use crate::error::UnexpectedResponseError; use crate::error::UsageLimitReachedError; use crate::flags::CODEX_RS_SSE_FIXTURE; use crate::model_family::ModelFamily; @@ -52,13 +59,6 @@ use crate::state::TaskKind; use crate::token_data::PlanType; use crate::tools::spec::create_tools_json_for_responses_api; use crate::util::backoff; -use chrono::DateTime; -use chrono::Utc; -use codex_otel::otel_event_manager::OtelEventManager; -use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; -use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; -use codex_protocol::models::ResponseItem; -use std::sync::Arc; #[derive(Debug, Deserialize)] struct ErrorResponse { @@ -628,13 +628,13 @@ fn parse_rate_limit_window( headers: &HeaderMap, used_percent_header: &str, window_minutes_header: &str, - resets_header: &str, + resets_at_header: &str, ) -> Option { let used_percent: Option = parse_header_f64(headers, used_percent_header); used_percent.and_then(|used_percent| { let window_minutes = parse_header_i64(headers, window_minutes_header); - let resets_at = parse_header_i64(headers, resets_header); + let resets_at = parse_header_i64(headers, resets_at_header); let has_data = used_percent != 0.0 || window_minutes.is_some_and(|minutes| minutes != 0) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index c683bb235c..11747c949f 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -644,13 +644,13 @@ pub struct TokenCountEvent { pub rate_limits: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct RateLimitSnapshot { pub primary: Option, pub secondary: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct RateLimitWindow { /// Percentage (0-100) of the window that has been consumed. pub used_percent: f64, From 58159383c4964b8bc196b533d121b70c04d3f0d1 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Mon, 20 Oct 2025 14:40:14 -0700 Subject: [PATCH 045/287] fix terminal corruption that could happen when onboarding and update banner (#5269) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of printing characters before booting the app, make the upgrade banner a history cell so it's well-behaved. Screenshot 2025-10-16 at 4 20 51 PM --------- Co-authored-by: Josh McKinney --- codex-rs/Cargo.lock | 10 +++ codex-rs/Cargo.toml | 1 + codex-rs/cli/src/main.rs | 2 +- codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/app.rs | 20 ++++- codex-rs/tui/src/history_cell.rs | 56 ++++++++++++++ codex-rs/tui/src/lib.rs | 124 +----------------------------- codex-rs/tui/src/update_prompt.rs | 4 +- codex-rs/tui/src/updates.rs | 69 ++++++++++++++++- 9 files changed, 157 insertions(+), 130 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 5075d37fad..f87f3af2e4 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1459,6 +1459,7 @@ dependencies = [ "pulldown-cmark", "rand 0.9.2", "ratatui", + "ratatui-macros", "regex-lite", "serde", "serde_json", @@ -4777,6 +4778,15 @@ dependencies = [ "unicode-width 0.2.1", ] +[[package]] +name = "ratatui-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fef540f80dbe8a0773266fa6077788ceb65ef624cdbf36e131aaf90b4a52df4" +dependencies = [ + "ratatui", +] + [[package]] name = "redox_syscall" version = "0.5.15" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 5b7732b2b2..67041c6f1d 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -150,6 +150,7 @@ pretty_assertions = "1.4.1" pulldown-cmark = "0.10" rand = "0.9" ratatui = "0.29.0" +ratatui-macros = "0.6.0" regex-lite = "0.1.7" reqwest = "0.12" rmcp = { version = "0.8.0", default-features = false } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 3993503412..940e1e536d 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -19,7 +19,7 @@ use codex_exec::Cli as ExecCli; use codex_responses_api_proxy::Args as ResponsesApiProxyArgs; use codex_tui::AppExitInfo; use codex_tui::Cli as TuiCli; -use codex_tui::UpdateAction; +use codex_tui::updates::UpdateAction; use owo_colors::OwoColorize; use std::path::PathBuf; use supports_color::Stream; diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index e8cb0fa5e0..824e4fee31 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -59,6 +59,7 @@ ratatui = { workspace = true, features = [ "unstable-rendered-line-info", "unstable-widget-ref", ] } +ratatui-macros = { workspace = true } regex-lite = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, features = ["preserve_order"] } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e1c2d062a4..5c274121c7 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1,4 +1,3 @@ -use crate::UpdateAction; use crate::app_backtrack::BacktrackState; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; @@ -13,6 +12,7 @@ use crate::render::highlight::highlight_bash_to_lines; use crate::resume_picker::ResumeSelection; use crate::tui; use crate::tui::TuiEvent; +use crate::updates::UpdateAction; use codex_ansi_escape::ansi_escape_line; use codex_core::AuthManager; use codex_core::ConversationManager; @@ -39,7 +39,9 @@ use std::thread; use std::time::Duration; use tokio::select; use tokio::sync::mpsc::unbounded_channel; -// use uuid::Uuid; + +#[cfg(not(debug_assertions))] +use crate::history_cell::UpdateAvailableHistoryCell; #[derive(Debug, Clone)] pub struct AppExitInfo { @@ -146,6 +148,8 @@ impl App { }; let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); + #[cfg(not(debug_assertions))] + let upgrade_version = crate::updates::get_upgrade_version(&config); let mut app = Self { server: conversation_manager, @@ -166,6 +170,18 @@ impl App { pending_update_action: None, }; + #[cfg(not(debug_assertions))] + if let Some(latest_version) = upgrade_version { + app.handle_event( + tui, + AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new( + latest_version, + crate::updates::get_update_action(), + ))), + ) + .await?; + } + let tui_events = tui.event_stream(); tokio::pin!(tui_events); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 4cbf25ae15..6bae1bd0c3 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -15,6 +15,8 @@ use crate::style::user_message_style; use crate::text_formatting::format_and_truncate_tool_result; use crate::text_formatting::truncate_text; use crate::ui_consts::LIVE_PREFIX_COLS; +use crate::updates::UpdateAction; +use crate::version::CODEX_CLI_VERSION; use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; use crate::wrapping::word_wrap_lines; @@ -264,6 +266,60 @@ impl HistoryCell for PlainHistoryCell { } } +#[cfg_attr(debug_assertions, allow(dead_code))] +#[derive(Debug)] +pub(crate) struct UpdateAvailableHistoryCell { + latest_version: String, + update_action: Option, +} + +#[cfg_attr(debug_assertions, allow(dead_code))] +impl UpdateAvailableHistoryCell { + pub(crate) fn new(latest_version: String, update_action: Option) -> Self { + Self { + latest_version, + update_action, + } + } +} + +impl HistoryCell for UpdateAvailableHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + use ratatui_macros::line; + use ratatui_macros::text; + let update_instruction = if let Some(update_action) = self.update_action { + line!["Run ", update_action.command_str().cyan(), " to update."] + } else { + line![ + "See ", + "https://github.com/openai/codex".cyan().underlined(), + " for installation options." + ] + }; + + let content = text![ + line![ + padded_emoji("✨").bold().cyan(), + "Update available!".bold().cyan(), + " ", + format!("{CODEX_CLI_VERSION} -> {}", self.latest_version).bold(), + ], + update_instruction, + "", + "See full release notes:", + "https://github.com/openai/codex/releases/latest" + .cyan() + .underlined(), + ]; + + let inner_width = content + .width() + .min(usize::from(width.saturating_sub(4))) + .max(1); + with_border_with_inner_width(content.lines, inner_width) + } +} + #[derive(Debug)] pub(crate) struct PrefixedWrappedHistoryCell { text: Text<'static>, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 83a35f79c8..1d3cc4c0b1 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -71,45 +71,14 @@ mod text_formatting; mod tui; mod ui_consts; mod update_prompt; +pub mod updates; mod version; -/// Update action the CLI should perform after the TUI exits. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum UpdateAction { - /// Update via `npm install -g @openai/codex@latest`. - NpmGlobalLatest, - /// Update via `bun install -g @openai/codex@latest`. - BunGlobalLatest, - /// Update via `brew upgrade codex`. - BrewUpgrade, -} - -impl UpdateAction { - /// Returns the list of command-line arguments for invoking the update. - pub fn command_args(&self) -> (&'static str, &'static [&'static str]) { - match self { - UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex@latest"]), - UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex@latest"]), - UpdateAction::BrewUpgrade => ("brew", &["upgrade", "codex"]), - } - } - - /// Returns string representation of the command-line arguments for invoking the update. - pub fn command_str(&self) -> String { - let (command, args) = self.command_args(); - let args_str = args.join(" "); - format!("{command} {args_str}") - } -} - mod wrapping; #[cfg(test)] pub mod test_backend; -#[cfg(not(debug_assertions))] -mod updates; - use crate::onboarding::TrustDirectorySelection; use crate::onboarding::WSL_INSTRUCTIONS; use crate::onboarding::onboarding_screen::OnboardingScreenArgs; @@ -343,56 +312,6 @@ async fn run_ratatui_app( } } - // Show update banner in terminal history (instead of stderr) so it is visible - // within the TUI scrollback. Building spans keeps styling consistent. - #[cfg(not(debug_assertions))] - if let Some(latest_version) = updates::get_upgrade_version(&initial_config) { - use crate::history_cell::padded_emoji; - use crate::history_cell::with_border_with_inner_width; - use ratatui::style::Stylize as _; - use ratatui::text::Line; - - let current_version = env!("CARGO_PKG_VERSION"); - - let mut content_lines: Vec> = vec![ - Line::from(vec![ - padded_emoji("✨").bold().cyan(), - "Update available!".bold().cyan(), - " ".into(), - format!("{current_version} -> {latest_version}.").bold(), - ]), - Line::from(""), - Line::from("See full release notes:"), - Line::from(""), - Line::from( - "https://github.com/openai/codex/releases/latest" - .cyan() - .underlined(), - ), - Line::from(""), - ]; - - if let Some(update_action) = get_update_action() { - content_lines.push(Line::from(vec![ - "Run ".into(), - update_action.command_str().cyan(), - " to update.".into(), - ])); - } else { - content_lines.push(Line::from(vec![ - "See ".into(), - "https://github.com/openai/codex".cyan().underlined(), - " for installation options.".into(), - ])); - } - - let viewport_width = tui.terminal.viewport_area.width as usize; - let inner_width = viewport_width.saturating_sub(4).max(1); - let mut lines = with_border_with_inner_width(content_lines, inner_width); - lines.push("".into()); - tui.insert_history_lines(lines); - } - // Initialize high-fidelity session event logging if enabled. session_log::maybe_init(&initial_config); @@ -525,47 +444,6 @@ async fn run_ratatui_app( app_result } -/// Get the update action from the environment. -/// Returns `None` if not managed by npm, bun, or brew. -#[cfg(not(debug_assertions))] -pub(crate) fn get_update_action() -> Option { - let exe = std::env::current_exe().unwrap_or_default(); - let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some(); - let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some(); - if managed_by_npm { - Some(UpdateAction::NpmGlobalLatest) - } else if managed_by_bun { - Some(UpdateAction::BunGlobalLatest) - } else if cfg!(target_os = "macos") - && (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local")) - { - Some(UpdateAction::BrewUpgrade) - } else { - None - } -} - -#[test] -#[cfg(not(debug_assertions))] -fn test_get_update_action() { - let prev = std::env::var_os("CODEX_MANAGED_BY_NPM"); - - // First: no npm var -> expect None (we do not run from brew in CI) - unsafe { std::env::remove_var("CODEX_MANAGED_BY_NPM") }; - assert_eq!(get_update_action(), None); - - // Then: with npm var -> expect NpmGlobalLatest - unsafe { std::env::set_var("CODEX_MANAGED_BY_NPM", "1") }; - assert_eq!(get_update_action(), Some(UpdateAction::NpmGlobalLatest)); - - // Restore prior value to avoid leaking state - if let Some(v) = prev { - unsafe { std::env::set_var("CODEX_MANAGED_BY_NPM", v) }; - } else { - unsafe { std::env::remove_var("CODEX_MANAGED_BY_NPM") }; - } -} - #[expect( clippy::print_stderr, reason = "TUI should no longer be displayed, so we can write to stderr." diff --git a/codex-rs/tui/src/update_prompt.rs b/codex-rs/tui/src/update_prompt.rs index 5da8462684..d505385b9e 100644 --- a/codex-rs/tui/src/update_prompt.rs +++ b/codex-rs/tui/src/update_prompt.rs @@ -1,6 +1,5 @@ #![cfg(not(debug_assertions))] -use crate::UpdateAction; use crate::history_cell::padded_emoji; use crate::key_hint; use crate::render::Insets; @@ -12,6 +11,7 @@ use crate::tui::FrameRequester; use crate::tui::Tui; use crate::tui::TuiEvent; use crate::updates; +use crate::updates::UpdateAction; use codex_core::config::Config; use color_eyre::Result; use crossterm::event::KeyCode; @@ -39,7 +39,7 @@ pub(crate) async fn run_update_prompt_if_needed( let Some(latest_version) = updates::get_upgrade_version_for_popup(config) else { return Ok(UpdatePromptOutcome::Continue); }; - let Some(update_action) = crate::get_update_action() else { + let Some(update_action) = crate::updates::get_update_action() else { return Ok(UpdatePromptOutcome::Continue); }; diff --git a/codex-rs/tui/src/updates.rs b/codex-rs/tui/src/updates.rs index ca004a36df..fe859e15f2 100644 --- a/codex-rs/tui/src/updates.rs +++ b/codex-rs/tui/src/updates.rs @@ -1,5 +1,3 @@ -#![cfg(any(not(debug_assertions), test))] - use chrono::DateTime; use chrono::Duration; use chrono::Utc; @@ -142,6 +140,53 @@ fn parse_version(v: &str) -> Option<(u64, u64, u64)> { Some((maj, min, pat)) } +/// Update action the CLI should perform after the TUI exits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpdateAction { + /// Update via `npm install -g @openai/codex@latest`. + NpmGlobalLatest, + /// Update via `bun install -g @openai/codex@latest`. + BunGlobalLatest, + /// Update via `brew upgrade codex`. + BrewUpgrade, +} + +#[cfg(any(not(debug_assertions), test))] +pub(crate) fn get_update_action() -> Option { + let exe = std::env::current_exe().unwrap_or_default(); + let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some(); + let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some(); + if managed_by_npm { + Some(UpdateAction::NpmGlobalLatest) + } else if managed_by_bun { + Some(UpdateAction::BunGlobalLatest) + } else if cfg!(target_os = "macos") + && (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local")) + { + Some(UpdateAction::BrewUpgrade) + } else { + None + } +} + +impl UpdateAction { + /// Returns the list of command-line arguments for invoking the update. + pub fn command_args(self) -> (&'static str, &'static [&'static str]) { + match self { + UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex@latest"]), + UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex@latest"]), + UpdateAction::BrewUpgrade => ("brew", &["upgrade", "codex"]), + } + } + + /// Returns string representation of the command-line arguments for invoking the update. + pub fn command_str(self) -> String { + let (command, args) = self.command_args(); + let args_str = args.join(" "); + format!("{command} {args_str}") + } +} + #[cfg(test)] mod tests { use super::*; @@ -165,4 +210,24 @@ mod tests { assert_eq!(parse_version(" 1.2.3 \n"), Some((1, 2, 3))); assert_eq!(is_newer(" 1.2.3 ", "1.2.2"), Some(true)); } + + #[test] + fn test_get_update_action() { + let prev = std::env::var_os("CODEX_MANAGED_BY_NPM"); + + // First: no npm var -> expect None (we do not run from brew in CI) + unsafe { std::env::remove_var("CODEX_MANAGED_BY_NPM") }; + assert_eq!(get_update_action(), None); + + // Then: with npm var -> expect NpmGlobalLatest + unsafe { std::env::set_var("CODEX_MANAGED_BY_NPM", "1") }; + assert_eq!(get_update_action(), Some(UpdateAction::NpmGlobalLatest)); + + // Restore prior value to avoid leaking state + if let Some(v) = prev { + unsafe { std::env::set_var("CODEX_MANAGED_BY_NPM", v) }; + } else { + unsafe { std::env::remove_var("CODEX_MANAGED_BY_NPM") }; + } + } } From 7d6e318f876deec8b05ee6902313194a10e22c17 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 20 Oct 2025 14:52:37 -0700 Subject: [PATCH 046/287] Reduce symbol size for tests (#5389) Test executables were huge because of detailed debugging symbols. Switch to less rich debugging symbols. --- .github/workflows/rust-ci.yml | 2 +- codex-rs/Cargo.toml | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index cbf62f72f0..7918629165 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -201,7 +201,7 @@ jobs: # Tests take too long for release builds to run them on every PR. if: ${{ matrix.profile != 'release' }} continue-on-error: true - run: cargo nextest run --all-features --no-fail-fast --target ${{ matrix.target }} + run: cargo nextest run --all-features --no-fail-fast --target ${{ matrix.target }} --cargo-profile ci-test env: RUST_BACKTRACE: 1 diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 67041c6f1d..120392f3fd 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -79,8 +79,8 @@ codex-rmcp-client = { path = "rmcp-client" } codex-stdio-to-uds = { path = "stdio-to-uds" } codex-tui = { path = "tui" } codex-utils-json-to-toml = { path = "utils/json-to-toml" } -codex-utils-readiness = { path = "utils/readiness" } codex-utils-pty = { path = "utils/pty" } +codex-utils-readiness = { path = "utils/readiness" } codex-utils-string = { path = "utils/string" } core_test_support = { path = "core/tests/common" } mcp-types = { path = "mcp-types" } @@ -257,6 +257,11 @@ strip = "symbols" # See https://github.com/openai/codex/issues/1411 for details. codegen-units = 1 +[profile.ci-test] +debug = 1 # Reduce debug symbol size +inherits = "test" +opt-level = 0 + [patch.crates-io] # Uncomment to debug local changes. # ratatui = { path = "../../ratatui" } From c782f8c68d55021c72c83bbcd0113d27fc5f6608 Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Mon, 20 Oct 2025 15:00:42 -0700 Subject: [PATCH 047/287] docs: update advanced guide details (#5395) --- docs/advanced.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/advanced.md b/docs/advanced.md index ebcd6e34fd..7010a62c95 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -10,7 +10,7 @@ Most day-to-day tuning lives in `config.toml`: set approval + sandbox presets, p Because Codex is written in Rust, it honors the `RUST_LOG` environment variable to configure its logging behavior. -The TUI defaults to `RUST_LOG=codex_core=info,codex_tui=info` and log messages are written to `~/.codex/log/codex-tui.log`, so you can leave the following running in a separate terminal to monitor log messages as they are written: +The TUI defaults to `RUST_LOG=codex_core=info,codex_tui=info,codex_rmcp_client=info` and log messages are written to `~/.codex/log/codex-tui.log`, so you can leave the following running in a separate terminal to monitor log messages as they are written: ``` tail -F ~/.codex/log/codex-tui.log @@ -22,7 +22,7 @@ See the Rust documentation on [`RUST_LOG`](https://docs.rs/env_logger/latest/env ## Model Context Protocol (MCP) {#model-context-protocol} -The Codex CLI and IDE extension is a MCP client which means that it can be configured to connect to MCP servers. For more information, refer to the [`config docs`](./config.md#connecting-to-mcp-servers). +The Codex CLI and IDE extension is a MCP client which means that it can be configured to connect to MCP servers. For more information, refer to the [`config docs`](./config.md#mcp-integration). ## Using Codex as an MCP Server {#mcp-server} @@ -43,7 +43,7 @@ Send a `tools/list` request and you will see that there are two tools available: | Property | Type | Description | | ----------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | --- | | **`prompt`** (required) | string | The initial user prompt to start the Codex conversation. | -| `approval-policy` | string | Approval policy for shell commands generated by the model: `untrusted`, `on-failure`, `never`. | +| `approval-policy` | string | Approval policy for shell commands generated by the model: `untrusted`, `on-failure`, `on-request`, `never`. | | `base-instructions` | string | The set of instructions to use instead of the default ones. | | `config` | object | Individual [config settings](https://github.com/openai/codex/blob/main/docs/config.md#config) that will override what is in `$CODEX_HOME/config.toml`. | | `cwd` | string | Working directory for the session. If relative, resolved against the server process's current directory. | | From c37469b5ba80363a50cf5a565275a4f526217d53 Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Mon, 20 Oct 2025 15:04:02 -0700 Subject: [PATCH 048/287] docs: clarify responses proxy metadata (#5406) --- codex-rs/responses-api-proxy/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/codex-rs/responses-api-proxy/README.md b/codex-rs/responses-api-proxy/README.md index 31ac9c45c4..1109271c1d 100644 --- a/codex-rs/responses-api-proxy/README.md +++ b/codex-rs/responses-api-proxy/README.md @@ -33,8 +33,8 @@ curl --fail --silent --show-error "${PROXY_BASE_URL}/shutdown" - Reads the API key from `stdin`. All callers should pipe the key in (for example, `printenv OPENAI_API_KEY | codex-responses-api-proxy`). - Formats the header value as `Bearer ` and attempts to `mlock(2)` the memory holding that header so it is not swapped to disk. - Listens on the provided port or an ephemeral port if `--port` is not specified. -- Accepts exactly `POST /v1/responses` (no query string). The request body is forwarded to `https://api.openai.com/v1/responses` with `Authorization: Bearer ` set. All original request headers (except any incoming `Authorization`) are forwarded upstream. For other requests, it responds with `403`. -- Optionally writes a single-line JSON file with server info, currently `{ "port": }`. +- Accepts exactly `POST /v1/responses` (no query string). The request body is forwarded to `https://api.openai.com/v1/responses` with `Authorization: Bearer ` set. All original request headers (except any incoming `Authorization`) are forwarded upstream, with `Host` overridden to `api.openai.com`. For other requests, it responds with `403`. +- Optionally writes a single-line JSON file with server info, currently `{ "port": , "pid": }`. - Optional `--http-shutdown` enables `GET /shutdown` to terminate the process with exit code `0`. This allows one user (e.g., `root`) to start the proxy and another unprivileged user on the host to shut it down. ## CLI @@ -50,7 +50,7 @@ codex-responses-api-proxy [--port ] [--server-info ] [--http-shutdow ## Notes - Only `POST /v1/responses` is permitted. No query strings are allowed. -- All request headers are forwarded to the upstream call (aside from overriding `Authorization`). Response status and content-type are mirrored from upstream. +- All request headers are forwarded to the upstream call (aside from overriding `Authorization` and `Host`). Response status and content-type are mirrored from upstream. ## Hardening Details From 740b4a95f44aa0a2671d0195d45a8c7185cd9b21 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Mon, 20 Oct 2025 15:35:36 -0700 Subject: [PATCH 049/287] [MCP] Add configuration options to enable or disable specific tools (#5367) Some MCP servers expose a lot of tools. In those cases, it is reasonable to allow/denylist tools for Codex to use so it doesn't get overwhelmed with too many tools. The new configuration options available in the `mcp_server` toml table are: * `enabled_tools` * `disabled_tools` Fixes #4796 --- codex-rs/cli/src/mcp_cmd.rs | 24 ++++ codex-rs/cli/tests/mcp_list.rs | 25 ++++ codex-rs/core/src/config.rs | 75 ++++++++++ codex-rs/core/src/config_types.rs | 129 +++++++++--------- codex-rs/core/src/mcp_connection_manager.rs | 143 +++++++++++++++++++- codex-rs/core/tests/suite/rmcp_client.rs | 8 ++ codex-rs/tui/src/history_cell.rs | 30 ++-- docs/config.md | 8 ++ 8 files changed, 361 insertions(+), 81 deletions(-) diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 50274f3ee5..3a4d0358e7 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -253,6 +253,8 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re enabled: true, startup_timeout_sec: None, tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, }; servers.insert(name.clone(), new_entry); @@ -676,6 +678,8 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re "name": get_args.name, "enabled": server.enabled, "transport": transport, + "enabled_tools": server.enabled_tools.clone(), + "disabled_tools": server.disabled_tools.clone(), "startup_timeout_sec": server .startup_timeout_sec .map(|timeout| timeout.as_secs_f64()), @@ -687,8 +691,28 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re return Ok(()); } + if !server.enabled { + println!("{} (disabled)", get_args.name); + return Ok(()); + } + println!("{}", get_args.name); println!(" enabled: {}", server.enabled); + let format_tool_list = |tools: &Option>| -> String { + match tools { + Some(list) if list.is_empty() => "[]".to_string(), + Some(list) => list.join(", "), + None => "-".to_string(), + } + }; + if server.enabled_tools.is_some() { + let enabled_tools_display = format_tool_list(&server.enabled_tools); + println!(" enabled_tools: {enabled_tools_display}"); + } + if server.disabled_tools.is_some() { + let disabled_tools_display = format_tool_list(&server.disabled_tools); + println!(" disabled_tools: {disabled_tools_display}"); + } match &server.transport { McpServerTransportConfig::Stdio { command, diff --git a/codex-rs/cli/tests/mcp_list.rs b/codex-rs/cli/tests/mcp_list.rs index ea0d6fc1d9..6440660cdf 100644 --- a/codex-rs/cli/tests/mcp_list.rs +++ b/codex-rs/cli/tests/mcp_list.rs @@ -134,3 +134,28 @@ async fn list_and_get_render_expected_output() -> Result<()> { Ok(()) } + +#[tokio::test] +async fn get_disabled_server_shows_single_line() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut add = codex_command(codex_home.path())?; + add.args(["mcp", "add", "docs", "--", "docs-server"]) + .assert() + .success(); + + let mut servers = load_global_mcp_servers(codex_home.path()).await?; + let docs = servers + .get_mut("docs") + .expect("docs server should exist after add"); + docs.enabled = false; + write_global_mcp_servers(codex_home.path(), &servers)?; + + let mut get_cmd = codex_command(codex_home.path())?; + let get_output = get_cmd.args(["mcp", "get", "docs"]).output()?; + assert!(get_output.status.success()); + let stdout = String::from_utf8(get_output.stdout)?; + assert_eq!(stdout.trim_end(), "docs (disabled)"); + + Ok(()) +} diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 93d0ad4ba0..fb974045a5 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -484,6 +484,16 @@ pub fn write_global_mcp_servers( entry["tool_timeout_sec"] = toml_edit::value(timeout.as_secs_f64()); } + if let Some(enabled_tools) = &config.enabled_tools { + entry["enabled_tools"] = + TomlItem::Value(enabled_tools.iter().collect::().into()); + } + + if let Some(disabled_tools) = &config.disabled_tools { + entry["disabled_tools"] = + TomlItem::Value(disabled_tools.iter().collect::().into()); + } + doc["mcp_servers"][name.as_str()] = TomlItem::Table(entry); } } @@ -1923,6 +1933,8 @@ approve_all = true enabled: true, startup_timeout_sec: Some(Duration::from_secs(3)), tool_timeout_sec: Some(Duration::from_secs(5)), + enabled_tools: None, + disabled_tools: None, }, ); @@ -2059,6 +2071,8 @@ bearer_token = "secret" enabled: true, startup_timeout_sec: None, tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, }, )]); @@ -2121,6 +2135,8 @@ ZIG_VAR = "3" enabled: true, startup_timeout_sec: None, tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, }, )]); @@ -2163,6 +2179,8 @@ ZIG_VAR = "3" enabled: true, startup_timeout_sec: None, tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, }, )]); @@ -2204,6 +2222,8 @@ ZIG_VAR = "3" enabled: true, startup_timeout_sec: Some(Duration::from_secs(2)), tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, }, )]); @@ -2261,6 +2281,8 @@ startup_timeout_sec = 2.0 enabled: true, startup_timeout_sec: Some(Duration::from_secs(2)), tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, }, )]); write_global_mcp_servers(codex_home.path(), &servers)?; @@ -2330,6 +2352,8 @@ X-Auth = "DOCS_AUTH" enabled: true, startup_timeout_sec: Some(Duration::from_secs(2)), tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, }, )]); @@ -2351,6 +2375,8 @@ X-Auth = "DOCS_AUTH" enabled: true, startup_timeout_sec: None, tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, }, ); write_global_mcp_servers(codex_home.path(), &servers)?; @@ -2410,6 +2436,8 @@ url = "https://example.com/mcp" enabled: true, startup_timeout_sec: Some(Duration::from_secs(2)), tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, }, ), ( @@ -2425,6 +2453,8 @@ url = "https://example.com/mcp" enabled: true, startup_timeout_sec: None, tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, }, ), ]); @@ -2499,6 +2529,8 @@ url = "https://example.com/mcp" enabled: false, startup_timeout_sec: None, tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, }, )]); @@ -2518,6 +2550,49 @@ url = "https://example.com/mcp" Ok(()) } + #[tokio::test] + async fn write_global_mcp_servers_serializes_tool_filters() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let servers = BTreeMap::from([( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "docs-server".to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }, + enabled: true, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: Some(vec!["allowed".to_string()]), + disabled_tools: Some(vec!["blocked".to_string()]), + }, + )]); + + write_global_mcp_servers(codex_home.path(), &servers)?; + + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + let serialized = std::fs::read_to_string(&config_path)?; + assert!(serialized.contains(r#"enabled_tools = ["allowed"]"#)); + assert!(serialized.contains(r#"disabled_tools = ["blocked"]"#)); + + let loaded = load_global_mcp_servers(codex_home.path()).await?; + let docs = loaded.get("docs").expect("docs entry"); + assert_eq!( + docs.enabled_tools.as_ref(), + Some(&vec!["allowed".to_string()]) + ); + assert_eq!( + docs.disabled_tools.as_ref(), + Some(&vec!["blocked".to_string()]) + ); + + Ok(()) + } + #[tokio::test] async fn persist_model_selection_updates_defaults() -> anyhow::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index 3da6108631..2f4be2b6da 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -35,6 +35,14 @@ pub struct McpServerConfig { /// Default timeout for MCP tool calls initiated via this server. #[serde(default, with = "option_duration_secs")] pub tool_timeout_sec: Option, + + /// Explicit allow-list of tools exposed from this server. When set, only these tools will be registered. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled_tools: Option>, + + /// Explicit deny-list of tools. These tools will be removed after applying `enabled_tools`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disabled_tools: Option>, } impl<'de> Deserialize<'de> for McpServerConfig { @@ -42,7 +50,7 @@ impl<'de> Deserialize<'de> for McpServerConfig { where D: Deserializer<'de>, { - #[derive(Deserialize)] + #[derive(Deserialize, Clone)] struct RawMcpServerConfig { // stdio command: Option, @@ -72,9 +80,13 @@ impl<'de> Deserialize<'de> for McpServerConfig { tool_timeout_sec: Option, #[serde(default)] enabled: Option, + #[serde(default)] + enabled_tools: Option>, + #[serde(default)] + disabled_tools: Option>, } - let raw = RawMcpServerConfig::deserialize(deserializer)?; + let mut raw = RawMcpServerConfig::deserialize(deserializer)?; let startup_timeout_sec = match (raw.startup_timeout_sec, raw.startup_timeout_ms) { (Some(sec), _) => { @@ -84,6 +96,10 @@ impl<'de> Deserialize<'de> for McpServerConfig { (None, Some(ms)) => Some(Duration::from_millis(ms)), (None, None) => None, }; + let tool_timeout_sec = raw.tool_timeout_sec; + let enabled = raw.enabled.unwrap_or_else(default_enabled); + let enabled_tools = raw.enabled_tools.clone(); + let disabled_tools = raw.disabled_tools.clone(); fn throw_if_set(transport: &str, field: &str, value: Option<&T>) -> Result<(), E> where @@ -97,72 +113,46 @@ impl<'de> Deserialize<'de> for McpServerConfig { ))) } - let transport = match raw { - RawMcpServerConfig { - command: Some(command), - args, - env, - env_vars, - cwd, - url, - bearer_token_env_var, - http_headers, - env_http_headers, - .. - } => { - throw_if_set("stdio", "url", url.as_ref())?; - throw_if_set( - "stdio", - "bearer_token_env_var", - bearer_token_env_var.as_ref(), - )?; - throw_if_set("stdio", "http_headers", http_headers.as_ref())?; - throw_if_set("stdio", "env_http_headers", env_http_headers.as_ref())?; - McpServerTransportConfig::Stdio { - command, - args: args.unwrap_or_default(), - env, - env_vars: env_vars.unwrap_or_default(), - cwd, - } - } - RawMcpServerConfig { - url: Some(url), - bearer_token, - bearer_token_env_var, + let transport = if let Some(command) = raw.command.clone() { + throw_if_set("stdio", "url", raw.url.as_ref())?; + throw_if_set( + "stdio", + "bearer_token_env_var", + raw.bearer_token_env_var.as_ref(), + )?; + throw_if_set("stdio", "bearer_token", raw.bearer_token.as_ref())?; + throw_if_set("stdio", "http_headers", raw.http_headers.as_ref())?; + throw_if_set("stdio", "env_http_headers", raw.env_http_headers.as_ref())?; + McpServerTransportConfig::Stdio { command, - args, - env, - env_vars, - cwd, - http_headers, - env_http_headers, - startup_timeout_sec: _, - tool_timeout_sec: _, - startup_timeout_ms: _, - enabled: _, - } => { - throw_if_set("streamable_http", "command", command.as_ref())?; - throw_if_set("streamable_http", "args", args.as_ref())?; - throw_if_set("streamable_http", "env", env.as_ref())?; - throw_if_set("streamable_http", "env_vars", env_vars.as_ref())?; - throw_if_set("streamable_http", "cwd", cwd.as_ref())?; - throw_if_set("streamable_http", "bearer_token", bearer_token.as_ref())?; - McpServerTransportConfig::StreamableHttp { - url, - bearer_token_env_var, - http_headers, - env_http_headers, - } + args: raw.args.clone().unwrap_or_default(), + env: raw.env.clone(), + env_vars: raw.env_vars.clone().unwrap_or_default(), + cwd: raw.cwd.take(), } - _ => return Err(SerdeError::custom("invalid transport")), + } else if let Some(url) = raw.url.clone() { + throw_if_set("streamable_http", "args", raw.args.as_ref())?; + throw_if_set("streamable_http", "env", raw.env.as_ref())?; + throw_if_set("streamable_http", "env_vars", raw.env_vars.as_ref())?; + throw_if_set("streamable_http", "cwd", raw.cwd.as_ref())?; + throw_if_set("streamable_http", "bearer_token", raw.bearer_token.as_ref())?; + McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var: raw.bearer_token_env_var.clone(), + http_headers: raw.http_headers.clone(), + env_http_headers: raw.env_http_headers.take(), + } + } else { + return Err(SerdeError::custom("invalid transport")); }; Ok(Self { transport, startup_timeout_sec, - tool_timeout_sec: raw.tool_timeout_sec, - enabled: raw.enabled.unwrap_or_else(default_enabled), + tool_timeout_sec, + enabled, + enabled_tools, + disabled_tools, }) } } @@ -527,6 +517,8 @@ mod tests { } ); assert!(cfg.enabled); + assert!(cfg.enabled_tools.is_none()); + assert!(cfg.disabled_tools.is_none()); } #[test] @@ -701,6 +693,21 @@ mod tests { ); } + #[test] + fn deserialize_server_config_with_tool_filters() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + enabled_tools = ["allowed"] + disabled_tools = ["blocked"] + "#, + ) + .expect("should deserialize tool filters"); + + assert_eq!(cfg.enabled_tools, Some(vec!["allowed".to_string()])); + assert_eq!(cfg.disabled_tools, Some(vec!["blocked".to_string()])); + } + #[test] fn deserialize_rejects_command_and_url() { toml::from_str::( diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 9ea634428c..edf612c433 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -237,6 +237,9 @@ pub(crate) struct McpConnectionManager { /// Fully qualified tool name -> tool instance. tools: HashMap, + + /// Server-name -> configured tool filters. + tool_filters: HashMap, } impl McpConnectionManager { @@ -261,6 +264,7 @@ impl McpConnectionManager { // Launch all configured servers concurrently. let mut join_set = JoinSet::new(); let mut errors = ClientStartErrors::new(); + let mut tool_filters: HashMap = HashMap::new(); for (server_name, cfg) in mcp_servers { // Validate server name before spawning @@ -273,11 +277,13 @@ impl McpConnectionManager { } if !cfg.enabled { + tool_filters.insert(server_name, ToolFilter::from_config(&cfg)); continue; } let startup_timeout = cfg.startup_timeout_sec.unwrap_or(DEFAULT_STARTUP_TIMEOUT); let tool_timeout = cfg.tool_timeout_sec.unwrap_or(DEFAULT_TOOL_TIMEOUT); + tool_filters.insert(server_name.clone(), ToolFilter::from_config(&cfg)); let resolved_bearer_token = match &cfg.transport { McpServerTransportConfig::StreamableHttp { @@ -393,9 +399,17 @@ impl McpConnectionManager { } }; - let tools = qualify_tools(all_tools); + let filtered_tools = filter_tools(all_tools, &tool_filters); + let tools = qualify_tools(filtered_tools); - Ok((Self { clients, tools }, errors)) + Ok(( + Self { + clients, + tools, + tool_filters, + }, + errors, + )) } /// Returns a single map that contains all tools. Each key is the @@ -541,6 +555,13 @@ impl McpConnectionManager { tool: &str, arguments: Option, ) -> Result { + if let Some(filter) = self.tool_filters.get(server) + && !filter.allows(tool) + { + return Err(anyhow!( + "tool '{tool}' is disabled for MCP server '{server}'" + )); + } let managed = self .clients .get(server) @@ -619,6 +640,52 @@ impl McpConnectionManager { } } +/// A tool is allowed to be used if both are true: +/// 1. enabled is None (no allowlist is set) or the tool is explicitly enabled. +/// 2. The tool is not explicitly disabled. +#[derive(Default, Clone)] +struct ToolFilter { + enabled: Option>, + disabled: HashSet, +} + +impl ToolFilter { + fn from_config(cfg: &McpServerConfig) -> Self { + let enabled = cfg + .enabled_tools + .as_ref() + .map(|tools| tools.iter().cloned().collect::>()); + let disabled = cfg + .disabled_tools + .as_ref() + .map(|tools| tools.iter().cloned().collect::>()) + .unwrap_or_default(); + + Self { enabled, disabled } + } + + fn allows(&self, tool_name: &str) -> bool { + if let Some(enabled) = &self.enabled + && !enabled.contains(tool_name) + { + return false; + } + + !self.disabled.contains(tool_name) + } +} + +fn filter_tools(tools: Vec, filters: &HashMap) -> Vec { + tools + .into_iter() + .filter(|tool| { + filters + .get(&tool.server_name) + .is_none_or(|filter| filter.allows(&tool.tool_name)) + }) + .collect() +} + fn resolve_bearer_token( server_name: &str, bearer_token_env_var: Option<&str>, @@ -711,6 +778,7 @@ fn is_valid_mcp_server_name(server_name: &str) -> bool { mod tests { use super::*; use mcp_types::ToolInputSchema; + use std::collections::HashSet; fn create_test_tool(server_name: &str, tool_name: &str) -> ToolInfo { ToolInfo { @@ -793,4 +861,75 @@ mod tests { "mcp__my_server__yet_anot419a82a89325c1b477274a41f8c65ea5f3a7f341" ); } + + #[test] + fn tool_filter_allows_by_default() { + let filter = ToolFilter::default(); + + assert!(filter.allows("any")); + } + + #[test] + fn tool_filter_applies_enabled_list() { + let filter = ToolFilter { + enabled: Some(HashSet::from(["allowed".to_string()])), + disabled: HashSet::new(), + }; + + assert!(filter.allows("allowed")); + assert!(!filter.allows("denied")); + } + + #[test] + fn tool_filter_applies_disabled_list() { + let filter = ToolFilter { + enabled: None, + disabled: HashSet::from(["blocked".to_string()]), + }; + + assert!(!filter.allows("blocked")); + assert!(filter.allows("open")); + } + + #[test] + fn tool_filter_applies_enabled_then_disabled() { + let filter = ToolFilter { + enabled: Some(HashSet::from(["keep".to_string(), "remove".to_string()])), + disabled: HashSet::from(["remove".to_string()]), + }; + + assert!(filter.allows("keep")); + assert!(!filter.allows("remove")); + assert!(!filter.allows("unknown")); + } + + #[test] + fn filter_tools_applies_per_server_filters() { + let tools = vec![ + create_test_tool("server1", "tool_a"), + create_test_tool("server1", "tool_b"), + create_test_tool("server2", "tool_a"), + ]; + let mut filters = HashMap::new(); + filters.insert( + "server1".to_string(), + ToolFilter { + enabled: Some(HashSet::from(["tool_a".to_string(), "tool_b".to_string()])), + disabled: HashSet::from(["tool_b".to_string()]), + }, + ); + filters.insert( + "server2".to_string(), + ToolFilter { + enabled: None, + disabled: HashSet::from(["tool_a".to_string()]), + }, + ); + + let filtered = filter_tools(tools, &filters); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].server_name, "server1"); + assert_eq!(filtered[0].tool_name, "tool_a"); + } } diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 609ae87118..99c863b8f9 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -94,6 +94,8 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { enabled: true, startup_timeout_sec: Some(Duration::from_secs(10)), tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, }, ); }) @@ -230,6 +232,8 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { enabled: true, startup_timeout_sec: Some(Duration::from_secs(10)), tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, }, ); }) @@ -381,6 +385,8 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { enabled: true, startup_timeout_sec: Some(Duration::from_secs(10)), tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, }, ); }) @@ -564,6 +570,8 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> { enabled: true, startup_timeout_sec: Some(Duration::from_secs(10)), tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, }, ); }) diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 6bae1bd0c3..fe85fb8716 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1056,18 +1056,21 @@ pub(crate) fn new_mcp_tools_output( .collect(); names.sort(); - let status = auth_statuses + let auth_status = auth_statuses .get(server.as_str()) .copied() .unwrap_or(McpAuthStatus::Unsupported); - lines.push(vec![" • Server: ".into(), server.clone().into()].into()); - let status_line = if cfg.enabled { - vec![" • Status: ".into(), "enabled".green()].into() - } else { - vec![" • Status: ".into(), "disabled".red()].into() - }; - lines.push(status_line); - lines.push(vec![" • Auth: ".into(), status.to_string().into()].into()); + let mut header: Vec> = vec![" • ".into(), server.clone().into()]; + if !cfg.enabled { + header.push(" ".into()); + header.push("(disabled)".red()); + lines.push(header.into()); + lines.push(Line::from("")); + continue; + } + lines.push(header.into()); + lines.push(vec![" • Status: ".into(), "enabled".green()].into()); + lines.push(vec![" • Auth: ".into(), auth_status.to_string().into()].into()); match &cfg.transport { McpServerTransportConfig::Stdio { @@ -1128,15 +1131,6 @@ pub(crate) fn new_mcp_tools_output( } } - if !cfg.enabled { - let disabled = "(disabled)".red(); - lines.push(vec![" • Tools: ".into(), disabled.clone()].into()); - lines.push(vec![" • Resources: ".into(), disabled.clone()].into()); - lines.push(vec![" • Resource templates: ".into(), disabled].into()); - lines.push(Line::from("")); - continue; - } - if names.is_empty() { lines.push(" • Tools: (none)".into()); } else { diff --git a/docs/config.md b/docs/config.md index 8d495214c3..b92342b2c9 100644 --- a/docs/config.md +++ b/docs/config.md @@ -441,8 +441,14 @@ startup_timeout_sec = 20 tool_timeout_sec = 30 # Optional: disable a server without removing it enabled = false +# Optional: only expose a subset of tools from this server +enabled_tools = ["search", "summarize"] +# Optional: hide specific tools (applied after `enabled_tools`, if set) +disabled_tools = ["search"] ``` +When both `enabled_tools` and `disabled_tools` are specified, Codex first restricts the server to the allow-list and then removes any tools that appear in the deny-list. + #### Experimental RMCP client Codex is transitioning to the [official Rust MCP SDK](https://github.com/modelcontextprotocol/rust-sdk). @@ -871,6 +877,8 @@ If `forced_chatgpt_workspace_id` is set but `forced_login_method` is not set, AP | `mcp_servers..enabled` | boolean | When false, Codex skips starting the server (default: true). | | `mcp_servers..startup_timeout_sec` | number | Startup timeout in seconds (default: 10). Timeout is applied both for initializing MCP server and initially listing tools. | | `mcp_servers..tool_timeout_sec` | number | Per-tool timeout in seconds (default: 60). Accepts fractional values; omit to use the default. | +| `mcp_servers..enabled_tools` | array | Restrict the server to the listed tool names. | +| `mcp_servers..disabled_tools` | array | Remove the listed tool names after applying `enabled_tools`, if any. | | `model_providers..name` | string | Display name. | | `model_providers..base_url` | string | API base URL. | | `model_providers..env_key` | string | Env var for API key. | From 32d50bda944661afcbe992f77d9fb0b2886130ca Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Mon, 20 Oct 2025 15:52:25 -0700 Subject: [PATCH 050/287] Treat `zsh -lc` like `bash -lc` (#5411) Without proper `zsh -lc` parsing, we lose some things like proper command parsing, turn diff tracking, safe command checks, and other things we expect from raw or `bash -lc` commands. --- codex-rs/core/src/bash.rs | 26 +++++++++----- .../command_safety/is_dangerous_command.rs | 13 +++++-- .../src/command_safety/is_safe_command.rs | 9 +++-- codex-rs/core/src/parse_command.rs | 36 +++++++++++++------ 4 files changed, 60 insertions(+), 24 deletions(-) diff --git a/codex-rs/core/src/bash.rs b/codex-rs/core/src/bash.rs index c87f2764b1..07fc125f16 100644 --- a/codex-rs/core/src/bash.rs +++ b/codex-rs/core/src/bash.rs @@ -5,13 +5,13 @@ use tree_sitter_bash::LANGUAGE as BASH; /// Parse the provided bash source using tree-sitter-bash, returning a Tree on /// success or None if parsing failed. -pub fn try_parse_bash(bash_lc_arg: &str) -> Option { +pub fn try_parse_shell(shell_lc_arg: &str) -> Option { let lang = BASH.into(); let mut parser = Parser::new(); #[expect(clippy::expect_used)] parser.set_language(&lang).expect("load bash grammar"); let old_tree: Option<&Tree> = None; - parser.parse(bash_lc_arg, old_tree) + parser.parse(shell_lc_arg, old_tree) } /// Parse a script which may contain multiple simple commands joined only by @@ -88,18 +88,19 @@ pub fn try_parse_word_only_commands_sequence(tree: &Tree, src: &str) -> Option Option>> { - let [bash, flag, script] = command else { +/// Returns the sequence of plain commands within a `bash -lc "..."` or +/// `zsh -lc "..."` invocation when the script only contains word-only commands +/// joined by safe operators. +pub fn parse_shell_lc_plain_commands(command: &[String]) -> Option>> { + let [shell, flag, script] = command else { return None; }; - if bash != "bash" || flag != "-lc" { + if flag != "-lc" || !(shell == "bash" || shell == "zsh") { return None; } - let tree = try_parse_bash(script)?; + let tree = try_parse_shell(script)?; try_parse_word_only_commands_sequence(&tree, script) } @@ -154,7 +155,7 @@ mod tests { use super::*; fn parse_seq(src: &str) -> Option>> { - let tree = try_parse_bash(src)?; + let tree = try_parse_shell(src)?; try_parse_word_only_commands_sequence(&tree, src) } @@ -234,4 +235,11 @@ mod tests { fn rejects_trailing_operator_parse_error() { assert!(parse_seq("ls &&").is_none()); } + + #[test] + fn parse_zsh_lc_plain_commands() { + let command = vec!["zsh".to_string(), "-lc".to_string(), "ls".to_string()]; + let parsed = parse_shell_lc_plain_commands(&command).unwrap(); + assert_eq!(parsed, vec![vec!["ls".to_string()]]); + } } diff --git a/codex-rs/core/src/command_safety/is_dangerous_command.rs b/codex-rs/core/src/command_safety/is_dangerous_command.rs index 852af93ef9..b422513674 100644 --- a/codex-rs/core/src/command_safety/is_dangerous_command.rs +++ b/codex-rs/core/src/command_safety/is_dangerous_command.rs @@ -1,4 +1,4 @@ -use crate::bash::parse_bash_lc_plain_commands; +use crate::bash::parse_shell_lc_plain_commands; pub fn command_might_be_dangerous(command: &[String]) -> bool { if is_dangerous_to_call_with_exec(command) { @@ -6,7 +6,7 @@ pub fn command_might_be_dangerous(command: &[String]) -> bool { } // Support `bash -lc "