diff --git a/README.md b/README.md index 5cc7fd4953..77c8d2199c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ -

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

Codex CLI is a coding agent from OpenAI that runs locally on your computer.

Codex CLI splash @@ -14,7 +13,19 @@ If you want Codex in your code editor (VS Code, Cursor, Windsurf), | null, allowedSandboxModes: Array | null, allowedPermissions: Array | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; +export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedPermissions: Array | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 0fbb93f397..c0c7d55528 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -1393,6 +1393,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -1471,6 +1472,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-image".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -1806,6 +1808,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -1866,6 +1869,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -1978,6 +1982,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2043,6 +2048,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2109,6 +2115,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2199,6 +2206,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2283,6 +2291,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2347,6 +2356,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2367,6 +2377,7 @@ mod tests { }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2441,6 +2452,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2461,6 +2473,7 @@ mod tests { }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2530,6 +2543,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2595,6 +2609,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2660,6 +2675,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2680,6 +2696,7 @@ mod tests { }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2728,6 +2745,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2748,6 +2766,7 @@ mod tests { }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2789,6 +2808,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-compact".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -3042,6 +3062,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -3098,6 +3119,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -3156,6 +3178,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -3204,6 +3227,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 25f62f2c24..fffe213811 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -361,6 +361,7 @@ pub struct ConfigRequirements { pub allowed_permissions: Option>, pub allowed_web_search_modes: Option>, pub allow_managed_hooks_only: Option, + pub allow_appshots: Option, pub computer_use: Option, pub feature_requirements: Option>, #[experimental("configRequirements/read.hooks")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 7112e9eb13..8f6824f397 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -1593,6 +1593,7 @@ fn config_requirements_granular_allowed_approval_policy_is_marked_experimental() allowed_permissions: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, feature_requirements: None, hooks: None, diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index efe6c69a32..5f784ea23a 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -3219,6 +3219,7 @@ mod tests { "turn-1", &EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + trace_id: None, started_at: Some(42), model_context_window: None, collaboration_mode_kind: Default::default(), @@ -3252,6 +3253,7 @@ mod tests { id: "turn-1".to_string(), msg: EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + trace_id: None, started_at: Some(42), model_context_window: None, collaboration_mode_kind: Default::default(), @@ -3301,6 +3303,7 @@ mod tests { &event_turn_id, &EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { turn_id: event_turn_id.clone(), + trace_id: None, started_at: Some(42), model_context_window: None, collaboration_mode_kind: Default::default(), diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 8f50a3c216..72948270a6 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -431,6 +431,7 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR normalized }), allow_managed_hooks_only: requirements.allow_managed_hooks_only, + allow_appshots: requirements.allow_appshots, computer_use: requirements .computer_use .map(map_computer_use_requirements_to_api), @@ -658,6 +659,17 @@ mod tests { assert_eq!(mapped.hooks, None); } + #[test] + fn requirements_api_includes_allow_appshots() { + let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { + allow_appshots: Some(false), + ..ConfigRequirementsToml::default() + }); + + assert_eq!(mapped.allow_appshots, Some(false)); + assert_eq!(mapped.hooks, None); + } + #[test] fn requirements_api_includes_computer_use_requirements() { let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index f4593ab7c4..c6b5541aa2 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -1155,6 +1155,7 @@ mod thread_processor_behavior_tests { "turn-1", &EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index f21a973f63..6416499663 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -1431,6 +1431,7 @@ async fn thread_resume_token_usage_replay_ignores_stale_interrupted_tail_turn() "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: stale_turn_id.to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -1518,6 +1519,7 @@ async fn thread_resume_token_usage_replay_can_belong_to_interrupted_turn() -> Re "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: interrupted_turn_id.to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -1835,6 +1837,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 4833ff2c6d..918d81aad2 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -41,9 +41,11 @@ use seatbelt::DenialLogger; pub async fn run_command_under_seatbelt( command: SeatbeltCommand, codex_linux_sandbox_exe: Option, + loader_overrides: LoaderOverrides, ) -> anyhow::Result<()> { let SeatbeltCommand { permissions_profile, + config_profile: _, cwd, include_managed_config, allow_unix_sockets, @@ -60,6 +62,7 @@ pub async fn run_command_under_seatbelt( permissions_profile, cwd, managed_requirements_mode, + loader_overrides, }, command, config_overrides, @@ -75,6 +78,7 @@ pub async fn run_command_under_seatbelt( pub async fn run_command_under_seatbelt( _command: SeatbeltCommand, _codex_linux_sandbox_exe: Option, + _loader_overrides: LoaderOverrides, ) -> anyhow::Result<()> { anyhow::bail!("Seatbelt sandbox is only available on macOS"); } @@ -82,9 +86,11 @@ pub async fn run_command_under_seatbelt( pub async fn run_command_under_landlock( command: LandlockCommand, codex_linux_sandbox_exe: Option, + loader_overrides: LoaderOverrides, ) -> anyhow::Result<()> { let LandlockCommand { permissions_profile, + config_profile: _, cwd, include_managed_config, config_overrides, @@ -99,6 +105,7 @@ pub async fn run_command_under_landlock( permissions_profile, cwd, managed_requirements_mode, + loader_overrides, }, command, config_overrides, @@ -113,9 +120,11 @@ pub async fn run_command_under_landlock( pub async fn run_command_under_windows_sandbox( command: WindowsCommand, codex_linux_sandbox_exe: Option, + loader_overrides: LoaderOverrides, ) -> anyhow::Result<()> { let WindowsCommand { permissions_profile, + config_profile: _, cwd, include_managed_config, config_overrides, @@ -130,6 +139,7 @@ pub async fn run_command_under_windows_sandbox( permissions_profile, cwd, managed_requirements_mode, + loader_overrides, }, command, config_overrides, @@ -153,6 +163,7 @@ struct DebugSandboxConfigOptions { permissions_profile: Option, cwd: Option, managed_requirements_mode: ManagedRequirementsMode, + loader_overrides: LoaderOverrides, } #[derive(Debug, Clone, Copy)] @@ -650,7 +661,7 @@ async fn load_debug_sandbox_config( } async fn load_debug_sandbox_config_with_codex_home( - mut cli_overrides: Vec<(String, TomlValue)>, + cli_overrides: Vec<(String, TomlValue)>, codex_linux_sandbox_exe: Option, options: DebugSandboxConfigOptions, codex_home: Option, @@ -660,7 +671,9 @@ async fn load_debug_sandbox_config_with_codex_home( permissions_profile, cwd, managed_requirements_mode, + loader_overrides, } = options; + let mut cli_overrides = cli_overrides; if let Some(permissions_profile) = permissions_profile { cli_overrides.push(( @@ -674,7 +687,7 @@ async fn load_debug_sandbox_config_with_codex_home( // config. Keep that behavior unless this invocation explicitly passes a // legacy `sandbox_mode` CLI override for compatibility with older callers. let uses_legacy_sandbox_mode_override = cli_overrides_use_legacy_sandbox_mode(&cli_overrides); - let config = build_debug_sandbox_config( + let config = build_debug_sandbox_config_with_loader_overrides( cli_overrides.clone(), ConfigOverrides { cwd: cwd.clone(), @@ -683,6 +696,7 @@ async fn load_debug_sandbox_config_with_codex_home( }, codex_home.clone(), managed_requirements_mode, + loader_overrides.clone(), strict_config, ) .await?; @@ -691,7 +705,7 @@ async fn load_debug_sandbox_config_with_codex_home( return Ok(config); } - build_debug_sandbox_config( + build_debug_sandbox_config_with_loader_overrides( cli_overrides, ConfigOverrides { sandbox_mode: Some(SandboxMode::ReadOnly), @@ -701,17 +715,19 @@ async fn load_debug_sandbox_config_with_codex_home( }, codex_home, managed_requirements_mode, + loader_overrides, strict_config, ) .await .map_err(Into::into) } -async fn build_debug_sandbox_config( +async fn build_debug_sandbox_config_with_loader_overrides( cli_overrides: Vec<(String, TomlValue)>, harness_overrides: ConfigOverrides, codex_home: Option, managed_requirements_mode: ManagedRequirementsMode, + mut loader_overrides: LoaderOverrides, strict_config: bool, ) -> std::io::Result { let mut builder = ConfigBuilder::default() @@ -719,11 +735,9 @@ async fn build_debug_sandbox_config( .harness_overrides(harness_overrides) .strict_config(strict_config); if matches!(managed_requirements_mode, ManagedRequirementsMode::Ignore) { - builder = builder.loader_overrides(LoaderOverrides { - ignore_managed_requirements: true, - ..LoaderOverrides::default() - }); + loader_overrides.ignore_managed_requirements = true; } + builder = builder.loader_overrides(loader_overrides); if let Some(codex_home) = codex_home { builder = builder .codex_home(codex_home.clone()) @@ -750,6 +764,24 @@ mod tests { use pretty_assertions::assert_eq; use tempfile::TempDir; + async fn build_debug_sandbox_config( + cli_overrides: Vec<(String, TomlValue)>, + harness_overrides: ConfigOverrides, + codex_home: Option, + managed_requirements_mode: ManagedRequirementsMode, + strict_config: bool, + ) -> std::io::Result { + build_debug_sandbox_config_with_loader_overrides( + cli_overrides, + harness_overrides, + codex_home, + managed_requirements_mode, + LoaderOverrides::default(), + strict_config, + ) + .await + } + fn escape_toml_path(path: &std::path::Path) -> String { path.display().to_string().replace('\\', "\\\\") } @@ -758,6 +790,18 @@ mod tests { codex_home: &TempDir, docs: &std::path::Path, private: &std::path::Path, + ) -> std::io::Result<()> { + write_permissions_profile_config_to_path( + &codex_home.path().join("config.toml"), + docs, + private, + ) + } + + fn write_permissions_profile_config_to_path( + config_path: &std::path::Path, + docs: &std::path::Path, + private: &std::path::Path, ) -> std::io::Result<()> { std::fs::create_dir_all(private)?; let config = format!( @@ -772,7 +816,7 @@ mod tests { escape_toml_path(docs), escape_toml_path(private), ); - std::fs::write(codex_home.path().join("config.toml"), config)?; + std::fs::write(config_path, config)?; Ok(()) } @@ -812,6 +856,7 @@ mod tests { permissions_profile: None, cwd: None, managed_requirements_mode: ManagedRequirementsMode::Include, + loader_overrides: LoaderOverrides::default(), }, Some(codex_home_path), /*strict_config*/ false, @@ -836,6 +881,70 @@ mod tests { Ok(()) } + #[tokio::test] + async fn debug_sandbox_honors_config_profile_loader_overrides() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let sandbox_paths = TempDir::new()?; + let docs = sandbox_paths.path().join("docs"); + let private = docs.join("private"); + let profile_path = codex_home.path().join("work.config.toml"); + write_permissions_profile_config_to_path(&profile_path, &docs, &private)?; + let codex_home_path = codex_home.path().to_path_buf(); + let loader_overrides = LoaderOverrides { + user_config_path: Some(AbsolutePathBuf::from_absolute_path(&profile_path)?), + user_config_profile: Some("work".parse().expect("profile name should parse")), + ..LoaderOverrides::default() + }; + + let profile_config = build_debug_sandbox_config_with_loader_overrides( + Vec::new(), + ConfigOverrides::default(), + Some(codex_home_path.clone()), + ManagedRequirementsMode::Include, + loader_overrides.clone(), + /*strict_config*/ false, + ) + .await?; + let read_only_config = build_debug_sandbox_config( + Vec::new(), + ConfigOverrides { + sandbox_mode: Some(SandboxMode::ReadOnly), + ..Default::default() + }, + Some(codex_home_path.clone()), + ManagedRequirementsMode::Include, + /*strict_config*/ false, + ) + .await?; + + let config = load_debug_sandbox_config_with_codex_home( + Vec::new(), + /*codex_linux_sandbox_exe*/ None, + DebugSandboxConfigOptions { + permissions_profile: None, + cwd: None, + managed_requirements_mode: ManagedRequirementsMode::Include, + loader_overrides, + }, + Some(codex_home_path), + /*strict_config*/ false, + ) + .await?; + + assert!(config_uses_permission_profiles(&config)); + assert_ne!( + profile_config.permissions.file_system_sandbox_policy(), + read_only_config.permissions.file_system_sandbox_policy(), + "test fixture should distinguish the profile config from read-only" + ); + assert_eq!( + config.permissions.file_system_sandbox_policy(), + profile_config.permissions.file_system_sandbox_policy(), + ); + + Ok(()) + } + #[tokio::test] async fn debug_sandbox_honors_explicit_legacy_sandbox_mode() -> anyhow::Result<()> { let codex_home = TempDir::new()?; @@ -872,6 +981,7 @@ mod tests { permissions_profile: None, cwd: None, managed_requirements_mode: ManagedRequirementsMode::Include, + loader_overrides: LoaderOverrides::default(), }, Some(codex_home_path), /*strict_config*/ false, @@ -929,6 +1039,7 @@ mod tests { permissions_profile: None, cwd: None, managed_requirements_mode: ManagedRequirementsMode::Include, + loader_overrides: LoaderOverrides::default(), }, Some(codex_home_path), /*strict_config*/ false, @@ -955,6 +1066,7 @@ mod tests { permissions_profile: Some(":workspace".to_string()), cwd: None, managed_requirements_mode: ManagedRequirementsMode::Ignore, + loader_overrides: LoaderOverrides::default(), }, Some(codex_home.path().to_path_buf()), /*strict_config*/ false, @@ -993,6 +1105,7 @@ mod tests { permissions_profile: Some("limited-read-test".to_string()), cwd: None, managed_requirements_mode: ManagedRequirementsMode::Ignore, + loader_overrides: LoaderOverrides::default(), }, Some(codex_home.path().to_path_buf()), /*strict_config*/ false, @@ -1031,6 +1144,7 @@ mod tests { permissions_profile: Some(":workspace".to_string()), cwd: Some(cwd.path().to_path_buf()), managed_requirements_mode: ManagedRequirementsMode::Ignore, + loader_overrides: LoaderOverrides::default(), }, Some(codex_home.path().to_path_buf()), /*strict_config*/ false, diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index d9e1efe980..5e2ba0caa5 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -5,6 +5,7 @@ pub(crate) mod login; use clap::Parser; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; +use codex_utils_cli::ProfileV2Name; use std::path::PathBuf; pub use debug_sandbox::run_command_under_landlock; @@ -28,6 +29,10 @@ pub struct SeatbeltCommand { #[arg(long = "permissions-profile", value_name = "NAME")] pub permissions_profile: Option, + /// Layer $CODEX_HOME/.config.toml on top of the base user config. + #[arg(long = "profile", short = 'p')] + pub config_profile: Option, + /// Working directory used for profile resolution and command execution. #[arg( short = 'C', @@ -72,6 +77,10 @@ pub struct LandlockCommand { #[arg(long = "permissions-profile", value_name = "NAME")] pub permissions_profile: Option, + /// Layer $CODEX_HOME/.config.toml on top of the base user config. + #[arg(long = "profile", short = 'p')] + pub config_profile: Option, + /// Working directory used for profile resolution and command execution. #[arg( short = 'C', @@ -103,6 +112,10 @@ pub struct WindowsCommand { #[arg(long = "permissions-profile", value_name = "NAME")] pub permissions_profile: Option, + /// Layer $CODEX_HOME/.config.toml on top of the base user config. + #[arg(long = "profile", short = 'p')] + pub config_profile: Option, + /// Working directory used for profile resolution and command execution. #[arg( short = 'C', diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index aefc022221..4bb7ef74a0 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -353,6 +353,10 @@ type HostSandboxArgs = UnsupportedSandboxArgs; #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] #[derive(Debug, Parser)] struct UnsupportedSandboxArgs { + /// Layer $CODEX_HOME/.config.toml on top of the base user config. + #[arg(long = "profile", short = 'p')] + pub config_profile: Option, + #[clap(skip)] pub config_overrides: CliConfigOverrides, @@ -1242,6 +1246,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { root_remote_auth_token_env.as_deref(), "sandbox", )?; + let config_profile = sandbox_cli + .config_profile + .as_ref() + .or(interactive.config_profile_v2.as_ref()); + let loader_overrides = loader_overrides_for_profile(config_profile)?; prepend_config_flags( &mut sandbox_cli.config_overrides, root_config_overrides.clone(), @@ -1250,22 +1259,28 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { codex_cli::run_command_under_seatbelt( sandbox_cli, arg0_paths.codex_linux_sandbox_exe.clone(), + loader_overrides, ) .await?; #[cfg(target_os = "linux")] codex_cli::run_command_under_landlock( sandbox_cli, arg0_paths.codex_linux_sandbox_exe.clone(), + loader_overrides, ) .await?; #[cfg(target_os = "windows")] codex_cli::run_command_under_windows_sandbox( sandbox_cli, arg0_paths.codex_linux_sandbox_exe.clone(), + loader_overrides, ) .await?; #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] - anyhow::bail!("`codex sandbox` is not supported on this operating system"); + { + let _ = loader_overrides; + anyhow::bail!("`codex sandbox` is not supported on this operating system"); + } } Some(Subcommand::Debug(DebugCommand { subcommand })) => match subcommand { DebugSubcommand::Models(cmd) => { @@ -1441,11 +1456,12 @@ fn profile_v2_for_subcommand<'a>( | Subcommand::Resume(_) | Subcommand::Fork(_) | Subcommand::Mcp(_) + | Subcommand::Sandbox(_) | Subcommand::Debug(DebugCommand { subcommand: DebugSubcommand::PromptInput(_), }) => Ok(Some(profile_v2)), _ => anyhow::bail!( - "--profile only applies to runtime commands and `codex mcp`: `codex`, `codex exec`, `codex review`, `codex resume`, `codex fork`, `codex mcp`, and `codex debug prompt-input`." + "--profile only applies to runtime commands and `codex mcp`: `codex`, `codex exec`, `codex review`, `codex resume`, `codex fork`, `codex mcp`, `codex sandbox`, and `codex debug prompt-input`." ), } } @@ -2252,6 +2268,12 @@ mod tests { .as_deref(), Some("work") ); + assert_eq!( + profile_v2_for_args(&["codex", "--profile", "work", "sandbox"]) + .expect("sandbox supports config profile") + .as_deref(), + Some("work") + ); } #[test] @@ -2500,6 +2522,21 @@ mod tests { assert_eq!(command.command, vec!["echo"]); } + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] + #[test] + fn sandbox_parses_config_profile() { + let cli = + MultitoolCli::try_parse_from(["codex", "sandbox", "--profile", "work", "--", "echo"]) + .expect("parse"); + + let Some(Subcommand::Sandbox(command)) = cli.subcommand else { + panic!("expected sandbox command"); + }; + + assert_eq!(command.config_profile.as_deref(), Some("work")); + assert_eq!(command.command, vec!["echo"]); + } + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] #[test] fn sandbox_rejects_explicit_profile_controls_without_profile() { diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 3104262c23..a84d1c9019 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -8,6 +8,7 @@ use anyhow::bail; use clap::ArgGroup; use codex_config::types::AppToolApproval; use codex_config::types::McpServerConfig; +use codex_config::types::McpServerOAuthConfig; use codex_config::types::McpServerTransportConfig; use codex_core::McpManager; use codex_core::config::Config; @@ -134,6 +135,14 @@ pub struct AddMcpStreamableHttpArgs { requires = "url" )] pub bearer_token_env_var: Option, + + /// Optional OAuth client identifier to use for this MCP server. + #[arg(long = "oauth-client-id", value_name = "CLIENT_ID", requires = "url")] + pub oauth_client_id: Option, + + /// Optional OAuth resource parameter to include during MCP login. + #[arg(long = "oauth-resource", value_name = "RESOURCE", requires = "url")] + pub oauth_resource: Option, } #[derive(Debug, clap::Parser)] @@ -282,7 +291,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re .await .with_context(|| format!("failed to load MCP servers from {}", codex_home.display()))?; - let transport = match transport_args { + let (transport, oauth_client_id, oauth_resource) = match transport_args { AddMcpTransportArgs { stdio: Some(stdio), .. } => { @@ -297,27 +306,37 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re } else { Some(stdio.env.into_iter().collect::>()) }; - McpServerTransportConfig::Stdio { - command: command_bin, - args: command_args, - env: env_map, - env_vars: Vec::new(), - cwd: None, - } + ( + McpServerTransportConfig::Stdio { + command: command_bin, + args: command_args, + env: env_map, + env_vars: Vec::new(), + cwd: None, + }, + None, + None, + ) } AddMcpTransportArgs { streamable_http: Some(AddMcpStreamableHttpArgs { url, bearer_token_env_var, + oauth_client_id, + oauth_resource, }), .. - } => McpServerTransportConfig::StreamableHttp { - url, - bearer_token_env_var, - http_headers: None, - env_http_headers: None, - }, + } => ( + McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var, + http_headers: None, + env_http_headers: None, + }, + oauth_client_id, + oauth_resource, + ), AddMcpTransportArgs { .. } => bail!("exactly one of --command or --url must be provided"), }; @@ -334,8 +353,12 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re enabled_tools: None, disabled_tools: None, scopes: None, - oauth: None, - oauth_resource: None, + oauth: oauth_client_id + .clone() + .map(|client_id| McpServerOAuthConfig { + client_id: Some(client_id), + }), + oauth_resource: oauth_resource.clone(), tools: HashMap::new(), }; @@ -364,8 +387,8 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re oauth_config.http_headers, oauth_config.env_http_headers, &resolved_scopes, - /*oauth_client_id*/ None, - /*oauth_resource*/ None, + oauth_client_id.as_deref(), + oauth_resource.as_deref(), config.mcp_oauth_callback_port, config.mcp_oauth_callback_url.as_deref(), ) diff --git a/codex-rs/cli/tests/mcp_add_remove.rs b/codex-rs/cli/tests/mcp_add_remove.rs index ecfbed1264..d0fc5f327d 100644 --- a/codex-rs/cli/tests/mcp_add_remove.rs +++ b/codex-rs/cli/tests/mcp_add_remove.rs @@ -198,6 +198,42 @@ async fn add_streamable_http_with_custom_env_var() -> Result<()> { Ok(()) } +#[tokio::test] +async fn add_streamable_http_with_oauth_options() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut add_cmd = codex_command(codex_home.path())?; + add_cmd + .args([ + "mcp", + "add", + "oauth-server", + "--url", + "https://example.com/mcp", + "--oauth-client-id", + "eci-prd-pub-codex-123", + "--oauth-resource", + "https://resource.example.com", + ]) + .assert() + .success(); + + let servers = load_global_mcp_servers(codex_home.path()).await?; + let oauth_server = servers + .get("oauth-server") + .expect("oauth server should exist"); + assert_eq!( + oauth_server.oauth_client_id(), + Some("eci-prd-pub-codex-123") + ); + assert_eq!( + oauth_server.oauth_resource.as_deref(), + Some("https://resource.example.com") + ); + + Ok(()) +} + #[tokio::test] async fn add_streamable_http_rejects_removed_flag() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index 27649ad4c1..19fbd8cd1d 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -1220,6 +1220,7 @@ mod tests { remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -1305,6 +1306,7 @@ mod tests { remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -1341,6 +1343,7 @@ mod tests { remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -1394,6 +1397,7 @@ mod tests { remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -1576,6 +1580,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -1659,6 +1664,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -1740,6 +1746,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -1949,6 +1956,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -1992,6 +2000,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -2055,6 +2064,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -2114,6 +2124,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -2175,6 +2186,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -2237,6 +2249,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -2303,6 +2316,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -2395,6 +2409,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -2433,6 +2448,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, diff --git a/codex-rs/codex-api/src/endpoint/images.rs b/codex-rs/codex-api/src/endpoint/images.rs new file mode 100644 index 0000000000..9d1bd41eea --- /dev/null +++ b/codex-rs/codex-api/src/endpoint/images.rs @@ -0,0 +1,302 @@ +use crate::auth::SharedAuthProvider; +use crate::endpoint::session::EndpointSession; +use crate::error::ApiError; +use crate::images::ImageEditRequest; +use crate::images::ImageGenerationRequest; +use crate::images::ImageResponse; +use crate::provider::Provider; +use codex_client::HttpTransport; +use codex_client::RequestTelemetry; +use http::HeaderMap; +use http::Method; +use serde::Serialize; +use serde_json::to_value; +use std::sync::Arc; + +pub struct ImagesClient { + session: EndpointSession, +} + +impl ImagesClient { + pub fn new(transport: T, provider: Provider, auth: SharedAuthProvider) -> Self { + Self { + session: EndpointSession::new(transport, provider, auth), + } + } + + pub fn with_telemetry(self, request: Option>) -> Self { + Self { + session: self.session.with_request_telemetry(request), + } + } + + pub async fn generate( + &self, + request: &ImageGenerationRequest, + extra_headers: HeaderMap, + ) -> Result { + self.post_image_request( + "images/generations", + request, + extra_headers, + "image generation", + ) + .await + } + + pub async fn edit( + &self, + request: &ImageEditRequest, + extra_headers: HeaderMap, + ) -> Result { + self.post_image_request("images/edits", request, extra_headers, "image edit") + .await + } + + async fn post_image_request( + &self, + path: &str, + request: &R, + extra_headers: HeaderMap, + operation: &str, + ) -> Result { + let body = to_value(request) + .map_err(|e| ApiError::Stream(format!("failed to encode {operation} request: {e}")))?; + let resp = self + .session + .execute(Method::POST, path, extra_headers, Some(body)) + .await?; + serde_json::from_slice(&resp.body) + .map_err(|e| ApiError::Stream(format!("failed to decode {operation} response: {e}"))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::AuthProvider; + use crate::images::ImageBackground; + use crate::images::ImageData; + use crate::images::ImageQuality; + use crate::images::ImageUrl; + use crate::provider::RetryConfig; + use async_trait::async_trait; + use codex_client::Request; + use codex_client::RequestBody; + use codex_client::Response; + use codex_client::StreamResponse; + use codex_client::TransportError; + use http::StatusCode; + use pretty_assertions::assert_eq; + use serde_json::json; + use std::sync::Mutex; + use std::time::Duration; + + #[derive(Clone, Default)] + struct DummyAuth; + + impl AuthProvider for DummyAuth { + fn add_auth_headers(&self, _headers: &mut HeaderMap) {} + } + + #[derive(Clone)] + struct CapturingTransport { + last_request: Arc>>, + response_body: Arc>, + } + + impl CapturingTransport { + fn new(response_body: Vec) -> Self { + Self { + last_request: Arc::new(Mutex::new(None)), + response_body: Arc::new(response_body), + } + } + } + + #[async_trait] + impl HttpTransport for CapturingTransport { + async fn execute(&self, req: Request) -> Result { + *self.last_request.lock().expect("lock request store") = Some(req); + Ok(Response { + status: StatusCode::OK, + headers: HeaderMap::new(), + body: self.response_body.as_ref().clone().into(), + }) + } + + async fn stream(&self, _req: Request) -> Result { + Err(TransportError::Build("stream should not run".to_string())) + } + } + + fn provider() -> Provider { + Provider { + name: "test".to_string(), + base_url: "https://example.com/api/codex".to_string(), + query_params: None, + headers: HeaderMap::new(), + retry: RetryConfig { + max_attempts: 1, + base_delay: Duration::from_millis(1), + retry_429: false, + retry_5xx: true, + retry_transport: true, + }, + stream_idle_timeout: Duration::from_secs(1), + } + } + + fn response_body() -> Vec { + serde_json::to_vec(&json!({ + "created": 1778832973u64, + "background": "opaque", + "data": [{"b64_json": "REDACT"}], + "output_format": "png", + "quality": "medium", + "size": "1024x1536", + "usage": { + "input_tokens": 1474, + "input_tokens_details": { + "image_tokens": 1457, + "text_tokens": 17, + }, + "output_tokens": 1372, + "output_tokens_details": { + "image_tokens": 1372, + "text_tokens": 0, + }, + "total_tokens": 2846, + } + })) + .expect("serialize response") + } + + fn expected_response() -> ImageResponse { + ImageResponse { + created: 1778832973, + background: Some(ImageBackground::Opaque), + data: vec![ImageData { + b64_json: "REDACT".to_string(), + }], + quality: Some(ImageQuality::Medium), + size: Some("1024x1536".to_string()), + } + } + + fn captured_request(transport: &CapturingTransport) -> Request { + transport + .last_request + .lock() + .expect("lock request store") + .clone() + .expect("request should be captured") + } + + #[tokio::test] + async fn generate_posts_typed_request_and_parses_image_response() { + let transport = CapturingTransport::new(response_body()); + let client = ImagesClient::new(transport.clone(), provider(), Arc::new(DummyAuth)); + + let response = client + .generate( + &ImageGenerationRequest { + prompt: "a red fox in a field".to_string(), + background: Some(ImageBackground::Opaque), + model: "gpt-image-1.5".to_string(), + n: None, + quality: Some(ImageQuality::Medium), + size: Some("1024x1536".to_string()), + }, + HeaderMap::new(), + ) + .await + .expect("image generation request should succeed"); + + assert_eq!(response, expected_response()); + + let request = captured_request(&transport); + assert_eq!( + request.url, + "https://example.com/api/codex/images/generations" + ); + assert_eq!( + request.body.as_ref().and_then(RequestBody::json), + Some(&json!({ + "prompt": "a red fox in a field", + "background": "opaque", + "model": "gpt-image-1.5", + "quality": "medium", + "size": "1024x1536", + })) + ); + } + + #[tokio::test] + async fn edit_posts_typed_request_and_parses_image_response() { + let transport = CapturingTransport::new(response_body()); + let client = ImagesClient::new(transport.clone(), provider(), Arc::new(DummyAuth)); + + let response = client + .edit( + &ImageEditRequest { + images: vec![ImageUrl { + image_url: "data:image/png;base64,Zm9v".to_string(), + }], + prompt: "add a red hat".to_string(), + background: None, + model: "gpt-image-1.5".to_string(), + n: None, + quality: None, + size: None, + }, + HeaderMap::new(), + ) + .await + .expect("image edit request should succeed"); + + assert_eq!(response, expected_response()); + + let request = captured_request(&transport); + assert_eq!(request.url, "https://example.com/api/codex/images/edits"); + assert_eq!( + request.body.as_ref().and_then(RequestBody::json), + Some(&json!({ + "images": [{"image_url": "data:image/png;base64,Zm9v"}], + "prompt": "add a red hat", + "model": "gpt-image-1.5", + })) + ); + } + + #[tokio::test] + async fn image_response_requires_image_data() { + let transport = CapturingTransport::new( + serde_json::to_vec(&json!({"created": 1778832973u64})).expect("serialize response"), + ); + let client = ImagesClient::new(transport, provider(), Arc::new(DummyAuth)); + + let error = client + .generate( + &ImageGenerationRequest { + prompt: "a red fox in a field".to_string(), + background: None, + model: "gpt-image-1.5".to_string(), + n: None, + quality: None, + size: None, + }, + HeaderMap::new(), + ) + .await + .expect_err("image response without data should fail"); + + let ApiError::Stream(message) = error else { + panic!("expected image response decode error"); + }; + assert!( + message.starts_with("failed to decode image generation response: missing field `data`"), + "{message}" + ); + } +} diff --git a/codex-rs/codex-api/src/endpoint/mod.rs b/codex-rs/codex-api/src/endpoint/mod.rs index 21ebf372a1..106c5d73ff 100644 --- a/codex-rs/codex-api/src/endpoint/mod.rs +++ b/codex-rs/codex-api/src/endpoint/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod compact; +pub(crate) mod images; pub(crate) mod memories; pub(crate) mod models; pub(crate) mod realtime_call; @@ -9,6 +10,7 @@ pub(crate) mod search; mod session; pub use compact::CompactClient; +pub use images::ImagesClient; pub use memories::MemoriesClient; pub use models::ModelsClient; pub use realtime_call::RealtimeCallClient; diff --git a/codex-rs/codex-api/src/images.rs b/codex-rs/codex-api/src/images.rs new file mode 100644 index 0000000000..f915a5f78d --- /dev/null +++ b/codex-rs/codex-api/src/images.rs @@ -0,0 +1,70 @@ +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct ImageGenerationRequest { + pub prompt: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub background: Option, + pub model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub n: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub quality: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct ImageEditRequest { + pub images: Vec, + pub prompt: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub background: Option, + pub model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub n: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub quality: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ImageUrl { + pub image_url: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ImageBackground { + Transparent, + Opaque, + Auto, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ImageQuality { + Low, + Medium, + High, + Auto, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct ImageResponse { + pub created: u64, + pub data: Vec, + #[serde(default)] + pub background: Option, + #[serde(default)] + pub quality: Option, + #[serde(default)] + pub size: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct ImageData { + pub b64_json: String, +} diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index f47791b799..08176d8dec 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -4,6 +4,7 @@ pub(crate) mod common; pub(crate) mod endpoint; pub(crate) mod error; pub(crate) mod files; +pub(crate) mod images; pub(crate) mod provider; pub(crate) mod rate_limits; pub(crate) mod requests; @@ -41,6 +42,7 @@ pub use crate::common::WS_REQUEST_HEADER_TRACESTATE_CLIENT_METADATA_KEY; pub use crate::common::create_text_param_for_request; pub use crate::common::response_create_client_metadata; pub use crate::endpoint::CompactClient; +pub use crate::endpoint::ImagesClient; pub use crate::endpoint::MemoriesClient; pub use crate::endpoint::ModelsClient; pub use crate::endpoint::RealtimeCallClient; @@ -63,6 +65,13 @@ pub use crate::endpoint::SearchClient; pub use crate::endpoint::session_update_session_json; pub use crate::error::ApiError; pub use crate::files::upload_local_file; +pub use crate::images::ImageBackground; +pub use crate::images::ImageData; +pub use crate::images::ImageEditRequest; +pub use crate::images::ImageGenerationRequest; +pub use crate::images::ImageQuality; +pub use crate::images::ImageResponse; +pub use crate::images::ImageUrl; pub use crate::provider::Provider; pub use crate::provider::RetryConfig; pub use crate::provider::is_azure_responses_provider; diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index d209c16199..93d16d3ccf 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -89,6 +89,7 @@ pub struct ConfigRequirements { pub permission_profile: ConstrainedWithSource, pub web_search_mode: ConstrainedWithSource, pub allow_managed_hooks_only: Option>, + pub allow_appshots: Option>, pub computer_use: Option>, pub feature_requirements: Option>, pub managed_hooks: Option>, @@ -124,6 +125,7 @@ impl Default for ConfigRequirements { /*source*/ None, ), allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, feature_requirements: None, managed_hooks: None, @@ -750,6 +752,7 @@ pub struct ConfigRequirementsToml { pub remote_sandbox_config: Option>, pub allowed_web_search_modes: Option>, pub allow_managed_hooks_only: Option, + pub allow_appshots: Option, pub computer_use: Option, #[serde(rename = "features", alias = "feature_requirements")] pub feature_requirements: Option, @@ -801,6 +804,7 @@ pub struct ConfigRequirementsWithSources { pub allowed_permissions: Option>>, pub allowed_web_search_modes: Option>>, pub allow_managed_hooks_only: Option>, + pub allow_appshots: Option>, pub computer_use: Option>, pub feature_requirements: Option>, pub hooks: Option>, @@ -840,6 +844,7 @@ impl ConfigRequirementsWithSources { remote_sandbox_config: _, allowed_web_search_modes: _, allow_managed_hooks_only: _, + allow_appshots: _, computer_use: _, feature_requirements: _, hooks: _, @@ -872,6 +877,7 @@ impl ConfigRequirementsWithSources { allowed_permissions, allowed_web_search_modes, allow_managed_hooks_only, + allow_appshots, computer_use, feature_requirements, hooks, @@ -902,6 +908,7 @@ impl ConfigRequirementsWithSources { allowed_permissions, allowed_web_search_modes, allow_managed_hooks_only, + allow_appshots, computer_use, feature_requirements, hooks, @@ -922,6 +929,7 @@ impl ConfigRequirementsWithSources { remote_sandbox_config: None, allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value), allow_managed_hooks_only: allow_managed_hooks_only.map(|sourced| sourced.value), + allow_appshots: allow_appshots.map(|sourced| sourced.value), computer_use: computer_use.map(|sourced| sourced.value), feature_requirements: feature_requirements.map(|sourced| sourced.value), hooks: hooks.map(|sourced| sourced.value), @@ -1008,6 +1016,7 @@ impl ConfigRequirementsToml { && self.remote_sandbox_config.is_none() && self.allowed_web_search_modes.is_none() && self.allow_managed_hooks_only.is_none() + && self.allow_appshots.is_none() && self .computer_use .as_ref() @@ -1054,6 +1063,7 @@ impl TryFrom for ConfigRequirements { allowed_permissions: _, allowed_web_search_modes, allow_managed_hooks_only, + allow_appshots, computer_use, feature_requirements, hooks, @@ -1291,6 +1301,7 @@ impl TryFrom for ConfigRequirements { permission_profile, web_search_mode, allow_managed_hooks_only, + allow_appshots, computer_use, feature_requirements, managed_hooks, @@ -1361,6 +1372,7 @@ mod tests { remote_sandbox_config: _, allowed_web_search_modes, allow_managed_hooks_only, + allow_appshots, computer_use, feature_requirements, hooks, @@ -1386,6 +1398,8 @@ mod tests { .map(|value| Sourced::new(value, RequirementSource::Unknown)), allow_managed_hooks_only: allow_managed_hooks_only .map(|value| Sourced::new(value, RequirementSource::Unknown)), + allow_appshots: allow_appshots + .map(|value| Sourced::new(value, RequirementSource::Unknown)), computer_use: computer_use.map(|value| Sourced::new(value, RequirementSource::Unknown)), feature_requirements: feature_requirements .map(|value| Sourced::new(value, RequirementSource::Unknown)), @@ -1466,6 +1480,19 @@ mod tests { Ok(()) } + #[test] + fn deserialize_allow_appshots() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" + allow_appshots = true + "#, + )?; + + assert_eq!(requirements.allow_appshots, Some(true)); + assert!(!requirements.is_empty()); + Ok(()) + } + #[test] fn filesystem_requirements_table_cannot_define_a_permission_profile() { let err = from_str::( @@ -1484,6 +1511,19 @@ mod tests { ); } + #[test] + fn allow_appshots_false_is_still_configured() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" + allow_appshots = false + "#, + )?; + + assert_eq!(requirements.allow_appshots, Some(false)); + assert!(!requirements.is_empty()); + Ok(()) + } + #[test] fn deserialize_computer_use_requirements() -> Result<()> { let requirements: ConfigRequirementsToml = from_str( @@ -1539,6 +1579,7 @@ mod tests { remote_sandbox_config: None, allowed_web_search_modes: Some(allowed_web_search_modes.clone()), allow_managed_hooks_only: Some(true), + allow_appshots: Some(false), computer_use: Some(computer_use.clone()), feature_requirements: Some(feature_requirements.clone()), hooks: None, @@ -1578,6 +1619,7 @@ mod tests { /*value*/ true, enforce_source.clone(), )), + allow_appshots: Some(Sourced::new(/*value*/ false, enforce_source.clone(),)), computer_use: Some(Sourced::new(computer_use, enforce_source.clone())), feature_requirements: Some(Sourced::new( feature_requirements, @@ -1623,6 +1665,7 @@ mod tests { allowed_permissions: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, feature_requirements: None, hooks: None, @@ -1673,6 +1716,7 @@ mod tests { allowed_permissions: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, feature_requirements: None, hooks: None, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 54114f05d1..ed39df1548 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -271,6 +271,7 @@ async fn get_status_returns_not_found_without_manager() { async fn on_event_updates_status_from_task_started() { let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index b5802268d3..f2bded8d06 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -100,6 +100,7 @@ pub(crate) async fn run_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + trace_id: turn_context.trace_id.clone(), started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 30d1e5f0e8..969da47544 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -62,6 +62,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + trace_id: turn_context.trace_id.clone(), started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 251d37b35c..0e235a941f 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -78,6 +78,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + trace_id: turn_context.trace_id.clone(), started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, @@ -110,7 +111,7 @@ async fn run_remote_compact_task_inner( turn_context.as_ref(), trigger, reason, - CompactionImplementation::Responses, + CompactionImplementation::ResponsesCompactionV2, phase, ) .await; diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index db671e1d3a..2682e6ef41 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -1101,6 +1101,7 @@ allowed_approval_policies = ["on-request"] remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, feature_requirements: None, hooks: None, @@ -1161,6 +1162,7 @@ allowed_approval_policies = ["on-request"] remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, feature_requirements: None, hooks: None, @@ -1370,6 +1372,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, feature_requirements: None, hooks: None, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 89310277ab..09f17e201d 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -8098,6 +8098,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() remote_sandbox_config: None, allowed_web_search_modes: Some(vec![codex_config::WebSearchModeRequirement::Cached]), allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, feature_requirements: None, hooks: None, @@ -8820,6 +8821,7 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, feature_requirements: None, hooks: None, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index a70492ef05..7044b9c676 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2440,6 +2440,7 @@ impl Config { permission_profile: mut constrained_permission_profile, web_search_mode: mut constrained_web_search_mode, allow_managed_hooks_only: _, + allow_appshots: _, computer_use: _, feature_requirements, managed_hooks: _, diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 8798db200c..da09fb3e7f 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -468,16 +468,8 @@ pub async fn reload_user_config(sess: &Arc) { pub async fn compact(sess: &Arc, sub_id: String) { let turn_context = sess.new_default_turn_with_sub_id(sub_id).await; - sess.spawn_task( - Arc::clone(&turn_context), - vec![UserInput::Text { - text: turn_context.compact_prompt().to_string(), - // Compaction prompt is synthesized; no UI element ranges to preserve. - text_elements: Vec::new(), - }], - CompactTask, - ) - .await; + sess.spawn_task(Arc::clone(&turn_context), Vec::new(), CompactTask) + .await; } pub async fn thread_rollback(sess: &Arc, sub_id: String, num_turns: u32) { diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index fc2811710a..11b8651ae6 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -122,6 +122,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -189,6 +190,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -218,6 +220,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -289,6 +292,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -318,6 +322,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -381,6 +386,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -410,6 +416,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: second_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -438,6 +445,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -501,6 +509,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -530,6 +539,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: assistant_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -590,6 +600,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: only_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -643,6 +654,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: user_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -671,6 +683,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -716,6 +729,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -743,6 +757,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -875,6 +890,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -939,6 +955,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -1043,6 +1060,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -1070,6 +1088,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: aborted_turn_id, + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -1151,6 +1170,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -1178,6 +1198,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -1268,6 +1289,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -1295,6 +1317,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -1346,6 +1369,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id, + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -1420,6 +1444,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -1447,6 +1472,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compacted_incomplete_turn_id, + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -1470,6 +1496,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: replacing_turn_id, + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index d865340fe0..a47dcb569a 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -267,8 +267,24 @@ fn skill_message(text: &str) -> ResponseItem { } #[tokio::test] -async fn regular_turn_emits_turn_started_without_waiting_for_startup_prewarm() { - let (sess, tc, rx) = make_session_and_context_with_rx().await; +async fn regular_turn_emits_turn_started_with_trace_id_without_waiting_for_startup_prewarm() { + let _trace_test_context = install_test_tracing("codex-core-tests"); + let request_parent = W3cTraceContext { + traceparent: Some("00-00000000000000000000000000000011-0000000000000022-01".into()), + tracestate: Some("vendor=value".into()), + }; + let request_span = info_span!("app_server.request"); + assert!(set_parent_from_w3c_trace_context( + &request_span, + &request_parent + )); + let (sess, tc, rx) = make_session_and_context_with_rx() + .instrument(request_span) + .await; + assert_eq!( + tc.trace_id.as_deref(), + Some("00000000000000000000000000000011") + ); let (_tx, startup_prewarm_rx) = tokio::sync::oneshot::channel::<()>(); let handle = tokio::spawn(async move { let _ = startup_prewarm_rx.await; @@ -294,10 +310,11 @@ async fn regular_turn_emits_turn_started_without_waiting_for_startup_prewarm() { .await .expect("expected turn started event without waiting for startup prewarm") .expect("channel open"); - assert!(matches!( - first.msg, - EventMsg::TurnStarted(TurnStartedEvent { turn_id, .. }) if turn_id == tc.sub_id - )); + let EventMsg::TurnStarted(turn_started) = first.msg else { + panic!("expected turn started event"); + }; + assert_eq!(turn_started.turn_id, tc.sub_id); + assert_eq!(turn_started.trace_id, tc.trace_id); sess.abort_all_tasks(TurnAbortReason::Interrupted).await; } @@ -2378,6 +2395,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -2573,6 +2591,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -2600,6 +2619,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -2684,6 +2704,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -2709,6 +2730,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compact_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -2728,6 +2750,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -2783,6 +2806,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -2808,6 +2832,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-2".to_string(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -2833,6 +2858,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-3".to_string(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -5712,7 +5738,7 @@ async fn spawn_task_turn_span_inherits_dispatch_trace_context() { self: Arc, _session: Arc, _ctx: Arc, - _input: Vec, + _input: Vec, _cancellation_token: CancellationToken, ) -> Option { let mut trace = self @@ -7788,7 +7814,7 @@ impl SessionTask for NeverEndingTask { self: Arc, _session: Arc, _ctx: Arc, - _input: Vec, + _input: Vec, cancellation_token: CancellationToken, ) -> Option { if self.listen_to_cancellation_token { @@ -7817,7 +7843,7 @@ impl SessionTask for GuardianDeniedApprovalTask { self: Arc, session: Arc, ctx: Arc, - _input: Vec, + _input: Vec, cancellation_token: CancellationToken, ) -> Option { let session = session.clone_session(); diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 0993f7f9f2..b3475492b7 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -121,8 +121,8 @@ pub(crate) enum RunTurnResult { Terminal, } -/// Takes a user message as input and runs a loop where, at each sampling request, the model -/// replies with either: +/// Takes initial turn input and runs a loop where, at each sampling request, +/// the model replies with either: /// /// - requested function calls /// - an assistant message @@ -140,7 +140,7 @@ pub(crate) async fn run_turn( turn_context: Arc, turn_extension_data: Arc, auto_compact_limiter: &mut AutoCompactTurnLimiter, - input: Vec, + input: Vec, prewarmed_client_session: Option, cancellation_token: CancellationToken, ) -> RunTurnResult { @@ -190,26 +190,13 @@ pub(crate) async fn run_turn( if run_pending_session_start_hooks(&sess, &turn_context).await { return RunTurnResult::Continue(None); } - if !input.is_empty() { - let initial_turn_input = TurnInput::UserInput(input.clone()); - let user_prompt_submit_outcome = - inspect_pending_input(&sess, &turn_context, &initial_turn_input).await; - if user_prompt_submit_outcome.should_stop { - record_additional_contexts( - &sess, - &turn_context, - user_prompt_submit_outcome.additional_contexts, - ) - .await; - return RunTurnResult::Continue(None); - } - record_pending_input( - &sess, - &turn_context, - initial_turn_input, - user_prompt_submit_outcome.additional_contexts, - ) - .await; + let mut can_drain_pending_input = input.is_empty(); + let input_record_outcome = run_hooks_and_record_inputs(&sess, &turn_context, &input).await; + if input_record_outcome.accepted_user_input { + auto_compact_limiter.reset_after_user_input(); + } + if input_record_outcome.should_stop { + return RunTurnResult::Continue(None); } sess.merge_connector_selection(explicitly_enabled_connectors.clone()) @@ -250,9 +237,8 @@ pub(crate) async fn run_turn( // one instance across retries within this turn. // Pending input is drained into history before building the next model request. // However, we defer that drain until after sampling in two cases: - // 1. At the start of a turn, so the fresh user prompt in `input` gets sampled first. + // 1. At the start of a turn, so the fresh turn input in `input` gets sampled first. // 2. After auto-compact, when model/tool continuation needs to resume before any steer. - let mut can_drain_pending_input = input.is_empty(); loop { // Note that pending_input would be something like a message the user @@ -264,31 +250,12 @@ pub(crate) async fn run_turn( Vec::new() }; - let mut blocked_pending_input = false; - let mut accepted_pending_input = false; - for pending_input_item in pending_input { - let is_user_input = matches!(&pending_input_item, TurnInput::UserInput(_)); - let hook_outcome = - inspect_pending_input(&sess, &turn_context, &pending_input_item).await; - if hook_outcome.should_stop { - blocked_pending_input = true; - record_additional_contexts(&sess, &turn_context, hook_outcome.additional_contexts) - .await; - } else { - accepted_pending_input = true; - record_pending_input( - &sess, - &turn_context, - pending_input_item, - hook_outcome.additional_contexts, - ) - .await; - if is_user_input { - auto_compact_limiter.reset_after_user_input(); - } - } + let pending_input_record_outcome = + run_hooks_and_record_inputs(&sess, &turn_context, &pending_input).await; + if pending_input_record_outcome.accepted_user_input { + auto_compact_limiter.reset_after_user_input(); } - if blocked_pending_input && !accepted_pending_input { + if pending_input_record_outcome.should_stop { break; } @@ -476,6 +443,45 @@ pub(crate) async fn run_turn( RunTurnResult::Continue(last_agent_message) } +#[derive(Default)] +struct InputRecordOutcome { + should_stop: bool, + accepted_user_input: bool, +} + +async fn run_hooks_and_record_inputs( + sess: &Arc, + turn_context: &Arc, + input: &[TurnInput], +) -> InputRecordOutcome { + let mut blocked_input = false; + let mut accepted_input = false; + let mut accepted_user_input = false; + for input_item in input { + let hook_outcome = inspect_pending_input(sess, turn_context, input_item).await; + if hook_outcome.should_stop { + blocked_input = true; + record_additional_contexts(sess, turn_context, hook_outcome.additional_contexts).await; + } else { + accepted_input = true; + if matches!(input_item, TurnInput::UserInput(_)) { + accepted_user_input = true; + } + record_pending_input( + sess, + turn_context, + input_item.clone(), + hook_outcome.additional_contexts, + ) + .await; + } + } + InputRecordOutcome { + should_stop: blocked_input && !accepted_input, + accepted_user_input, + } +} + #[expect( clippy::await_holding_invalid_type, reason = "MCP tool listing borrows the read guard across cancellation-aware await" @@ -483,9 +489,18 @@ pub(crate) async fn run_turn( async fn build_skills_and_plugins( sess: &Arc, turn_context: &TurnContext, - input: &[UserInput], + input: &[TurnInput], cancellation_token: &CancellationToken, ) -> Option<(Vec, HashSet)> { + let user_input = input + .iter() + .filter_map(|item| match item { + TurnInput::UserInput(content) => Some(content.as_slice()), + TurnInput::ResponseInputItem(_) => None, + }) + .flatten() + .cloned() + .collect::>(); let tracking = build_track_events_context( turn_context.model_info.slug.clone(), sess.conversation_id.to_string(), @@ -499,7 +514,7 @@ async fn build_skills_and_plugins( // Structured plugin:// mentions are resolved from the current session's // enabled plugins, then converted into turn-scoped guidance below. let mentioned_plugins = - collect_explicit_plugin_mentions(input, loaded_plugins.capability_summaries()); + collect_explicit_plugin_mentions(&user_input, loaded_plugins.capability_summaries()); let mcp_tools = if turn_context.apps_enabled() || !mentioned_plugins.is_empty() { // Plugin mentions need raw MCP/app inventory even when app tools // are normally hidden so we can describe the plugin's currently @@ -537,7 +552,7 @@ async fn build_skills_and_plugins( let skill_name_counts_lower = build_skill_name_counts(&skills_outcome.skills, &skills_outcome.disabled_paths).1; let mentioned_skills = collect_explicit_skill_mentions( - input, + &user_input, &skills_outcome.skills, &skills_outcome.disabled_paths, &connector_slug_counts, @@ -579,7 +594,7 @@ async fn build_skills_and_plugins( ); let plugin_items = build_plugin_injections(&mentioned_plugins, &mcp_tools, &available_connectors); - let mut explicitly_enabled_connectors = collect_explicit_app_ids(input); + let mut explicitly_enabled_connectors = collect_explicit_app_ids(&user_input); explicitly_enabled_connectors.extend(skill_connector_ids); let connector_names_by_id = available_connectors .iter() @@ -615,7 +630,7 @@ async fn build_skills_and_plugins( async fn track_turn_resolved_config_analytics( sess: &Session, turn_context: &TurnContext, - input: &[UserInput], + input: &[TurnInput], ) { let thread_config = { let state = sess.state.lock().await; @@ -632,6 +647,11 @@ async fn track_turn_resolved_config_analytics( thread_id: sess.conversation_id.to_string(), num_input_images: input .iter() + .filter_map(|item| match item { + TurnInput::UserInput(content) => Some(content.as_slice()), + TurnInput::ResponseInputItem(_) => None, + }) + .flatten() .filter(|item| { matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) diff --git a/codex-rs/core/src/tasks/compact.rs b/codex-rs/core/src/tasks/compact.rs index dddf46391e..77914633a2 100644 --- a/codex-rs/core/src/tasks/compact.rs +++ b/codex-rs/core/src/tasks/compact.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use super::SessionTask; use super::SessionTaskContext; +use crate::session::TurnInput; use crate::session::turn_context::TurnContext; use crate::state::TaskKind; use codex_protocol::user_input::UserInput; @@ -23,7 +24,7 @@ impl SessionTask for CompactTask { self: Arc, session: Arc, ctx: Arc, - input: Vec, + _input: Vec, _cancellation_token: CancellationToken, ) -> Option { let session = session.clone_session(); @@ -47,6 +48,11 @@ impl SessionTask for CompactTask { /*inc*/ 1, &[("type", "local")], ); + let input = vec![UserInput::Text { + text: ctx.compact_prompt().to_string(), + // Compaction prompt is synthesized; no UI element ranges to preserve. + text_elements: Vec::new(), + }]; crate::compact::run_compact_task(session.clone(), ctx, input).await }; None diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 9cb6b5f0f2..4d6db8acf2 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -214,7 +214,7 @@ pub(crate) trait SessionTask: Send + Sync + 'static { self: Arc, session: Arc, ctx: Arc, - input: Vec, + input: Vec, cancellation_token: CancellationToken, ) -> impl std::future::Future> + Send; @@ -245,7 +245,7 @@ pub(crate) trait AnySessionTask: Send + Sync + 'static { self: Arc, session: Arc, ctx: Arc, - input: Vec, + input: Vec, cancellation_token: CancellationToken, ) -> BoxFuture<'static, Option>; @@ -276,7 +276,7 @@ where self: Arc, session: Arc, ctx: Arc, - input: Vec, + input: Vec, cancellation_token: CancellationToken, ) -> BoxFuture<'static, Option> { Box::pin(SessionTask::run( @@ -380,6 +380,11 @@ impl Session { )); let ctx = Arc::clone(&turn_context); let task_for_run = Arc::clone(&task); + let task_input = if input.is_empty() { + Vec::new() + } else { + vec![TurnInput::UserInput(input)] + }; let task_cancellation_token = cancellation_token.child_token(); // Task-owned turn spans keep a core-owned span open for the // full task lifecycle after the submission dispatch span ends. @@ -405,7 +410,7 @@ impl Session { .run( Arc::clone(&session_ctx), ctx, - input, + task_input, task_cancellation_token.child_token(), ) .await; diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index da8321392c..dbf38bf77d 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -13,7 +13,6 @@ use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::TurnStartedEvent; -use codex_protocol::user_input::UserInput; use tracing::Instrument; use tracing::trace_span; @@ -46,7 +45,7 @@ impl SessionTask for RegularTask { self: Arc, session: Arc, ctx: Arc, - input: Vec, + input: Vec, cancellation_token: CancellationToken, ) -> Option { let sess = session.clone_session(); @@ -56,6 +55,7 @@ impl SessionTask for RegularTask { // not wait on startup prewarm resolution. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: ctx.sub_id.clone(), + trace_id: ctx.trace_id.clone(), started_at: ctx.turn_timing_state.started_at_unix_secs().await, model_context_window: ctx.model_context_window(), collaboration_mode_kind: ctx.collaboration_mode.mode, diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index f89c7d062f..ff7f38a7ea 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -20,6 +20,7 @@ use crate::codex_delegate::run_codex_thread_one_shot; use crate::config::Constrained; use crate::review_format::format_review_findings_block; use crate::review_format::render_review_output_text; +use crate::session::TurnInput; use crate::session::session::Session; use crate::session::turn_context::TurnContext; use crate::state::TaskKind; @@ -59,7 +60,7 @@ impl SessionTask for ReviewTask { self: Arc, session: Arc, ctx: Arc, - input: Vec, + input: Vec, cancellation_token: CancellationToken, ) -> Option { session.session.services.session_telemetry.counter( @@ -68,11 +69,19 @@ impl SessionTask for ReviewTask { &[], ); + let mut user_input = Vec::new(); + for item in input { + match item { + TurnInput::UserInput(mut content) => user_input.append(&mut content), + TurnInput::ResponseInputItem(_) => {} + } + } + // Start sub-codex conversation and get the receiver for events. let output = match start_review_conversation( session.clone(), ctx.clone(), - input, + user_input, cancellation_token.clone(), ) .await diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 23f3882eda..396aecbeea 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -7,7 +7,6 @@ use codex_network_proxy::PROXY_ACTIVE_ENV_KEY; use codex_network_proxy::PROXY_ENV_KEYS; #[cfg(target_os = "macos")] use codex_network_proxy::PROXY_GIT_SSH_COMMAND_ENV_KEY; -use codex_protocol::user_input::UserInput; use tokio_util::sync::CancellationToken; use tracing::error; use uuid::Uuid; @@ -17,6 +16,7 @@ use crate::exec::StdoutStream; use crate::exec::execute_exec_request; use crate::exec_env::create_env; use crate::sandboxing::ExecRequest; +use crate::session::TurnInput; use crate::session::turn_context::TurnContext; use crate::state::TaskKind; use crate::tools::format_exec_output_str; @@ -77,7 +77,7 @@ impl SessionTask for UserShellCommandTask { self: Arc, session: Arc, turn_context: Arc, - _input: Vec, + _input: Vec, cancellation_token: CancellationToken, ) -> Option { execute_user_shell_command( @@ -114,6 +114,7 @@ pub(crate) async fn execute_user_shell_command( // freshly reinjected context before the summary/replacement history is applied. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + trace_id: turn_context.trace_id.clone(), started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 255a8336b9..c79a6859d7 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -187,6 +187,7 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { RolloutItem::ResponseItem(assistant_msg("a1")), RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-2".to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -1307,6 +1308,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { InitialHistory::Forked(vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-explicit".to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index ab9387d48a..a4d6b8e1d3 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -758,24 +758,34 @@ async fn steered_user_input_resets_auto_compact_limit() { ]; let (server, _completions) = start_streaming_sse_server(vec![ - gated_commentary_sampling_chunks(1, "first answer", gate_first_completed_rx, 500), - compact_summary_chunks(1, 500), gated_commentary_sampling_chunks( - 2, + /*idx*/ 1, + "first answer", + gate_first_completed_rx, + /*total_tokens*/ 500, + ), + compact_summary_chunks(/*idx*/ 1, /*total_tokens*/ 500), + gated_commentary_sampling_chunks( + /*idx*/ 2, "processed second prompt", gate_second_completed_rx, - 500, + /*total_tokens*/ 500, ), - compact_summary_chunks(2, 500), - gated_commentary_sampling_chunks(3, "processed third prompt", gate_third_completed_rx, 500), - compact_summary_chunks(3, 500), + compact_summary_chunks(/*idx*/ 2, /*total_tokens*/ 500), gated_commentary_sampling_chunks( - 4, + /*idx*/ 3, + "processed third prompt", + gate_third_completed_rx, + /*total_tokens*/ 500, + ), + compact_summary_chunks(/*idx*/ 3, /*total_tokens*/ 500), + gated_commentary_sampling_chunks( + /*idx*/ 4, "processed fourth prompt", gate_fourth_completed_rx, - 500, + /*total_tokens*/ 500, ), - compact_summary_chunks(4, 500), + compact_summary_chunks(/*idx*/ 4, /*total_tokens*/ 500), final_chunks, ]) .await; @@ -849,7 +859,7 @@ async fn steered_user_input_is_rejected_when_auto_compact_guard_fires() { } responses.extend([ fourth_sampling_chunks, - compact_summary_chunks(4, token_count_used_after_compaction), + compact_summary_chunks(/*idx*/ 4, token_count_used_after_compaction), ]); let (server, _completions) = start_streaming_sse_server(responses).await; diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index d2bdcd1d32..51242ede2a 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -50,6 +50,7 @@ fn resume_history( history: vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.clone(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, diff --git a/codex-rs/external-agent-sessions/src/export.rs b/codex-rs/external-agent-sessions/src/export.rs index 3682a4f7f8..01220c07c1 100644 --- a/codex-rs/external-agent-sessions/src/export.rs +++ b/codex-rs/external-agent-sessions/src/export.rs @@ -67,6 +67,7 @@ fn rollout_items_from_messages(messages: &[ConversationMessage]) -> Vec, /// Unix timestamp (in seconds) when the turn started. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(type = "number | null", optional)] diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index e89b97ed54..4c40e04455 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -156,6 +156,17 @@ fn render_debug_config_lines(stack: &ConfigLayerStack) -> Vec> { )); } + if let Some(allow_appshots) = requirements_toml.allow_appshots { + requirement_lines.push(requirement_line( + "allow_appshots", + allow_appshots.to_string(), + requirements + .allow_appshots + .as_ref() + .map(|sourced| &sourced.source), + )); + } + if requirements_toml.guardian_policy_config.is_some() { requirement_lines.push(requirement_line( "guardian_policy_config", @@ -662,6 +673,10 @@ mod tests { /*value*/ true, RequirementSource::CloudRequirements, )), + allow_appshots: Some(Sourced::new( + /*value*/ false, + RequirementSource::CloudRequirements, + )), feature_requirements: Some(Sourced::new( FeatureRequirementsToml { entries: BTreeMap::from([("guardian_approval".to_string(), true)]), @@ -701,6 +716,7 @@ mod tests { remote_sandbox_config: None, allowed_web_search_modes: Some(vec![WebSearchModeRequirement::Cached]), allow_managed_hooks_only: Some(true), + allow_appshots: Some(false), computer_use: None, guardian_policy_config: Some("Use the managed guardian policy.".to_string()), feature_requirements: Some(FeatureRequirementsToml { @@ -763,6 +779,7 @@ mod tests { ) ); assert!(rendered.contains("allow_managed_hooks_only: true (source: cloud requirements)")); + assert!(rendered.contains("allow_appshots: false (source: cloud requirements)")); assert!( rendered.contains("guardian_policy_config: configured (source: cloud requirements)") ); @@ -917,6 +934,7 @@ approval_policy = "never" remote_sandbox_config: None, allowed_web_search_modes: Some(Vec::new()), allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 32123b787c..47a6aeb6ee 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -166,6 +166,8 @@ mod tests { } pub fn set_modes() -> Result<()> { + ensure_virtual_terminal_processing()?; + execute!(stdout(), EnableBracketedPaste)?; enable_raw_mode()?; @@ -239,12 +241,16 @@ fn restore_common( raw_mode_restore: RawModeRestore, keyboard_restore: KeyboardRestore, ) -> Result<()> { + let mut first_error = ensure_virtual_terminal_processing().err(); + match keyboard_restore { KeyboardRestore::PopStack => keyboard_modes::restore_keyboard_enhancement_stack(), KeyboardRestore::ResetAfterExit => keyboard_modes::reset_keyboard_reporting_after_exit(), } - let mut first_error = execute!(stdout(), DisableBracketedPaste).err(); + if let Err(err) = execute!(stdout(), DisableBracketedPaste) { + first_error.get_or_insert(err); + } let _ = execute!(stdout(), DisableFocusChange); if matches!(raw_mode_restore, RawModeRestore::Disable) && let Err(err) = disable_raw_mode() @@ -797,6 +803,8 @@ impl Tui { // the synchronized update, to avoid racing with the event reader. let mut pending_viewport_area = self.pending_viewport_area()?; + ensure_virtual_terminal_processing()?; + stdout().sync_update(|_| { #[cfg(unix)] if let Some(prepared) = prepared_resume.take() { @@ -854,6 +862,10 @@ impl Tui { &mut self, request: Option, ) -> std::result::Result<(), crate::pets::PetImageRenderError> { + if let Err(err) = ensure_virtual_terminal_processing() { + return Err(crate::pets::PetImageRenderError::Terminal(err)); + } + let terminal = &mut self.terminal; let state = &mut self.ambient_pet_image_state; stdout().sync_update(|_| { @@ -869,6 +881,10 @@ impl Tui { &mut self, request: Option, ) -> std::result::Result<(), crate::pets::PetImageRenderError> { + if let Err(err) = ensure_virtual_terminal_processing() { + return Err(crate::pets::PetImageRenderError::Terminal(err)); + } + let terminal = &mut self.terminal; let state = &mut self.pet_picker_preview_image_state; stdout().sync_update(|_| { @@ -887,6 +903,10 @@ impl Tui { pub fn clear_ambient_pet_image( &mut self, ) -> std::result::Result<(), crate::pets::PetImageRenderError> { + if let Err(err) = ensure_virtual_terminal_processing() { + return Err(crate::pets::PetImageRenderError::Terminal(err)); + } + crate::pets::render_ambient_pet_image( self.terminal.backend_mut(), &mut self.ambient_pet_image_state, @@ -911,6 +931,8 @@ impl Tui { .suspend_context .prepare_resume_action(&mut self.terminal, &mut self.alt_saved_viewport); + ensure_virtual_terminal_processing()?; + stdout().sync_update(|_| { #[cfg(unix)] if let Some(prepared) = prepared_resume.take() { @@ -968,3 +990,51 @@ impl Tui { Ok(None) } } + +#[cfg(windows)] +fn ensure_virtual_terminal_processing() -> Result<()> { + use windows_sys::Win32::Foundation::HANDLE; + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; + use windows_sys::Win32::System::Console::ENABLE_PROCESSED_OUTPUT; + use windows_sys::Win32::System::Console::ENABLE_VIRTUAL_TERMINAL_PROCESSING; + use windows_sys::Win32::System::Console::GetConsoleMode; + use windows_sys::Win32::System::Console::GetStdHandle; + use windows_sys::Win32::System::Console::STD_ERROR_HANDLE; + use windows_sys::Win32::System::Console::STD_OUTPUT_HANDLE; + use windows_sys::Win32::System::Console::SetConsoleMode; + + fn enable_for_handle(handle: HANDLE) -> Result<()> { + if handle == INVALID_HANDLE_VALUE || handle == 0 { + return Ok(()); + } + + let mut mode = 0; + if unsafe { GetConsoleMode(handle, &mut mode) } == 0 { + return Ok(()); + } + + let requested = ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING; + if mode & requested == requested { + return Ok(()); + } + + if unsafe { SetConsoleMode(handle, mode | requested) } == 0 { + return Err(std::io::Error::last_os_error()); + } + + Ok(()) + } + + let stdout_handle = unsafe { GetStdHandle(STD_OUTPUT_HANDLE) }; + enable_for_handle(stdout_handle)?; + + let stderr_handle = unsafe { GetStdHandle(STD_ERROR_HANDLE) }; + enable_for_handle(stderr_handle)?; + + Ok(()) +} + +#[cfg(not(windows))] +fn ensure_virtual_terminal_processing() -> Result<()> { + Ok(()) +} diff --git a/codex-rs/tui/src/update_action.rs b/codex-rs/tui/src/update_action.rs index cb4aa662ca..0dbb145390 100644 --- a/codex-rs/tui/src/update_action.rs +++ b/codex-rs/tui/src/update_action.rs @@ -47,7 +47,12 @@ impl UpdateAction { ), UpdateAction::StandaloneWindows => ( "powershell", - &["-c", "irm https://chatgpt.com/codex/install.ps1|iex"], + &[ + "-ExecutionPolicy", + "Bypass", + "-c", + "irm https://chatgpt.com/codex/install.ps1 | iex", + ], ), } } @@ -142,7 +147,12 @@ mod tests { UpdateAction::StandaloneWindows.command_args(), ( "powershell", - &["-c", "irm https://chatgpt.com/codex/install.ps1|iex"][..], + &[ + "-ExecutionPolicy", + "Bypass", + "-c", + "irm https://chatgpt.com/codex/install.ps1 | iex" + ][..], ) ); } diff --git a/codex-rs/windows-sandbox-rs/Cargo.toml b/codex-rs/windows-sandbox-rs/Cargo.toml index 3b00b40ae8..53c81f0b03 100644 --- a/codex-rs/windows-sandbox-rs/Cargo.toml +++ b/codex-rs/windows-sandbox-rs/Cargo.toml @@ -37,6 +37,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tempfile = "3" tokio = { workspace = true, features = ["sync", "rt"] } +tracing-appender = { workspace = true } windows = { version = "0.58", features = [ "Win32_Foundation", "Win32_NetworkManagement_WindowsFirewall", diff --git a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs index f0475bc39f..9b178fdca9 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs @@ -6,7 +6,6 @@ use anyhow::Result; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64; use codex_otel::StatsigMetricsSettings; -use codex_windows_sandbox::LOG_FILE_NAME; use codex_windows_sandbox::SETUP_VERSION; use codex_windows_sandbox::SetupErrorCode; use codex_windows_sandbox::SetupErrorReport; @@ -21,6 +20,7 @@ use codex_windows_sandbox::hide_newly_created_users; use codex_windows_sandbox::install_wfp_filters; use codex_windows_sandbox::is_command_cwd_root; use codex_windows_sandbox::log_note; +use codex_windows_sandbox::log_writer; use codex_windows_sandbox::path_mask_allows; use codex_windows_sandbox::sandbox_bin_dir; use codex_windows_sandbox::sandbox_dir; @@ -36,7 +36,6 @@ use serde::Serialize; use std::collections::HashSet; use std::ffi::OsStr; use std::ffi::c_void; -use std::fs::File; use std::io::Write; use std::os::windows::process::CommandExt; use std::path::Path; @@ -109,7 +108,7 @@ enum SetupMode { ReadAclsOnly, } -fn log_line(log: &mut File, msg: &str) -> Result<()> { +fn log_line(log: &mut dyn Write, msg: &str) -> Result<()> { let ts = chrono::Utc::now().to_rfc3339(); writeln!(log, "[{ts}] {msg}").map_err(|err| { anyhow::Error::new(SetupFailure::new( @@ -156,7 +155,7 @@ fn workspace_write_cap_sids_for_path( Ok(sid_strs) } -fn spawn_read_acl_helper(payload: &Payload, _log: &mut File) -> Result<()> { +fn spawn_read_acl_helper(payload: &Payload, _log: &mut dyn Write) -> Result<()> { let mut read_payload = payload.clone(); read_payload.mode = SetupMode::ReadAclsOnly; read_payload.refresh_only = true; @@ -182,7 +181,7 @@ struct ReadAclSubjects<'a> { fn apply_read_acls( read_roots: &[PathBuf], subjects: &ReadAclSubjects<'_>, - log: &mut File, + log: &mut dyn Write, refresh_errors: &mut Vec, access_mask: u32, access_label: &str, @@ -259,7 +258,7 @@ fn read_mask_allows_or_log( read_mask: u32, access_label: &str, refresh_errors: &mut Vec, - log: &mut File, + log: &mut dyn Write, ) -> Result { match path_mask_allows(root, psids, read_mask, /*require_all_bits*/ true) { Ok(has) => Ok(has), @@ -294,7 +293,7 @@ fn lock_sandbox_dir( sandbox_group_access_mode: i32, sandbox_group_mask: u32, real_user_mask: u32, - _log: &mut File, + _log: &mut dyn Write, ) -> Result<()> { std::fs::create_dir_all(dir)?; let system_sid = resolve_sid("SYSTEM")?; @@ -391,8 +390,7 @@ pub fn main() -> Result<()> { if let Ok(codex_home) = std::env::var("CODEX_HOME") { let sbx_dir = sandbox_dir(Path::new(&codex_home)); let _ = std::fs::create_dir_all(&sbx_dir); - let log_path = sbx_dir.join(LOG_FILE_NAME); - if let Ok(mut f) = File::options().create(true).append(true).open(&log_path) { + if let Some(mut f) = log_writer(&sbx_dir) { let _ = writeln!( f, "[{}] top-level error: {}", @@ -442,17 +440,12 @@ fn real_main() -> Result<()> { format!("failed to create sandbox dir {}: {err}", sbx_dir.display()), )) })?; - let log_path = sbx_dir.join(LOG_FILE_NAME); - let mut log = File::options() - .create(true) - .append(true) - .open(&log_path) - .map_err(|err| { - anyhow::Error::new(SetupFailure::new( - SetupErrorCode::HelperLogFailed, - format!("open log {} failed: {err}", log_path.display()), - )) - })?; + let mut log = log_writer(&sbx_dir).ok_or_else(|| { + anyhow::Error::new(SetupFailure::new( + SetupErrorCode::HelperLogFailed, + format!("open log in {} failed", sbx_dir.display()), + )) + })?; let result = run_setup(&payload, &mut log, &sbx_dir); if let Err(err) = &result { let _ = log_line(&mut log, &format!("setup error: {err:?}")); @@ -480,14 +473,14 @@ fn real_main() -> Result<()> { result } -fn run_setup(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<()> { +fn run_setup(payload: &Payload, log: &mut dyn Write, sbx_dir: &Path) -> Result<()> { match payload.mode { SetupMode::ReadAclsOnly => run_read_acl_only(payload, log), SetupMode::Full => run_setup_full(payload, log, sbx_dir), } } -fn run_read_acl_only(payload: &Payload, log: &mut File) -> Result<()> { +fn run_read_acl_only(payload: &Payload, log: &mut dyn Write) -> Result<()> { let _read_acl_guard = match acquire_read_acl_mutex()? { Some(guard) => guard, None => { @@ -550,7 +543,7 @@ fn run_read_acl_only(payload: &Payload, log: &mut File) -> Result<()> { Ok(()) } -fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<()> { +fn run_setup_full(payload: &Payload, log: &mut dyn Write, sbx_dir: &Path) -> Result<()> { let refresh_only = payload.refresh_only; if !refresh_only { let provision_result = provision_sandbox_users( diff --git a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/firewall.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/firewall.rs index 0a83950853..bf2476dd12 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/firewall.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/firewall.rs @@ -1,5 +1,4 @@ use anyhow::Result; -use std::fs::File; use std::io::Write; use windows::Win32::Foundation::S_OK; @@ -57,7 +56,7 @@ pub fn ensure_offline_proxy_allowlist( offline_sid: &str, proxy_ports: &[u16], allow_local_binding: bool, - log: &mut File, + log: &mut dyn Write, ) -> Result<()> { let local_user_spec = format!("O:LSD:(A;;CC;;;{offline_sid})"); @@ -154,7 +153,7 @@ pub fn ensure_offline_proxy_allowlist( result } -pub fn ensure_offline_outbound_block(offline_sid: &str, log: &mut File) -> Result<()> { +pub fn ensure_offline_outbound_block(offline_sid: &str, log: &mut dyn Write) -> Result<()> { let local_user_spec = format!("O:LSD:(A;;CC;;;{offline_sid})"); let hr = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) }; @@ -206,7 +205,11 @@ pub fn ensure_offline_outbound_block(offline_sid: &str, log: &mut File) -> Resul result } -fn remove_rule_if_present(rules: &INetFwRules, internal_name: &str, log: &mut File) -> Result<()> { +fn remove_rule_if_present( + rules: &INetFwRules, + internal_name: &str, + log: &mut dyn Write, +) -> Result<()> { let name = BSTR::from(internal_name); if unsafe { rules.Item(&name) }.is_ok() { unsafe { rules.Remove(&name) }.map_err(|err| { @@ -266,7 +269,11 @@ fn validate_local_policy_modify_result( ))) } -fn ensure_block_rule(rules: &INetFwRules, spec: &BlockRuleSpec<'_>, log: &mut File) -> Result<()> { +fn ensure_block_rule( + rules: &INetFwRules, + spec: &BlockRuleSpec<'_>, + log: &mut dyn Write, +) -> Result<()> { let name = BSTR::from(spec.internal_name); let rule: INetFwRule3 = match unsafe { rules.Item(&name) } { Ok(existing) => existing.cast().map_err(|err| { @@ -453,7 +460,7 @@ fn port_range_string(start: u32, end: u32) -> String { } } -fn log_line(log: &mut File, msg: &str) -> Result<()> { +fn log_line(log: &mut dyn Write, msg: &str) -> Result<()> { let ts = chrono::Utc::now().to_rfc3339(); writeln!(log, "[{ts}] {msg}")?; Ok(()) diff --git a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/sandbox_users.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/sandbox_users.rs index de76b2413b..7f21f185ee 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/sandbox_users.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/sandbox_users.rs @@ -7,7 +7,7 @@ use rand::rngs::SmallRng; use serde::Serialize; use std::ffi::OsStr; use std::ffi::c_void; -use std::fs::File; +use std::io::Write; use std::path::Path; use std::path::PathBuf; use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER; @@ -49,7 +49,7 @@ const SID_AUTHENTICATED_USERS: &str = "S-1-5-11"; const SID_EVERYONE: &str = "S-1-1-0"; const SID_SYSTEM: &str = "S-1-5-18"; -pub fn ensure_sandbox_users_group(log: &mut File) -> Result<()> { +pub fn ensure_sandbox_users_group(log: &mut dyn Write) -> Result<()> { ensure_local_group(SANDBOX_USERS_GROUP, SANDBOX_USERS_GROUP_COMMENT, log) } @@ -63,7 +63,7 @@ pub fn provision_sandbox_users( online_username: &str, proxy_ports: &[u16], allow_local_binding: bool, - log: &mut File, + log: &mut dyn Write, ) -> Result<()> { ensure_sandbox_users_group(log)?; super::log_line( @@ -86,13 +86,13 @@ pub fn provision_sandbox_users( Ok(()) } -pub fn ensure_sandbox_user(username: &str, password: &str, log: &mut File) -> Result<()> { +pub fn ensure_sandbox_user(username: &str, password: &str, log: &mut dyn Write) -> Result<()> { ensure_local_user(username, password, log)?; ensure_local_group_member(SANDBOX_USERS_GROUP, username)?; Ok(()) } -pub fn ensure_local_user(name: &str, password: &str, log: &mut File) -> Result<()> { +pub fn ensure_local_user(name: &str, password: &str, log: &mut dyn Write) -> Result<()> { let name_w = to_wide(OsStr::new(name)); let pwd_w = to_wide(OsStr::new(password)); unsafe { @@ -156,7 +156,7 @@ pub fn ensure_local_user(name: &str, password: &str, log: &mut File) -> Result<( Ok(()) } -pub fn ensure_local_group(name: &str, comment: &str, log: &mut File) -> Result<()> { +pub fn ensure_local_group(name: &str, comment: &str, log: &mut dyn Write) -> Result<()> { const ERROR_ALIAS_EXISTS: u32 = 1379; const NERR_GROUP_EXISTS: u32 = 2223; diff --git a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin.rs index be8b0c67e7..47cacf72e0 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin.rs @@ -1,5 +1,5 @@ use std::ffi::c_void; -use std::fs::File; +use std::io::Write; use std::path::PathBuf; use anyhow::Result; @@ -13,7 +13,7 @@ use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ; pub(super) fn ensure_codex_app_runtime_bin_readable( sandbox_group_psid: *mut c_void, refresh_errors: &mut Vec, - log: &mut File, + log: &mut dyn Write, ) -> Result<()> { let local_app_data = std::env::var_os("LOCALAPPDATA") .map(PathBuf::from) diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index 43b3f71955..ca9d6c16f3 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -183,10 +183,14 @@ pub use ipc_framed::read_frame; #[cfg(target_os = "windows")] pub use ipc_framed::write_frame; #[cfg(target_os = "windows")] -pub use logging::LOG_FILE_NAME; +pub use logging::current_log_file_path; +#[cfg(target_os = "windows")] +pub use logging::log_file_path_for_utc_date; #[cfg(target_os = "windows")] pub use logging::log_note; #[cfg(target_os = "windows")] +pub use logging::log_writer; +#[cfg(target_os = "windows")] pub use path_normalization::canonicalize_path; #[cfg(target_os = "windows")] pub use policy::SandboxPolicy; diff --git a/codex-rs/windows-sandbox-rs/src/logging.rs b/codex-rs/windows-sandbox-rs/src/logging.rs index 1c0d695f32..f5e8399709 100644 --- a/codex-rs/windows-sandbox-rs/src/logging.rs +++ b/codex-rs/windows-sandbox-rs/src/logging.rs @@ -1,13 +1,16 @@ -use std::fs::OpenOptions; use std::io::Write; use std::path::Path; use std::path::PathBuf; use std::sync::OnceLock; use codex_utils_string::take_bytes_at_char_boundary; +use tracing_appender::rolling::RollingFileAppender; +use tracing_appender::rolling::Rotation; const LOG_COMMAND_PREVIEW_LIMIT: usize = 200; -pub const LOG_FILE_NAME: &str = "sandbox.log"; +pub const LOG_FILE_PREFIX: &str = "sandbox"; +pub const LOG_FILE_SUFFIX: &str = "log"; +pub const MAX_LOG_FILES: usize = 90; fn exe_label() -> &'static str { static LABEL: OnceLock = OnceLock::new(); @@ -28,18 +31,35 @@ fn preview(command: &[String]) -> String { } } -fn log_file_path(base_dir: &Path) -> Option { - if base_dir.is_dir() { - Some(base_dir.join(LOG_FILE_NAME)) - } else { - None +pub fn log_file_path_for_utc_date(base_dir: &Path, date: chrono::NaiveDate) -> PathBuf { + base_dir.join(format!( + "{LOG_FILE_PREFIX}.{}.{}", + date.format("%Y-%m-%d"), + LOG_FILE_SUFFIX + )) +} + +pub fn current_log_file_path(base_dir: &Path) -> PathBuf { + log_file_path_for_utc_date(base_dir, chrono::Utc::now().date_naive()) +} + +pub fn log_writer(base_dir: &Path) -> Option { + if !base_dir.is_dir() { + return None; } + + RollingFileAppender::builder() + .rotation(Rotation::DAILY) + .filename_prefix(LOG_FILE_PREFIX) + .filename_suffix(LOG_FILE_SUFFIX) + .max_log_files(MAX_LOG_FILES) + .build(base_dir) + .ok() } fn append_line(line: &str, base_dir: Option<&Path>) { if let Some(dir) = base_dir - && let Some(path) = log_file_path(dir) - && let Ok(mut f) = OpenOptions::new().create(true).append(true).open(path) + && let Some(mut f) = log_writer(dir) { let _ = writeln!(f, "{line}"); } @@ -68,7 +88,7 @@ pub fn debug_log(msg: &str, base_dir: Option<&Path>) { } } -// Unconditional note logging to sandbox.log +// Unconditional note logging to the daily sandbox log. pub fn log_note(msg: &str, base_dir: Option<&Path>) { let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f"); append_line(&format!("[{ts} {}] {}", exe_label(), msg), base_dir); @@ -88,4 +108,38 @@ mod tests { let previewed = result.unwrap(); assert!(previewed.len() <= LOG_COMMAND_PREVIEW_LIMIT); } + + #[test] + fn log_note_writes_to_daily_rolling_log() { + let tempdir = tempfile::tempdir().expect("tempdir"); + + log_note("hello daily log", Some(tempdir.path())); + + let entries = std::fs::read_dir(tempdir.path()) + .expect("read log dir") + .collect::, _>>() + .expect("read entries"); + assert_eq!(entries.len(), 1); + + let log_path = entries[0].path(); + let filename = log_path + .file_name() + .and_then(|name| name.to_str()) + .expect("utf-8 filename"); + assert!(filename.starts_with("sandbox.")); + assert!(filename.ends_with(".log")); + + let log = std::fs::read_to_string(log_path).expect("read log"); + assert!(log.contains("hello daily log")); + } + + #[test] + fn log_file_path_for_utc_date_matches_rolling_appender_name() { + let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 21).expect("valid date"); + + assert_eq!( + log_file_path_for_utc_date(Path::new("logs"), date), + PathBuf::from("logs").join("sandbox.2026-05-21.log") + ); + } } diff --git a/codex-rs/windows-sandbox-rs/src/unified_exec/tests.rs b/codex-rs/windows-sandbox-rs/src/unified_exec/tests.rs index 9cd3d6d96d..e2830ad8a1 100644 --- a/codex-rs/windows-sandbox-rs/src/unified_exec/tests.rs +++ b/codex-rs/windows-sandbox-rs/src/unified_exec/tests.rs @@ -70,7 +70,7 @@ fn sandbox_home(name: &str) -> TempDir { } fn sandbox_log(codex_home: &Path) -> String { - let log_path = codex_home.join(".sandbox").join("sandbox.log"); + let log_path = crate::current_log_file_path(&codex_home.join(".sandbox")); fs::read_to_string(&log_path) .unwrap_or_else(|err| format!("failed to read {}: {err}", log_path.display())) } diff --git a/scripts/codex_package/dotslash.py b/scripts/codex_package/dotslash.py new file mode 100644 index 0000000000..9252ae29f1 --- /dev/null +++ b/scripts/codex_package/dotslash.py @@ -0,0 +1,222 @@ +"""Fetch executable artifacts from checked-in DotSlash manifests.""" + +import hashlib +import json +import shutil +import stat +import tarfile +import tempfile +import zipfile +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import urlparse +from urllib.request import urlopen + +from .targets import TargetSpec + + +DOWNLOAD_TIMEOUT_SECS = 60 + + +@dataclass(frozen=True) +class DotSlashArtifact: + size: int + digest: str + archive_format: str + archive_member: str + url: str + + +def fetch_dotslash_executable( + spec: TargetSpec, + *, + manifest_path: Path, + artifact_label: str, + cache_key: str, + dest_name: str, + missing_ok: bool = False, +) -> Path | None: + artifact = artifact_for_target( + spec, + manifest_path, + artifact_label=artifact_label, + missing_ok=missing_ok, + ) + if artifact is None: + return None + + cache_dir = default_cache_root() / cache_key + archive_path = cache_dir / archive_filename(artifact.url) + + if not archive_is_valid(archive_path, artifact, artifact_label): + download_archive(artifact.url, archive_path) + try: + verify_archive(archive_path, artifact, artifact_label) + except RuntimeError: + archive_path.unlink(missing_ok=True) + raise + + dest = cache_dir / dest_name + extract_archive_member(archive_path, artifact, dest, artifact_label) + if not spec.is_windows: + mode = dest.stat().st_mode + dest.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + return dest + + +def artifact_for_target( + spec: TargetSpec, + manifest_path: Path, + *, + artifact_label: str, + missing_ok: bool = False, +) -> DotSlashArtifact | None: + manifest = load_manifest(manifest_path) + platform_info = manifest.get("platforms", {}).get(spec.dotslash_platform) + if platform_info is None: + if missing_ok: + return None + raise RuntimeError( + f"{artifact_label} manifest {manifest_path} is missing platform " + f"{spec.dotslash_platform!r}" + ) + + providers = platform_info.get("providers") + if not providers: + raise RuntimeError( + f"{artifact_label} manifest {manifest_path} has no providers for " + f"{spec.dotslash_platform!r}" + ) + + hash_name = platform_info.get("hash") + if hash_name != "sha256": + raise RuntimeError( + f"Unsupported {artifact_label} hash {hash_name!r} for " + f"{spec.dotslash_platform!r}; expected sha256" + ) + + return DotSlashArtifact( + size=int(platform_info["size"]), + digest=str(platform_info["digest"]), + archive_format=str(platform_info["format"]), + archive_member=str(platform_info["path"]), + url=str(providers[0]["url"]), + ) + + +def load_manifest(manifest_path: Path) -> dict: + text = manifest_path.read_text(encoding="utf-8") + if text.startswith("#!"): + text = "\n".join(text.splitlines()[1:]) + return json.loads(text) + + +def default_cache_root() -> Path: + return Path(tempfile.gettempdir()) / "codex-package" + + +def archive_filename(url: str) -> str: + filename = Path(urlparse(url).path).name + if not filename: + raise RuntimeError(f"Unable to determine archive filename from {url}") + return filename + + +def archive_is_valid( + archive_path: Path, + artifact: DotSlashArtifact, + artifact_label: str, +) -> bool: + if not archive_path.is_file(): + return False + try: + verify_archive(archive_path, artifact, artifact_label) + except RuntimeError: + archive_path.unlink(missing_ok=True) + return False + return True + + +def verify_archive( + archive_path: Path, + artifact: DotSlashArtifact, + artifact_label: str, +) -> None: + actual_size = archive_path.stat().st_size + if actual_size != artifact.size: + raise RuntimeError( + f"{artifact_label} archive {archive_path} has size {actual_size}, " + f"expected {artifact.size}" + ) + + digest = hashlib.sha256() + with open(archive_path, "rb") as fh: + for chunk in iter(lambda: fh.read(1024 * 1024), b""): + digest.update(chunk) + + actual_digest = digest.hexdigest() + if actual_digest != artifact.digest: + raise RuntimeError( + f"{artifact_label} archive {archive_path} has sha256 {actual_digest}, " + f"expected {artifact.digest}" + ) + + +def download_archive(url: str, archive_path: Path) -> None: + archive_path.parent.mkdir(parents=True, exist_ok=True) + temp_path = archive_path.with_suffix(f"{archive_path.suffix}.tmp") + temp_path.unlink(missing_ok=True) + try: + with urlopen(url, timeout=DOWNLOAD_TIMEOUT_SECS) as response: + with open(temp_path, "wb") as out: + shutil.copyfileobj(response, out) + temp_path.replace(archive_path) + finally: + temp_path.unlink(missing_ok=True) + + +def extract_archive_member( + archive_path: Path, + artifact: DotSlashArtifact, + dest: Path, + artifact_label: str, +) -> None: + dest.parent.mkdir(parents=True, exist_ok=True) + dest.unlink(missing_ok=True) + + if artifact.archive_format == "tar.gz": + with tarfile.open(archive_path, "r:gz") as archive: + try: + member = archive.getmember(artifact.archive_member) + except KeyError as exc: + raise RuntimeError( + f"{artifact_label} archive {archive_path} is missing " + f"{artifact.archive_member!r}" + ) from exc + + extracted = archive.extractfile(member) + if extracted is None: + raise RuntimeError( + f"{artifact_label} archive member {artifact.archive_member!r} is not a file" + ) + with extracted, open(dest, "wb") as out: + shutil.copyfileobj(extracted, out) + return + + if artifact.archive_format == "zip": + with zipfile.ZipFile(archive_path) as archive: + try: + with archive.open(artifact.archive_member) as extracted: + with open(dest, "wb") as out: + shutil.copyfileobj(extracted, out) + except KeyError as exc: + raise RuntimeError( + f"{artifact_label} archive {archive_path} is missing " + f"{artifact.archive_member!r}" + ) from exc + return + + raise RuntimeError( + f"Unsupported {artifact_label} archive format {artifact.archive_format!r}; " + "expected tar.gz or zip" + ) diff --git a/scripts/codex_package/ripgrep.py b/scripts/codex_package/ripgrep.py index ce3ad7dc34..33a484022b 100644 --- a/scripts/codex_package/ripgrep.py +++ b/scripts/codex_package/ripgrep.py @@ -1,33 +1,14 @@ """Fetch ripgrep from the DotSlash manifest used by the package builder.""" -import hashlib -import json -import shutil -import stat -import tarfile -import tempfile -import zipfile -from dataclasses import dataclass from pathlib import Path -from urllib.parse import urlparse -from urllib.request import urlopen +from .dotslash import fetch_dotslash_executable from .targets import REPO_ROOT from .targets import TargetSpec from .targets import resolve_input_path RG_MANIFEST = REPO_ROOT / "scripts" / "codex_package" / "rg" -DOWNLOAD_TIMEOUT_SECS = 60 - - -@dataclass(frozen=True) -class RgArtifact: - size: int - digest: str - archive_format: str - archive_member: str - url: str def resolve_rg_bin(spec: TargetSpec, rg_bin: Path | None) -> Path: @@ -41,155 +22,14 @@ def fetch_rg( spec: TargetSpec, *, manifest_path: Path = RG_MANIFEST, - cache_root: Path | None = None, ) -> Path: - artifact = artifact_for_target(spec, manifest_path) - cache_dir = (cache_root or default_cache_root()) / f"{spec.target}-rg" - archive_path = cache_dir / archive_filename(artifact.url) - - if not archive_is_valid(archive_path, artifact): - download_archive(artifact.url, archive_path) - try: - verify_archive(archive_path, artifact) - except RuntimeError: - archive_path.unlink(missing_ok=True) - raise - - dest = cache_dir / spec.rg_name - extract_rg(archive_path, artifact, dest) - if not spec.is_windows: - mode = dest.stat().st_mode - dest.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - return dest - - -def artifact_for_target(spec: TargetSpec, manifest_path: Path) -> RgArtifact: - manifest = load_manifest(manifest_path) - try: - platform_info = manifest["platforms"][spec.dotslash_platform] - except KeyError as exc: - raise RuntimeError( - f"ripgrep manifest {manifest_path} is missing platform {spec.dotslash_platform!r}" - ) from exc - - providers = platform_info.get("providers") - if not providers: - raise RuntimeError( - f"ripgrep manifest {manifest_path} has no providers for {spec.dotslash_platform!r}" - ) - - hash_name = platform_info.get("hash") - if hash_name != "sha256": - raise RuntimeError( - f"Unsupported ripgrep hash {hash_name!r} for " - f"{spec.dotslash_platform!r}; expected sha256" - ) - - return RgArtifact( - size=int(platform_info["size"]), - digest=str(platform_info["digest"]), - archive_format=str(platform_info["format"]), - archive_member=str(platform_info["path"]), - url=str(providers[0]["url"]), - ) - - -def load_manifest(manifest_path: Path) -> dict: - text = manifest_path.read_text(encoding="utf-8") - if text.startswith("#!"): - text = "\n".join(text.splitlines()[1:]) - return json.loads(text) - - -def default_cache_root() -> Path: - return Path(tempfile.gettempdir()) / "codex-package" - - -def archive_filename(url: str) -> str: - filename = Path(urlparse(url).path).name - if not filename: - raise RuntimeError(f"Unable to determine archive filename from {url}") - return filename - - -def archive_is_valid(archive_path: Path, artifact: RgArtifact) -> bool: - if not archive_path.is_file(): - return False - try: - verify_archive(archive_path, artifact) - except RuntimeError: - archive_path.unlink(missing_ok=True) - return False - return True - - -def verify_archive(archive_path: Path, artifact: RgArtifact) -> None: - actual_size = archive_path.stat().st_size - if actual_size != artifact.size: - raise RuntimeError( - f"ripgrep archive {archive_path} has size {actual_size}, expected {artifact.size}" - ) - - digest = hashlib.sha256() - with open(archive_path, "rb") as fh: - for chunk in iter(lambda: fh.read(1024 * 1024), b""): - digest.update(chunk) - - actual_digest = digest.hexdigest() - if actual_digest != artifact.digest: - raise RuntimeError( - f"ripgrep archive {archive_path} has sha256 {actual_digest}, " - f"expected {artifact.digest}" - ) - - -def download_archive(url: str, archive_path: Path) -> None: - archive_path.parent.mkdir(parents=True, exist_ok=True) - temp_path = archive_path.with_suffix(f"{archive_path.suffix}.tmp") - temp_path.unlink(missing_ok=True) - try: - with urlopen(url, timeout=DOWNLOAD_TIMEOUT_SECS) as response: - with open(temp_path, "wb") as out: - shutil.copyfileobj(response, out) - temp_path.replace(archive_path) - finally: - temp_path.unlink(missing_ok=True) - - -def extract_rg(archive_path: Path, artifact: RgArtifact, dest: Path) -> None: - dest.parent.mkdir(parents=True, exist_ok=True) - dest.unlink(missing_ok=True) - - if artifact.archive_format == "tar.gz": - with tarfile.open(archive_path, "r:gz") as archive: - try: - member = archive.getmember(artifact.archive_member) - except KeyError as exc: - raise RuntimeError( - f"ripgrep archive {archive_path} is missing {artifact.archive_member!r}" - ) from exc - - extracted = archive.extractfile(member) - if extracted is None: - raise RuntimeError( - f"ripgrep archive member {artifact.archive_member!r} is not a file" - ) - with extracted, open(dest, "wb") as out: - shutil.copyfileobj(extracted, out) - return - - if artifact.archive_format == "zip": - with zipfile.ZipFile(archive_path) as archive: - try: - with archive.open(artifact.archive_member) as extracted: - with open(dest, "wb") as out: - shutil.copyfileobj(extracted, out) - except KeyError as exc: - raise RuntimeError( - f"ripgrep archive {archive_path} is missing {artifact.archive_member!r}" - ) from exc - return - - raise RuntimeError( - f"Unsupported ripgrep archive format {artifact.archive_format!r}; expected tar.gz or zip" + rg_bin = fetch_dotslash_executable( + spec, + manifest_path=manifest_path, + artifact_label="ripgrep", + cache_key=f"{spec.target}-rg", + dest_name=spec.rg_name, ) + if rg_bin is None: + raise AssertionError("ripgrep is required for all package targets") + return rg_bin