diff --git a/.github/scripts/windows_bazel_test_durations.tsv b/.github/scripts/windows_bazel_test_durations.tsv index 32bcc24783..3cd4b4283a 100644 --- a/.github/scripts/windows_bazel_test_durations.tsv +++ b/.github/scripts/windows_bazel_test_durations.tsv @@ -218,8 +218,6 @@ //codex-rs/sandboxing:sandboxing-unit-tests 74 //codex-rs/secrets:secrets-unit-tests 57 //codex-rs/shell-command:shell-command-unit-tests 60 -//codex-rs/shell-escalation:codex-execve-wrapper-bin-unit-tests 62 -//codex-rs/shell-escalation:shell-escalation-unit-tests 60 //codex-rs/skills:skills-unit-tests 59 //codex-rs/state:state-unit-tests 242 //codex-rs/test-binary-support:test-binary-support-unit-tests 48 diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index bb0ae5b38b..6e275de4d4 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2350,7 +2350,6 @@ dependencies = [ "codex-install-context", "codex-linux-sandbox", "codex-sandboxing", - "codex-shell-escalation", "codex-utils-absolute-path", "codex-utils-cargo-bin", "codex-utils-home-dir", @@ -2966,7 +2965,6 @@ dependencies = [ "codex-rollout-trace", "codex-sandboxing", "codex-shell-command", - "codex-shell-escalation", "codex-skills", "codex-skills-extension", "codex-state", @@ -4561,26 +4559,6 @@ dependencies = [ "which 8.0.0", ] -[[package]] -name = "codex-shell-escalation" -version = "0.0.0" -dependencies = [ - "anyhow", - "clap", - "codex-protocol", - "codex-utils-absolute-path", - "libc", - "pretty_assertions", - "serde", - "serde_json", - "socket2 0.6.3", - "tempfile", - "tokio", - "tokio-util", - "tracing", - "tracing-subscriber", -] - [[package]] name = "codex-skills" version = "0.0.0" @@ -4765,7 +4743,6 @@ dependencies = [ "codex-features", "codex-file-system", "codex-protocol", - "codex-utils-absolute-path", "codex-utils-cargo-bin", "codex-utils-output-truncation", "codex-utils-path-uri", @@ -4776,7 +4753,6 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.18", - "tracing", "urlencoding", ] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 73c3ed62bd..4703a04f6d 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -42,7 +42,6 @@ members = [ "config-schema", "context-fragments", "shell-command", - "shell-escalation", "skills", "core", "core-api", @@ -275,7 +274,6 @@ codex-rollout-trace = { path = "rollout-trace" } codex-sandboxing = { path = "sandboxing" } codex-secrets = { path = "secrets" } codex-shell-command = { path = "shell-command" } -codex-shell-escalation = { path = "shell-escalation" } codex-skills-extension = { path = "ext/skills" } codex-skills = { path = "skills" } codex-state = { path = "state" } @@ -480,7 +478,6 @@ symphonia = { version = "0.6.0", default-features = false, features = [ "ogg", "wav", ] } -socket2 = "0.6.1" # Keep SQLx versions in sync; when bumping, audit the SQLite constructor deny list in clippy.toml. sqlx = { version = "=0.9.0", default-features = false, features = [ "chrono", diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 64c82fa5dc..26aeed921f 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -219,18 +219,6 @@ enum CliCommand { /// Follow-up user message for the second turn. follow_up_message: String, }, - /// Trigger zsh-fork multi-subcommand approvals and assert expected approval behavior. - #[command(name = "trigger-zsh-fork-multi-cmd-approval")] - TriggerZshForkMultiCmdApproval { - /// Optional prompt; defaults to an explicit `/usr/bin/true && /usr/bin/true` command. - user_message: Option, - /// Minimum number of command-approval callbacks expected in the turn. - #[arg(long, default_value_t = 2)] - min_approvals: usize, - /// One-based approval index to abort (e.g. --abort-on 2 aborts the second approval). - #[arg(long)] - abort_on: Option, - }, /// Trigger a ChatGPT or Amazon Bedrock login flow. TestLogin { /// Use the device-code login flow instead of the browser callback flow. @@ -417,22 +405,6 @@ pub async fn run() -> Result<()> { ) .await } - CliCommand::TriggerZshForkMultiCmdApproval { - user_message, - min_approvals, - abort_on, - } => { - let endpoint = resolve_endpoint(codex_bin, url)?; - trigger_zsh_fork_multi_cmd_approval( - &endpoint, - &config_overrides, - user_message, - min_approvals, - abort_on, - &dynamic_tools, - ) - .await - } CliCommand::TestLogin { device_code, amazon_bedrock, @@ -821,112 +793,6 @@ async fn send_message_v2_endpoint( .await } -async fn trigger_zsh_fork_multi_cmd_approval( - endpoint: &Endpoint, - config_overrides: &[String], - user_message: Option, - min_approvals: usize, - abort_on: Option, - dynamic_tools: &Option>, -) -> Result<()> { - if let Some(abort_on) = abort_on - && abort_on == 0 - { - bail!("--abort-on must be >= 1 when provided"); - } - - let default_prompt = "Run this exact command using shell command execution without rewriting or splitting it: /usr/bin/true && /usr/bin/true"; - let message = user_message.unwrap_or_else(|| default_prompt.to_string()); - - with_client( - "trigger-zsh-fork-multi-cmd-approval", - endpoint, - config_overrides, - |client| { - let initialize = client.initialize()?; - println!("< initialize response: {initialize:?}"); - - let thread_response = client.thread_start(ThreadStartParams { - dynamic_tools: dynamic_tools.clone(), - ..Default::default() - })?; - println!("< thread/start response: {thread_response:?}"); - - client.command_approval_behavior = match abort_on { - Some(index) => CommandApprovalBehavior::AbortOn(index), - None => CommandApprovalBehavior::AlwaysAccept, - }; - client.command_approval_count = 0; - client.command_approval_item_ids.clear(); - client.command_execution_statuses.clear(); - client.last_turn_status = None; - - let mut turn_params = TurnStartParams { - thread_id: thread_response.thread.id.clone(), - client_user_message_id: None, - input: vec![V2UserInput::Text { - text: message, - text_elements: Vec::new(), - }], - ..Default::default() - }; - turn_params.approval_policy = Some(AskForApproval::OnRequest); - turn_params.sandbox_policy = Some(SandboxPolicy::ReadOnly { - network_access: false, - }); - - let turn_response = client.turn_start(turn_params)?; - println!("< turn/start response: {turn_response:?}"); - client.stream_turn(&thread_response.thread.id, &turn_response.turn.id)?; - - if client.command_approval_count < min_approvals { - bail!( - "expected at least {min_approvals} command approvals, got {}", - client.command_approval_count - ); - } - let mut approvals_per_item = std::collections::BTreeMap::new(); - for item_id in &client.command_approval_item_ids { - *approvals_per_item.entry(item_id.clone()).or_insert(0usize) += 1; - } - let max_approvals_for_one_item = - approvals_per_item.values().copied().max().unwrap_or(0); - if max_approvals_for_one_item < min_approvals { - bail!( - "expected at least {min_approvals} approvals for one command item, got max {max_approvals_for_one_item} with map {approvals_per_item:?}" - ); - } - - let last_command_status = client.command_execution_statuses.last(); - if abort_on.is_none() { - if last_command_status != Some(&CommandExecutionStatus::Completed) { - bail!("expected completed command execution, got {last_command_status:?}"); - } - if client.last_turn_status != Some(TurnStatus::Completed) { - bail!( - "expected completed turn in all-accept flow, got {:?}", - client.last_turn_status - ); - } - } else if last_command_status == Some(&CommandExecutionStatus::Completed) { - bail!( - "expected non-completed command execution in mixed approval/decline flow, got {last_command_status:?}" - ); - } - - println!( - "[zsh-fork multi-approval summary] approvals={}, approvals_per_item={approvals_per_item:?}, command_statuses={:?}, turn_status={:?}", - client.command_approval_count, - client.command_execution_statuses, - client.last_turn_status - ); - - Ok(()) - }, - ) - .await -} - async fn resume_message_v2( endpoint: &Endpoint, config_overrides: &[String], @@ -1582,9 +1448,7 @@ enum ClientTransport { struct CodexClient { transport: ClientTransport, pending_notifications: VecDeque, - command_approval_behavior: CommandApprovalBehavior, command_approval_count: usize, - command_approval_item_ids: Vec, command_execution_statuses: Vec, command_execution_outputs: Vec, command_output_stream: String, @@ -1596,12 +1460,6 @@ struct CodexClient { last_turn_error_message: Option, } -#[derive(Debug, Clone, Copy)] -enum CommandApprovalBehavior { - AlwaysAccept, - AbortOn(usize), -} - fn item_started_before_helper_done_is_unexpected( item: &ThreadItem, command_item_started: bool, @@ -1671,9 +1529,7 @@ impl CodexClient { stdout: BufReader::new(stdout), }, pending_notifications: VecDeque::new(), - command_approval_behavior: CommandApprovalBehavior::AlwaysAccept, command_approval_count: 0, - command_approval_item_ids: Vec::new(), command_execution_statuses: Vec::new(), command_execution_outputs: Vec::new(), command_output_stream: String::new(), @@ -1710,9 +1566,7 @@ impl CodexClient { socket: Box::new(socket), }, pending_notifications: VecDeque::new(), - command_approval_behavior: CommandApprovalBehavior::AlwaysAccept, command_approval_count: 0, - command_approval_item_ids: Vec::new(), command_execution_statuses: Vec::new(), command_execution_outputs: Vec::new(), command_output_stream: String::new(), @@ -2194,7 +2048,6 @@ impl CodexClient { approval_id.as_deref().unwrap_or("") ); self.command_approval_count += 1; - self.command_approval_item_ids.push(item_id.clone()); if let Some(environment_id) = environment_id.as_deref() { println!("< environment: {environment_id}"); } @@ -2228,13 +2081,7 @@ impl CodexClient { println!("< proposed network policy amendments: {network_policy_amendments:?}"); } - let decision = match self.command_approval_behavior { - CommandApprovalBehavior::AlwaysAccept => CommandExecutionApprovalDecision::Accept, - CommandApprovalBehavior::AbortOn(index) if self.command_approval_count == index => { - CommandExecutionApprovalDecision::Cancel - } - CommandApprovalBehavior::AbortOn(_) => CommandExecutionApprovalDecision::Accept, - }; + let decision = CommandExecutionApprovalDecision::Accept; let response = CommandExecutionRequestApprovalResponse { decision: decision.clone(), }; diff --git a/codex-rs/app-server/src/config_manager.rs b/codex-rs/app-server/src/config_manager.rs index 2f826de9ff..10739fb0ef 100644 --- a/codex-rs/app-server/src/config_manager.rs +++ b/codex-rs/app-server/src/config_manager.rs @@ -240,7 +240,6 @@ impl ConfigManager { cwd.to_path_buf(), &layers, codex_home, - /*default_zsh_path*/ None, ) .await?; self.apply_network_policy(&mut config); @@ -266,7 +265,6 @@ impl ConfigManager { cwd.to_path_buf(), &refreshed_layers, AbsolutePathBuf::from_absolute_path(&self.codex_home)?, - /*default_zsh_path*/ None, ) .await?; self.apply_network_policy(&mut config); @@ -434,7 +432,6 @@ impl ConfigManager { cwd: Some(cwd.to_path_buf()), default_permissions: Some(permission_profile), codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), - main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), ..Default::default() }, Some(cwd.to_path_buf()), @@ -548,7 +545,6 @@ impl ConfigManager { fn apply_arg0_paths(&self, config: &mut Config) { config.codex_self_exe = self.arg0_paths.codex_self_exe.clone(); config.codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone(); - config.main_execve_wrapper_exe = self.arg0_paths.main_execve_wrapper_exe.clone(); } #[cfg(test)] diff --git a/codex-rs/app-server/src/config_manager_service_tests.rs b/codex-rs/app-server/src/config_manager_service_tests.rs index af60acb652..d390205cf5 100644 --- a/codex-rs/app-server/src/config_manager_service_tests.rs +++ b/codex-rs/app-server/src/config_manager_service_tests.rs @@ -2757,20 +2757,13 @@ async fn permission_config_reload_merges_session_layers() -> Result<()> { use codex_config::ConfigLayerSource; use codex_config::ConfigLayerStack; let tmp = tempdir()?; - let wrapper_dir = tmp.path().join("tmp/arg0/session"); - std::fs::create_dir_all(&wrapper_dir)?; - let wrapper = wrapper_dir.join("codex-execve-wrapper"); - std::fs::write(&wrapper, "")?; let service = ConfigManager::new( tmp.path().to_path_buf(), Vec::new(), LoaderOverrides::without_managed_config_for_tests(), /*strict_config*/ false, CloudConfigBundleLoader::default(), - codex_arg0::Arg0DispatchPaths { - main_execve_wrapper_exe: Some(wrapper), - ..Default::default() - }, + codex_arg0::Arg0DispatchPaths::default(), std::sync::Arc::new(codex_config::NoopThreadConfigLoader), ); @@ -2815,16 +2808,5 @@ async fn permission_config_reload_merges_session_layers() -> Result<()> { .collect::>(), vec!["first", "second"] ); - let policy = loaded.permissions.file_system_sandbox_policy(); - assert_eq!( - ( - policy.can_read_local_path_with_cwd(&wrapper_dir, loaded.cwd.as_path()), - policy.can_read_local_path_with_cwd( - &tmp.path().join("tmp/arg0/other"), - loaded.cwd.as_path() - ), - ), - (true, false), - ); Ok(()) } diff --git a/codex-rs/app-server/src/external_agent_migration/session_importer.rs b/codex-rs/app-server/src/external_agent_migration/session_importer.rs index 33f28ed878..13fbb93627 100644 --- a/codex-rs/app-server/src/external_agent_migration/session_importer.rs +++ b/codex-rs/app-server/src/external_agent_migration/session_importer.rs @@ -405,7 +405,6 @@ impl ExternalAgentSessionImporter { ConfigOverrides { cwd: Some(cwd), codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), - main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), ..Default::default() }, ) diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index efad426991..abc2daf877 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1693,7 +1693,6 @@ impl ThreadRequestProcessor { .map(codex_app_server_protocol::ApprovalsReviewer::to_core), sandbox_mode: sandbox.map(SandboxMode::to_core), codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), - main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), base_instructions, developer_instructions, personality, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 32a3ca8d8c..d8e8813c97 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -907,8 +907,6 @@ default_mode_request_user_input = true plugins = true remote_plugin = false unified_exec = true -shell_zsh_fork = false -unified_exec_zsh_fork = false [plugins."{METRICS_PLUGIN_ID}"] enabled = true diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 75855aad4d..d8d1e44192 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -162,7 +162,6 @@ mod turn_cost_otel; mod turn_interrupt; mod turn_settings_update; mod turn_start; -mod turn_start_zsh_fork; mod turn_steer; mod view_image; mod web_search; diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index b27ac1a1b2..ab566dc094 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -5468,7 +5468,6 @@ async fn command_execution_notifications_include_process_id( MockResponsesConfig::new(&server.uri()) .with_sandbox_mode("danger-full-access") .enable_feature(Feature::UnifiedExec) - .disable_feature(Feature::ShellZshFork) .disable_feature(Feature::ShellSnapshot) .write(codex_home.path())?; @@ -5653,7 +5652,6 @@ async fn command_execution_notifications_include_trusted_plugin_id( .enable_feature(Feature::Plugins) .enable_feature(Feature::UnifiedExec) .disable_feature(Feature::RemotePlugin) - .disable_feature(Feature::ShellZshFork) .disable_feature(Feature::ShellSnapshot) .with_extra_config("[plugins.\"google-calendar@openai-api-curated\"]\nenabled = true") .write(codex_home.path())?; diff --git a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs deleted file mode 100644 index 9384541311..0000000000 --- a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs +++ /dev/null @@ -1,847 +0,0 @@ -#![cfg(not(windows))] -// -// Running these tests with the patched zsh fork: -// -// The suite resolves the shared test-only zsh DotSlash file at -// `app-server/tests/suite/zsh` via DotSlash on first use, so `dotslash` and -// network access are required the first time the artifact is fetched. - -use anyhow::Result; -use app_test_support::MockResponsesConfig; -use app_test_support::TestAppServer; -use app_test_support::create_command_execution_sse_response; -use app_test_support::create_escalated_command_execution_sse_response; -use app_test_support::create_final_assistant_message_sse_response; -use app_test_support::create_mock_responses_server_sequence; -use app_test_support::create_mock_responses_server_sequence_unchecked; -use codex_app_server_protocol::CommandAction; -use codex_app_server_protocol::CommandExecutionApprovalDecision; -use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; -use codex_app_server_protocol::CommandExecutionStatus; -use codex_app_server_protocol::ItemCompletedNotification; -use codex_app_server_protocol::ItemStartedNotification; -use codex_app_server_protocol::ServerRequest; -use codex_app_server_protocol::ThreadItem; -use codex_app_server_protocol::ThreadStartParams; -use codex_app_server_protocol::ThreadStartResponse; -use codex_app_server_protocol::TurnCompletedNotification; -use codex_app_server_protocol::TurnStartParams; -use codex_app_server_protocol::TurnStartResponse; -use codex_app_server_protocol::TurnStatus; -use codex_app_server_protocol::UserInput as V2UserInput; -use codex_features::Feature; -use core_test_support::responses; -use core_test_support::skip_if_no_network; -use core_test_support::skip_if_remote; -use pretty_assertions::assert_eq; -use std::collections::BTreeMap; -use std::path::Path; -use std::path::PathBuf; -use tempfile::TempDir; -use test_case::test_case; -use tokio::time::timeout; - -#[cfg(windows)] -const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); -#[cfg(not(windows))] -const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); - -#[tokio::test] -async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { - // TODO(anp): Remove after zsh-fork fixtures can run in the selected remote environment. - skip_if_remote!( - Ok(()), - "zsh-fork fixtures use host-local zsh and workspace paths" - ); - skip_if_no_network!(Ok(())); - - let tmp = TempDir::new()?; - let codex_home = tmp.path().join("codex_home"); - std::fs::create_dir(&codex_home)?; - let workspace = tmp.path().join("workspace"); - std::fs::create_dir(&workspace)?; - let release_marker = workspace.join("interrupt-release"); - - let Some(zsh_path) = find_test_zsh_path()? else { - eprintln!("skipping zsh fork test: no zsh executable found"); - return Ok(()); - }; - eprintln!("using zsh path for zsh-fork test: {}", zsh_path.display()); - - // Keep the shell command in flight until we interrupt it. A fast command - // like `echo hi` can finish before the interrupt arrives on faster runners, - // which turns this into a test for post-command follow-up behavior instead - // of interrupting an active zsh-fork command. - let release_marker_escaped = release_marker.to_string_lossy().replace('\'', r#"'\''"#); - let wait_for_interrupt = - format!("while [ ! -f '{release_marker_escaped}' ]; do sleep 0.01; done"); - let response = create_command_execution_sse_response( - vec!["/bin/sh".to_string(), "-c".to_string(), wait_for_interrupt], - /*workdir*/ None, - Some(5000), - "call-zsh-fork", - )?; - let no_op_response = responses::sse(vec![ - responses::ev_response_created("resp-2"), - responses::ev_completed("resp-2"), - ]); - // Interrupting after the shell item starts can race with the follow-up - // model request that reports the aborted tool call. This test only cares - // that zsh-fork launches the expected command, so allow one extra no-op - // `/responses` POST instead of asserting an exact request count. - let server = - create_mock_responses_server_sequence_unchecked(vec![response, no_op_response]).await; - create_config_toml( - &codex_home, - &server.uri(), - "never", - &BTreeMap::from([ - (Feature::ShellZshFork, true), - (Feature::ShellSnapshot, false), - ]), - )?; - - let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace, &zsh_path).await?; - - let start_id = mcp - .send_thread_start_request_with_auto_env(ThreadStartParams { - model: Some("mock-model".to_string()), - cwd: Some(workspace.to_string_lossy().into_owned()), - ..Default::default() - }) - .await?; - let ThreadStartResponse { thread, .. } = - timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??; - - let turn_id = mcp - .send_turn_start_request(TurnStartParams { - thread_id: thread.id.clone(), - client_user_message_id: None, - input: vec![V2UserInput::Text { - text: "run echo hi".to_string(), - text_elements: Vec::new(), - }], - cwd: Some(workspace.clone()), - approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), - sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), - model: Some("mock-model".to_string()), - effort: Some(codex_protocol::openai_models::ReasoningEffort::Medium), - summary: Some(codex_protocol::config_types::ReasoningSummary::Auto), - ..Default::default() - }) - .await?; - let TurnStartResponse { turn } = - timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(turn_id)).await??; - - let started_command_execution = timeout(DEFAULT_READ_TIMEOUT, async { - loop { - let started_notif = mcp - .read_stream_until_notification_message("item/started") - .await?; - let started: ItemStartedNotification = - serde_json::from_value(started_notif.params.clone().expect("item/started params"))?; - if let ThreadItem::CommandExecution { .. } = started.item { - return Ok::(started.item); - } - } - }) - .await??; - let ThreadItem::CommandExecution { - id, - status, - command, - cwd, - .. - } = started_command_execution - else { - unreachable!("loop ensures we break on command execution items"); - }; - assert_eq!(id, "call-zsh-fork"); - assert_eq!(status, CommandExecutionStatus::InProgress); - assert!(command.starts_with(&command_packaged_zsh_path(&codex_home).display().to_string())); - assert!(command.contains("/bin/sh -c")); - assert!(command.contains("sleep 0.01")); - assert!(command.contains(&release_marker.display().to_string())); - assert_eq!(cwd.as_str(), workspace.to_string_lossy().as_ref()); - - mcp.interrupt_turn_and_wait_for_aborted(thread.id, turn.id, DEFAULT_READ_TIMEOUT) - .await?; - - Ok(()) -} - -#[test_case(CommandExecutionApprovalDecision::Decline; "declined")] -#[test_case(CommandExecutionApprovalDecision::Accept; "launch_failure")] -#[tokio::test] -async fn turn_start_shell_zsh_fork_exec_approval_v2( - decision: CommandExecutionApprovalDecision, -) -> Result<()> { - // TODO(anp): Remove after zsh-fork fixtures can run in the selected remote environment. - skip_if_remote!( - Ok(()), - "zsh-fork fixtures use host-local zsh and workspace paths" - ); - skip_if_no_network!(Ok(())); - - let tmp = TempDir::new()?; - let codex_home = tmp.path().join("codex_home"); - std::fs::create_dir(&codex_home)?; - let workspace = tmp.path().join("workspace"); - std::fs::create_dir(&workspace)?; - - let Some(zsh_path) = find_test_zsh_path()? else { - eprintln!("skipping zsh fork decline test: no zsh executable found"); - return Ok(()); - }; - eprintln!("using zsh path for zsh-fork test: {}", zsh_path.display()); - - let launch_failed = matches!(decision, CommandExecutionApprovalDecision::Accept); - let missing_cwd = workspace.join("missing-work-directory"); - let responses = vec![ - create_escalated_command_execution_sse_response( - vec![ - "python3".to_string(), - "-c".to_string(), - "print(42)".to_string(), - ], - launch_failed.then_some(missing_cwd.as_path()), - Some(5000), - "call-zsh-fork-decline", - )?, - create_final_assistant_message_sse_response("done")?, - ]; - let server = create_mock_responses_server_sequence(responses).await; - create_config_toml( - &codex_home, - &server.uri(), - "on-request", - &BTreeMap::from([ - (Feature::UnifiedExec, true), - (Feature::ShellZshFork, true), - (Feature::ShellSnapshot, false), - ]), - )?; - - let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace, &zsh_path).await?; - - let start_id = mcp - .send_thread_start_request_with_auto_env(ThreadStartParams { - model: Some("mock-model".to_string()), - cwd: Some(workspace.to_string_lossy().into_owned()), - ..Default::default() - }) - .await?; - let ThreadStartResponse { thread, .. } = - timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??; - - let turn_id = mcp - .send_turn_start_request(TurnStartParams { - thread_id: thread.id.clone(), - client_user_message_id: None, - input: vec![V2UserInput::Text { - text: "run python".to_string(), - text_elements: Vec::new(), - }], - cwd: Some(workspace.clone()), - ..Default::default() - }) - .await?; - let _: TurnStartResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(turn_id)).await??; - - let server_req = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_request_message(), - ) - .await??; - let ServerRequest::CommandExecutionRequestApproval { request_id, params } = server_req else { - panic!("expected CommandExecutionRequestApproval request"); - }; - assert_eq!(params.item_id, "call-zsh-fork-decline"); - assert_eq!(params.thread_id, thread.id); - - mcp.send_response( - request_id, - serde_json::to_value(CommandExecutionRequestApprovalResponse { decision })?, - ) - .await?; - - let completed_command_execution = timeout(DEFAULT_READ_TIMEOUT, async { - loop { - let completed_notif = mcp - .read_stream_until_notification_message("item/completed") - .await?; - let completed: ItemCompletedNotification = serde_json::from_value( - completed_notif - .params - .clone() - .expect("item/completed params"), - )?; - if let ThreadItem::CommandExecution { .. } = completed.item { - return Ok::(completed.item); - } - } - }) - .await??; - let ThreadItem::CommandExecution { - id, - status, - exit_code, - process_id, - duration_ms, - aggregated_output, - .. - } = completed_command_execution - else { - unreachable!("loop ensures we break on command execution items"); - }; - assert_eq!(id, "call-zsh-fork-decline"); - assert_eq!(process_id, None); - if launch_failed { - assert_eq!( - (status, exit_code, duration_ms), - (CommandExecutionStatus::Failed, Some(-1), Some(0)) - ); - assert!( - aggregated_output - .expect("launch diagnostic") - .starts_with("Failed to create unified exec process:") - ); - } else { - assert_eq!( - (status, exit_code, duration_ms), - (CommandExecutionStatus::Declined, None, None) - ); - assert!(aggregated_output.is_none()); - } - - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/completed"), - ) - .await??; - - let mut command_events = Vec::new(); - for method in mcp.pending_notification_methods() { - let notification = mcp.read_stream_until_notification_message(&method).await?; - if notification.params.expect("notification params")["item"]["id"] - == "call-zsh-fork-decline" - { - command_events.push(method); - } - } - assert_eq!(command_events, ["item/started"]); - - Ok(()) -} - -#[tokio::test] -async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> { - // TODO(anp): Remove after zsh-fork fixtures can run in the selected remote environment. - skip_if_remote!( - Ok(()), - "zsh-fork fixtures use host-local zsh and workspace paths" - ); - skip_if_no_network!(Ok(())); - - let tmp = TempDir::new()?; - let codex_home = tmp.path().join("codex_home"); - std::fs::create_dir(&codex_home)?; - let workspace = tmp.path().join("workspace"); - std::fs::create_dir(&workspace)?; - - let Some(zsh_path) = find_test_zsh_path()? else { - eprintln!("skipping zsh fork cancel test: no zsh executable found"); - return Ok(()); - }; - eprintln!("using zsh path for zsh-fork test: {}", zsh_path.display()); - - let responses = vec![create_escalated_command_execution_sse_response( - vec![ - "python3".to_string(), - "-c".to_string(), - "print(42)".to_string(), - ], - /*workdir*/ None, - Some(5000), - "call-zsh-fork-cancel", - )?]; - let server = create_mock_responses_server_sequence(responses).await; - create_config_toml( - &codex_home, - &server.uri(), - "on-request", - &BTreeMap::from([ - (Feature::ShellZshFork, true), - (Feature::ShellSnapshot, false), - ]), - )?; - - let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace, &zsh_path).await?; - - let start_id = mcp - .send_thread_start_request_with_auto_env(ThreadStartParams { - model: Some("mock-model".to_string()), - cwd: Some(workspace.to_string_lossy().into_owned()), - ..Default::default() - }) - .await?; - let ThreadStartResponse { thread, .. } = - timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??; - - let turn_id = mcp - .send_turn_start_request(TurnStartParams { - thread_id: thread.id.clone(), - client_user_message_id: None, - input: vec![V2UserInput::Text { - text: "run python".to_string(), - text_elements: Vec::new(), - }], - cwd: Some(workspace.clone()), - ..Default::default() - }) - .await?; - let _: TurnStartResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(turn_id)).await??; - - let server_req = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_request_message(), - ) - .await??; - let ServerRequest::CommandExecutionRequestApproval { request_id, params } = server_req else { - panic!("expected CommandExecutionRequestApproval request"); - }; - assert_eq!(params.item_id, "call-zsh-fork-cancel"); - assert_eq!(params.thread_id, thread.id.clone()); - - mcp.send_response( - request_id, - serde_json::to_value(CommandExecutionRequestApprovalResponse { - decision: CommandExecutionApprovalDecision::Cancel, - })?, - ) - .await?; - - let completed_command_execution = timeout(DEFAULT_READ_TIMEOUT, async { - loop { - let completed_notif = mcp - .read_stream_until_notification_message("item/completed") - .await?; - let completed: ItemCompletedNotification = serde_json::from_value( - completed_notif - .params - .clone() - .expect("item/completed params"), - )?; - if let ThreadItem::CommandExecution { .. } = completed.item { - return Ok::(completed.item); - } - } - }) - .await??; - let ThreadItem::CommandExecution { id, status, .. } = completed_command_execution else { - unreachable!("loop ensures we break on command execution items"); - }; - assert_eq!(id, "call-zsh-fork-cancel"); - assert_eq!(status, CommandExecutionStatus::Declined); - - let completed_notif = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/completed"), - ) - .await??; - let completed: TurnCompletedNotification = serde_json::from_value( - completed_notif - .params - .expect("turn/completed params must be present"), - )?; - assert_eq!(completed.thread_id, thread.id); - assert_eq!(completed.turn.status, TurnStatus::Interrupted); - - Ok(()) -} - -#[tokio::test] -async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() -> Result<()> { - // TODO(anp): Remove after zsh-fork fixtures can run in the selected remote environment. - skip_if_remote!( - Ok(()), - "zsh-fork fixtures use host-local zsh and workspace paths" - ); - skip_if_no_network!(Ok(())); - - let tmp = TempDir::new()?; - let codex_home = tmp.path().join("codex_home"); - std::fs::create_dir(&codex_home)?; - let workspace = tmp.path().join("workspace"); - std::fs::create_dir(&workspace)?; - - let Some(zsh_path) = find_test_zsh_path()? else { - eprintln!("skipping zsh fork subcommand decline test: no zsh executable found"); - return Ok(()); - }; - if !supports_exec_wrapper_intercept(&zsh_path) { - eprintln!( - "skipping zsh fork subcommand decline test: zsh does not support EXEC_WRAPPER intercepts ({})", - zsh_path.display() - ); - return Ok(()); - } - eprintln!("using zsh path for zsh-fork test: {}", zsh_path.display()); - let first_file = workspace.join("first.txt"); - let second_file = workspace.join("second.txt"); - std::fs::write(&first_file, "one")?; - std::fs::write(&second_file, "two")?; - let shell_command = format!( - "/bin/rm {} && /bin/rm {}", - first_file.display(), - second_file.display() - ); - let tool_call_arguments = serde_json::to_string(&serde_json::json!({ - "cmd": shell_command, - "workdir": serde_json::Value::Null, - "yield_time_ms": 20000 - }))?; - let response = responses::sse(vec![ - responses::ev_response_created("resp-1"), - responses::ev_function_call( - "call-zsh-fork-subcommand-decline", - "exec_command", - &tool_call_arguments, - ), - responses::ev_completed("resp-1"), - ]); - let no_op_response = responses::sse(vec![ - responses::ev_response_created("resp-2"), - responses::ev_completed("resp-2"), - ]); - // Linux CI has occasionally issued a second `/responses` POST after the - // subcommand-decline flow. This test is about approval/decline behavior in - // the zsh fork, not exact model request count, so allow an extra request - // and return a harmless no-op response if it arrives. - let server = - create_mock_responses_server_sequence_unchecked(vec![response, no_op_response]).await; - create_config_toml( - &codex_home, - &server.uri(), - "on-request", - &BTreeMap::from([ - (Feature::ShellZshFork, true), - (Feature::ShellSnapshot, false), - ]), - )?; - - let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace, &zsh_path).await?; - - let start_id = mcp - .send_thread_start_request_with_auto_env(ThreadStartParams { - model: Some("mock-model".to_string()), - cwd: Some(workspace.to_string_lossy().into_owned()), - ..Default::default() - }) - .await?; - let ThreadStartResponse { thread, .. } = - timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??; - - let turn_id = mcp - .send_turn_start_request(TurnStartParams { - thread_id: thread.id.clone(), - client_user_message_id: None, - input: vec![V2UserInput::Text { - text: "remove both files".to_string(), - text_elements: Vec::new(), - }], - cwd: Some(workspace.clone()), - approval_policy: Some(codex_app_server_protocol::AskForApproval::UnlessTrusted), - // This test is about execve-intercept approval propagation, not - // workspace sandboxing. Using full access avoids macOS sandbox - // setup failures that can terminate the parent shell before the - // second subcommand approval is observed. - sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), - model: Some("mock-model".to_string()), - effort: Some(codex_protocol::openai_models::ReasoningEffort::Medium), - summary: Some(codex_protocol::config_types::ReasoningSummary::Auto), - ..Default::default() - }) - .await?; - let TurnStartResponse { turn } = - timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(turn_id)).await??; - - let mut approved_subcommand_strings = Vec::new(); - let mut approved_subcommand_ids = Vec::new(); - let mut saw_parent_approval = false; - let target_decisions = [ - CommandExecutionApprovalDecision::Accept, - CommandExecutionApprovalDecision::Cancel, - ]; - let mut target_decision_index = 0; - let first_file_str = first_file.to_string_lossy().into_owned(); - let second_file_str = second_file.to_string_lossy().into_owned(); - let parent_shell_hint = format!("&& {}", &first_file_str); - while target_decision_index < target_decisions.len() || !saw_parent_approval { - let server_req = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_request_message(), - ) - .await??; - let ServerRequest::CommandExecutionRequestApproval { request_id, params } = server_req - else { - panic!("expected CommandExecutionRequestApproval request"); - }; - assert_eq!(params.item_id, "call-zsh-fork-subcommand-decline"); - assert_eq!(params.thread_id, thread.id); - let approval_command = params - .command - .as_deref() - .expect("approval command should be present"); - let has_first_file = approval_command.contains(&first_file_str); - let has_second_file = approval_command.contains(&second_file_str); - let mentions_rm_binary = - approval_command.contains("/bin/rm ") || approval_command.contains("/usr/bin/rm "); - let has_rm_action = params.command_actions.as_ref().is_some_and(|actions| { - actions.iter().any(|action| match action { - CommandAction::Read { name, .. } => name == "rm", - CommandAction::Unknown { command } => command.contains("rm"), - _ => false, - }) - }); - let is_target_subcommand = - (has_first_file != has_second_file) && (has_rm_action || mentions_rm_binary); - - if is_target_subcommand { - approved_subcommand_ids.push( - params - .approval_id - .clone() - .expect("approval_id must be present for zsh subcommand approvals"), - ); - approved_subcommand_strings.push(approval_command.to_string()); - } - let is_parent_approval = approval_command - .contains(&command_packaged_zsh_path(&codex_home).display().to_string()) - && (approval_command.contains(&shell_command) - || (has_first_file && has_second_file) - || approval_command.contains(&parent_shell_hint)); - let decision = if is_target_subcommand { - let decision = target_decisions[target_decision_index].clone(); - target_decision_index += 1; - decision - } else if is_parent_approval { - assert!( - !saw_parent_approval, - "unexpected extra non-target approval: {approval_command}" - ); - saw_parent_approval = true; - CommandExecutionApprovalDecision::Accept - } else { - // Login shells may run startup helpers (for example path_helper on macOS) - // before the parent shell command or target subcommands are reached. - CommandExecutionApprovalDecision::Accept - }; - mcp.send_response( - request_id, - serde_json::to_value(CommandExecutionRequestApprovalResponse { decision })?, - ) - .await?; - } - - assert!( - saw_parent_approval, - "expected parent shell approval request" - ); - assert_eq!(approved_subcommand_ids.len(), 2); - assert_ne!(approved_subcommand_ids[0], approved_subcommand_ids[1]); - assert_eq!(approved_subcommand_strings.len(), 2); - assert!(approved_subcommand_strings[0].contains(&first_file.display().to_string())); - assert!(approved_subcommand_strings[1].contains(&second_file.display().to_string())); - let parent_completed_command_execution = timeout(DEFAULT_READ_TIMEOUT, async { - loop { - let completed_notif = mcp - .read_stream_until_notification_message("item/completed") - .await?; - let completed: ItemCompletedNotification = serde_json::from_value( - completed_notif - .params - .clone() - .expect("item/completed params"), - )?; - if let ThreadItem::CommandExecution { id, .. } = &completed.item - && id == "call-zsh-fork-subcommand-decline" - { - return Ok::(completed.item); - } - } - }) - .await; - - match parent_completed_command_execution { - Ok(Ok(parent_completed_command_execution)) => { - let ThreadItem::CommandExecution { - id, - status, - aggregated_output, - .. - } = parent_completed_command_execution - else { - unreachable!("loop ensures we break on parent command execution item"); - }; - assert_eq!(id, "call-zsh-fork-subcommand-decline"); - assert_eq!(status, CommandExecutionStatus::Declined); - if let Some(output) = aggregated_output.as_deref() { - assert!( - output == "exec command rejected by user" - || output.contains("sandbox denied exec error"), - "unexpected aggregated output: {output}" - ); - } - - match timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/completed"), - ) - .await - { - Ok(Ok(completed_notif)) => { - let completed: TurnCompletedNotification = serde_json::from_value( - completed_notif - .params - .expect("turn/completed params must be present"), - )?; - assert_eq!(completed.thread_id, thread.id); - assert_eq!(completed.turn.id, turn.id); - assert!(matches!( - completed.turn.status, - TurnStatus::Interrupted | TurnStatus::Completed - )); - } - Ok(Err(error)) => return Err(error), - Err(_) => { - mcp.interrupt_turn_and_wait_for_aborted( - thread.id.clone(), - turn.id.clone(), - DEFAULT_READ_TIMEOUT, - ) - .await?; - } - } - } - Ok(Err(error)) => return Err(error), - Err(_) => { - // Some zsh builds abort the turn immediately after the rejected - // subcommand without emitting a parent `item/completed`, and Linux - // sandbox failures can also complete the turn before the parent - // completion item is observed. - let completed_notif = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/completed"), - ) - .await??; - let completed: TurnCompletedNotification = serde_json::from_value( - completed_notif - .params - .expect("turn/completed params must be present"), - )?; - assert_eq!(completed.thread_id, thread.id); - assert_eq!(completed.turn.id, turn.id); - assert!(matches!( - completed.turn.status, - TurnStatus::Interrupted | TurnStatus::Completed - )); - } - } - - Ok(()) -} - -async fn create_zsh_test_mcp_process( - codex_home: &Path, - zdotdir: &Path, - zsh_path: &Path, -) -> Result { - let app_server = create_test_package_app_server(codex_home, zsh_path)?; - let zdotdir = zdotdir.to_string_lossy().into_owned(); - TestAppServer::builder() - .with_codex_home(codex_home) - .with_program(&app_server) - .with_env_overrides(&[("ZDOTDIR", Some(zdotdir.as_str()))]) - .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) - .await -} - -fn create_test_package_app_server(codex_home: &Path, zsh_path: &Path) -> Result { - let package_dir = codex_home.join("test-package"); - let bin_dir = package_dir.join("bin"); - let package_zsh_path = packaged_zsh_path(codex_home); - let Some(zsh_bin_dir) = package_zsh_path.parent() else { - anyhow::bail!("packaged zsh path should have parent"); - }; - std::fs::create_dir_all(&bin_dir)?; - std::fs::create_dir_all(zsh_bin_dir)?; - std::fs::write(package_dir.join("codex-package.json"), "{}")?; - - let app_server = bin_dir.join("codex-app-server"); - codex_utils_cargo_bin::copy_executable( - &codex_utils_cargo_bin::cargo_bin("codex-app-server")?, - &app_server, - )?; - codex_utils_cargo_bin::copy_executable(zsh_path, &package_zsh_path)?; - Ok(app_server) -} - -fn packaged_zsh_path(codex_home: &Path) -> PathBuf { - codex_home - .join("test-package") - .join("codex-resources") - .join("zsh") - .join("bin") - .join("zsh") -} - -fn command_packaged_zsh_path(codex_home: &Path) -> PathBuf { - let path = packaged_zsh_path(codex_home); - std::fs::canonicalize(&path).unwrap_or(path) -} - -fn create_config_toml( - codex_home: &Path, - server_uri: &str, - approval_policy: &str, - feature_flags: &BTreeMap, -) -> std::io::Result<()> { - MockResponsesConfig::new(server_uri) - .with_approval_policy(approval_policy) - .disable_feature(Feature::RemoteModels) - .with_features(feature_flags) - .write(codex_home) -} - -fn find_test_zsh_path() -> Result> { - let repo_root = codex_utils_cargo_bin::repo_root()?; - let dotslash_zsh = repo_root.join("codex-rs/app-server/tests/suite/zsh"); - if !dotslash_zsh.is_file() { - eprintln!( - "skipping zsh fork test: shared zsh DotSlash file not found at {}", - dotslash_zsh.display() - ); - return Ok(None); - } - match core_test_support::fetch_dotslash_file(&dotslash_zsh, /*dotslash_cache*/ None) { - Ok(path) => return Ok(Some(path)), - Err(error) => { - eprintln!("failed to fetch vendored zsh via dotslash: {error:#}"); - } - } - - Ok(None) -} - -fn supports_exec_wrapper_intercept(zsh_path: &Path) -> bool { - let status = std::process::Command::new(zsh_path) - .arg("-fc") - .arg("/usr/bin/true") - .env("EXEC_WRAPPER", "/usr/bin/false") - .status(); - match status { - Ok(status) => !status.success(), - Err(_) => false, - } -} diff --git a/codex-rs/app-server/tests/suite/zsh b/codex-rs/app-server/tests/suite/zsh deleted file mode 100755 index f796fa7201..0000000000 --- a/codex-rs/app-server/tests/suite/zsh +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env dotslash - -// This is the patched zsh fork corresponding to -// `codex-rs/shell-escalation/patches/zsh-exec-wrapper.patch`. -// Fetching the prebuilt version via DotSlash makes it easier to write -// integration tests that exercise the zsh fork behavior in app-server tests. -// -// This checked-in fixture is still pinned to the latest released bundle that -// contains this binary. New releases publish standalone `codex-zsh-*.tar.gz` -// assets plus a generated `codex-zsh` DotSlash release asset, so this file can -// be retargeted when a newer fork build needs to be exercised in tests. -{ - "name": "codex-zsh", - "platforms": { - // macOS 13 builds (and therefore x86_64) were dropped in - // https://github.com/openai/codex/pull/7295, so we only provide an - // Apple Silicon build for now. - "macos-aarch64": { - "size": 53771483, - "hash": "blake3", - "digest": "ff664f63f5e1fa62762c9aff0aafa66cf196faf9b157f98ec98f59c152fc7bd3", - "format": "tar.gz", - "path": "package/vendor/aarch64-apple-darwin/zsh/macos-15/zsh", - "providers": [ - { - "url": "https://github.com/openai/codex/releases/download/rust-v0.104.0/codex-shell-tool-mcp-npm-0.104.0.tgz" - }, - { - "type": "github-release", - "repo": "openai/codex", - "tag": "rust-v0.104.0", - "name": "codex-shell-tool-mcp-npm-0.104.0.tgz" - } - ] - }, - "linux-x86_64": { - "size": 53771483, - "hash": "blake3", - "digest": "ff664f63f5e1fa62762c9aff0aafa66cf196faf9b157f98ec98f59c152fc7bd3", - "format": "tar.gz", - "path": "package/vendor/x86_64-unknown-linux-musl/zsh/ubuntu-24.04/zsh", - "providers": [ - { - "url": "https://github.com/openai/codex/releases/download/rust-v0.104.0/codex-shell-tool-mcp-npm-0.104.0.tgz" - }, - { - "type": "github-release", - "repo": "openai/codex", - "tag": "rust-v0.104.0", - "name": "codex-shell-tool-mcp-npm-0.104.0.tgz" - } - ] - }, - "linux-aarch64": { - "size": 53771483, - "hash": "blake3", - "digest": "ff664f63f5e1fa62762c9aff0aafa66cf196faf9b157f98ec98f59c152fc7bd3", - "format": "tar.gz", - "path": "package/vendor/aarch64-unknown-linux-musl/zsh/ubuntu-24.04/zsh", - "providers": [ - { - "url": "https://github.com/openai/codex/releases/download/rust-v0.104.0/codex-shell-tool-mcp-npm-0.104.0.tgz" - }, - { - "type": "github-release", - "repo": "openai/codex", - "tag": "rust-v0.104.0", - "name": "codex-shell-tool-mcp-npm-0.104.0.tgz" - } - ] - }, - } -} diff --git a/codex-rs/arg0/Cargo.toml b/codex-rs/arg0/Cargo.toml index 1f1bc3866e..fad5169ffa 100644 --- a/codex-rs/arg0/Cargo.toml +++ b/codex-rs/arg0/Cargo.toml @@ -20,7 +20,6 @@ codex-exec-server = { workspace = true } codex-install-context = { workspace = true } codex-linux-sandbox = { workspace = true } codex-sandboxing = { workspace = true } -codex-shell-escalation = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-home-dir = { workspace = true } codex-utils-pty = { workspace = true } diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index c346736719..33d2e65989 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -20,8 +20,6 @@ use tempfile::TempDir; const APPLY_PATCH_ARG0: &str = "apply_patch"; const MISSPELLED_APPLY_PATCH_ARG0: &str = "applypatch"; -#[cfg(unix)] -const EXECVE_WRAPPER_ARG0: &str = "codex-execve-wrapper"; const LOCK_FILENAME: &str = ".lock"; #[derive(Clone, Debug, Default, Eq, PartialEq)] @@ -33,6 +31,7 @@ pub struct Arg0DispatchPaths { /// instead of the real Codex CLI. pub codex_self_exe: Option, pub codex_linux_sandbox_exe: Option, + /// Always absent; retained for clients that still pass the retired wrapper path. pub main_execve_wrapper_exe: Option, } @@ -68,32 +67,6 @@ pub fn arg0_dispatch() -> Option { .and_then(|s| s.to_str()) .unwrap_or(""); - #[cfg(unix)] - if exe_name == EXECVE_WRAPPER_ARG0 { - let mut args = std::env::args(); - let _ = args.next(); - let file = match args.next() { - Some(file) => file, - None => std::process::exit(1), - }; - let argv = args.collect::>(); - - let runtime = match tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - Ok(runtime) => runtime, - Err(_) => std::process::exit(1), - }; - let exit_code = runtime.block_on( - codex_shell_escalation::run_shell_escalation_execve_wrapper(file, argv), - ); - match exit_code { - Ok(exit_code) => std::process::exit(exit_code), - Err(_) => std::process::exit(1), - } - } - if exe_name == CODEX_LINUX_SANDBOX_ARG0 { // Safety: [`run_main`] never returns. codex_linux_sandbox::run_main(); @@ -257,15 +230,13 @@ where Fut: Future>, { let paths = Arg0DispatchPaths { + main_execve_wrapper_exe: None, codex_self_exe: current_exe.clone(), codex_linux_sandbox_exe: if cfg!(target_os = "linux") { linux_sandbox_exe_path(path_entry_guard.as_ref(), current_exe) } else { None }, - main_execve_wrapper_exe: path_entry_guard - .as_ref() - .and_then(|path_entry| path_entry.paths().main_execve_wrapper_exe.clone()), }; let result = main_fn(paths).await; @@ -389,8 +360,6 @@ fn prepare_path_entry_for_codex_aliases( MISSPELLED_APPLY_PATCH_ARG0, #[cfg(target_os = "linux")] CODEX_LINUX_SANDBOX_ARG0, - #[cfg(unix)] - EXECVE_WRAPPER_ARG0, ] { let exe = std::env::current_exe()?; @@ -418,6 +387,7 @@ fn prepare_path_entry_for_codex_aliases( let updated_path_env_var = path_env_with_entry(path, existing_path); let paths = Arg0DispatchPaths { + main_execve_wrapper_exe: None, codex_self_exe: std::env::current_exe().ok(), codex_linux_sandbox_exe: { #[cfg(target_os = "linux")] @@ -429,16 +399,6 @@ fn prepare_path_entry_for_codex_aliases( None } }, - main_execve_wrapper_exe: { - #[cfg(unix)] - { - Some(path.join(EXECVE_WRAPPER_ARG0)) - } - #[cfg(not(unix))] - { - None - } - }, }; Ok(( @@ -656,9 +616,9 @@ mod tests { temp_dir, lock_file, Arg0DispatchPaths { + main_execve_wrapper_exe: None, codex_self_exe: Some(PathBuf::from("/usr/bin/codex")), codex_linux_sandbox_exe: Some(alias_path.clone()), - main_execve_wrapper_exe: None, }, ); @@ -737,20 +697,16 @@ mod tests { temp_dir, lock_file, Arg0DispatchPaths { + main_execve_wrapper_exe: None, codex_self_exe: Some(PathBuf::from("/usr/bin/codex")), codex_linux_sandbox_exe: Some(alias_path.clone()), - main_execve_wrapper_exe: Some(alias_path), }, ); super::build_runtime()?.block_on(run_main_with_arg0_guard( /*path_entry_guard*/ Some(path_entry), Some(PathBuf::from("/usr/bin/codex")), - |paths| async move { - let alias_path = paths - .codex_linux_sandbox_exe - .or(paths.main_execve_wrapper_exe) - .expect("unix dispatch should create at least one alias path"); + |_paths| async move { ensure!( alias_path.exists(), "alias path disappeared before main future was polled: {}", diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index b9f26e9d86..859fad9f69 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -664,7 +664,6 @@ fn config_overrides_from_interactive( .flatten(), codex_self_exe: arg0_paths.codex_self_exe.clone(), codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(), - main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe.clone(), show_raw_agent_reasoning: interactive.oss.then_some(true), additional_writable_roots: interactive.add_dir.clone(), ..Default::default() @@ -3229,7 +3228,7 @@ mod tests { let arg0_paths = Arg0DispatchPaths { codex_self_exe: Some(PathBuf::from("/bin/codex")), codex_linux_sandbox_exe: Some(PathBuf::from("/bin/codex-linux-sandbox")), - main_execve_wrapper_exe: Some(PathBuf::from("/bin/codex-execve-wrapper")), + ..Default::default() }; let overrides = config_overrides_from_interactive(&interactive, &arg0_paths); @@ -3249,10 +3248,6 @@ mod tests { overrides.codex_linux_sandbox_exe, arg0_paths.codex_linux_sandbox_exe ); - assert_eq!( - overrides.main_execve_wrapper_exe, - arg0_paths.main_execve_wrapper_exe - ); } #[test] diff --git a/codex-rs/cli/src/doctor/sandbox.rs b/codex-rs/cli/src/doctor/sandbox.rs index 10a7d351f4..4e1d54bb19 100644 --- a/codex-rs/cli/src/doctor/sandbox.rs +++ b/codex-rs/cli/src/doctor/sandbox.rs @@ -100,11 +100,6 @@ pub(super) fn sandbox_check(config: &Config, arg0_paths: &Arg0DispatchPaths) -> "codex-linux-sandbox helper", arg0_paths.codex_linux_sandbox_exe.as_deref(), ); - push_path_detail( - &mut details, - "execve wrapper helper", - arg0_paths.main_execve_wrapper_exe.as_deref(), - ); let mut status = CheckStatus::Ok; let mut summary = "sandbox configuration is readable".to_string(); diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 8d062a2397..e90a13c32f 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -2027,7 +2027,6 @@ async fn run_debug_prompt_input_command( cwd: shared.cwd, codex_self_exe: arg0_paths.codex_self_exe, codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe, - main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe, show_raw_agent_reasoning: shared.oss.then_some(true), ephemeral: Some(true), bypass_hook_trust: shared.bypass_hook_trust.then_some(true), diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index cfd7236881..a8ad7d678a 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -137,9 +137,6 @@ openssl-sys = { workspace = true, features = ["vendored"] } [target.aarch64-unknown-linux-musl.dependencies] openssl-sys = { workspace = true, features = ["vendored"] } -[target.'cfg(unix)'.dependencies] -codex-shell-escalation = { workspace = true } - [dev-dependencies] assert_cmd = { workspace = true } assert_matches = { workspace = true } diff --git a/codex-rs/core/src/agent/role_tests.rs b/codex-rs/core/src/agent/role_tests.rs index bcf8892aa6..b480f58580 100644 --- a/codex-rs/core/src/agent/role_tests.rs +++ b/codex-rs/core/src/agent/role_tests.rs @@ -189,7 +189,6 @@ async fn apply_role_preserves_unspecified_keys() { )]) .await; config.codex_linux_sandbox_exe = Some(PathBuf::from("/tmp/codex-linux-sandbox")); - config.main_execve_wrapper_exe = Some(PathBuf::from("/tmp/codex-execve-wrapper")); let role_path = write_role_config( &home, "instructions-only.toml", @@ -226,10 +225,6 @@ async fn apply_role_preserves_unspecified_keys() { config.codex_linux_sandbox_exe, Some(PathBuf::from("/tmp/codex-linux-sandbox")) ); - assert_eq!( - config.main_execve_wrapper_exe, - Some(PathBuf::from("/tmp/codex-execve-wrapper")) - ); assert_eq!(config.base_instructions, base_instructions); assert_eq!(config.base_instructions_provenance, provenance); } diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 2ec201aff2..40268dd89c 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -5611,7 +5611,6 @@ async fn rebuild_with_session_layers_refreshes_requirements() -> std::io::Result thread_config.cwd.to_path_buf(), &refreshed_layer_stack, codex_home.abs(), - /*default_zsh_path*/ None, ) .await?; @@ -5727,7 +5726,6 @@ async fn rebuild_with_session_layers_refreshes_plugin_derived_mcp_config() -> an thread_config.cwd.to_path_buf(), &refreshed_layer_stack, codex_home.abs(), - /*default_zsh_path*/ None, ) .await?; let plugins_manager = @@ -6037,25 +6035,6 @@ async fn add_dir_override_extends_workspace_writable_roots() -> std::io::Result< Ok(()) } -#[tokio::test] -async fn default_zsh_path_sets_runtime_zsh_path() -> std::io::Result<()> { - let codex_home = TempDir::new()?; - let default_zsh_path = codex_home.path().join("packaged-zsh"); - - let config = Config::load_from_base_config_with_overrides( - ConfigToml::default(), - ConfigOverrides { - default_zsh_path: Some(default_zsh_path.abs()), - ..Default::default() - }, - codex_home.abs(), - ) - .await?; - assert_eq!(config.zsh_path, Some(default_zsh_path)); - - Ok(()) -} - #[tokio::test] async fn sqlite_home_defaults_to_codex_home_for_workspace_write() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -11635,7 +11614,6 @@ async fn feature_requirements_can_still_disable_unified_exec() -> std::io::Resul [features] unified_exec = false shell_tool = true -unified_exec_zsh_fork = false use_xaa = false "#, ), @@ -11645,7 +11623,6 @@ use_xaa = false assert!(!config.features.enabled(Feature::UnifiedExec)); assert!(config.features.enabled(Feature::ShellTool)); - assert!(!config.features.enabled(Feature::UnifiedExecZshFork)); assert!(!config.features.enabled(Feature::UseXaa)); assert!( !config @@ -11660,13 +11637,8 @@ use_xaa = false .features .enable(Feature::UnifiedExec) .expect("managed feature mutations should normalize successfully"); - config - .features - .enable(Feature::UnifiedExecZshFork) - .expect("managed feature updates should preserve administrator policy"); assert!(!config.features.enabled(Feature::UnifiedExec)); assert!(config.features.enabled(Feature::ShellTool)); - assert!(!config.features.enabled(Feature::UnifiedExecZshFork)); Ok(()) } @@ -11830,6 +11802,8 @@ shell_tool = false #[test_case::test_case(Feature::Personality; "personality")] #[test_case::test_case(Feature::GuardianThreadContext; "guardian thread context")] +#[test_case::test_case(Feature::ShellZshFork; "shell zsh fork")] +#[test_case::test_case(Feature::UnifiedExecZshFork; "unified exec zsh fork")] fn retired_feature_requirements_do_not_pin_configured_values( feature: Feature, ) -> std::io::Result<()> { @@ -11876,7 +11850,7 @@ fn retired_feature_requirements_do_not_pin_configured_values( ( Features::with_defaults().enabled(feature), false, - usize::from(feature == Feature::GuardianThreadContext), + usize::from(feature != Feature::Personality), ), ); } diff --git a/codex-rs/core/src/config/managed_features.rs b/codex-rs/core/src/config/managed_features.rs index 80c951c488..a6519b2a44 100644 --- a/codex-rs/core/src/config/managed_features.rs +++ b/codex-rs/core/src/config/managed_features.rs @@ -243,6 +243,15 @@ fn parse_feature_requirements( if key == Feature::Personality.key() { continue; } + if matches!(key.as_str(), "shell_zsh_fork" | "unified_exec_zsh_fork") { + push_feature_requirement_warning( + &mut startup_warnings, + format!( + "Ignoring removed `features` requirement `{key}` from {source}; the patched zsh backend has been removed." + ), + ); + continue; + } if key == Feature::GuardianThreadContext.key() { push_feature_requirement_warning( &mut startup_warnings, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 0537ee4adb..db88628e7a 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -80,7 +80,6 @@ use codex_features::TokenBudgetConfigToml; use codex_git_utils::resolve_root_git_project_for_trust; use codex_http_client::HttpClientFactory; use codex_http_client::OutboundProxyPolicy; -use codex_install_context::InstallContext; use codex_login::AuthManagerConfig; use codex_login::AuthRouteConfig; use codex_mcp::DEFAULT_OPTIONAL_MCP_STARTUP_GRACE; @@ -155,7 +154,6 @@ use crate::config::permissions::BUILT_IN_READ_ONLY_PROFILE; use crate::config::permissions::BUILT_IN_WORKSPACE_PROFILE; use crate::config::permissions::apply_network_proxy_feature_config; use crate::config::permissions::default_builtin_permission_profile_name; -use crate::config::permissions::get_readable_roots_required_for_codex_runtime; use crate::config::permissions::network_proxy_config_for_profile_selection; use crate::config::permissions::validate_user_permission_profile_names; use crate::responses_metadata::validate_extra_metadata; @@ -1002,12 +1000,10 @@ pub struct Config { /// When this program is invoked, arg0 will be set to `codex-linux-sandbox`. pub codex_linux_sandbox_exe: Option, - /// Path to the `codex-execve-wrapper` executable used for shell - /// escalation. This cannot be set in the config file: it must be set in - /// code via [`ConfigOverrides`]. + /// Ignored compatibility field for clients that still pass the retired wrapper path. pub main_execve_wrapper_exe: Option, - /// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution. + /// Ignored compatibility field for clients that still pass a patched zsh path. pub zsh_path: Option, /// Value to use for `reasoning.effort` when making a request using the @@ -1933,7 +1929,6 @@ impl Config { cwd: PathBuf, refreshed_layers: &ConfigLayerStack, codex_home: AbsolutePathBuf, - default_zsh_path: Option, ) -> std::io::Result { let config_layer_stack = Self::layer_stack_preserving_session(session_layers, refreshed_layers)?; @@ -1943,7 +1938,6 @@ impl Config { cfg, ConfigOverrides { cwd: Some(cwd), - default_zsh_path, ..Default::default() }, codex_home, @@ -2033,8 +2027,7 @@ impl Config { /// designed to use [AskForApproval::Never] exclusively. /// /// Further, [ConfigOverrides] contains some options that are not supported - /// in [ConfigToml], such as `cwd`, `codex_self_exe`, `codex_linux_sandbox_exe`, and - /// `main_execve_wrapper_exe`. + /// in [ConfigToml], such as `cwd`, `codex_self_exe`, and `codex_linux_sandbox_exe`. pub async fn load_with_cli_overrides_and_harness_overrides( cli_overrides: Vec<(String, TomlValue)>, harness_overrides: ConfigOverrides, @@ -2667,8 +2660,8 @@ pub struct ConfigOverrides { pub service_tier: Option>, pub codex_self_exe: Option, pub codex_linux_sandbox_exe: Option, + /// Ignored compatibility field for clients that still pass the retired wrapper path. pub main_execve_wrapper_exe: Option, - pub default_zsh_path: Option, pub base_instructions: Option, pub developer_instructions: Option, /// Deprecated: `friendly` and `pragmatic` no longer select a style. @@ -3362,8 +3355,7 @@ impl Config { service_tier: service_tier_override, codex_self_exe, codex_linux_sandbox_exe, - main_execve_wrapper_exe, - default_zsh_path, + main_execve_wrapper_exe: _, base_instructions, developer_instructions, personality, @@ -4111,10 +4103,6 @@ impl Config { ) .await?; let compact_prompt = compact_prompt.or(file_compact_prompt); - let zsh_path = default_zsh_path - .or_else(|| InstallContext::current().bundled_zsh_path()) - .map(AbsolutePathBuf::into_path_buf); - let review_model = override_review_model.or(cfg.review_model); let check_for_update_on_startup = cfg.check_for_update_on_startup.unwrap_or(true); @@ -4208,11 +4196,11 @@ impl Config { &network_permission_profile, &shell_environment_policy.r#set, )?; - let mut helper_readable_roots = get_readable_roots_required_for_codex_runtime( - &codex_home, - zsh_path.as_ref(), - main_execve_wrapper_exe.as_ref(), - ); + let mut helper_readable_roots: Vec<_> = std::env::var_os("PATH") + .as_deref() + .and_then(|path| permissions::active_arg0_helper_dir(&codex_home, path)) + .into_iter() + .collect(); if features.enabled(Feature::MemoryTool) && memories_config.use_memories { helper_readable_roots.push(memories_root); } @@ -4442,8 +4430,8 @@ impl Config { file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode), codex_self_exe, codex_linux_sandbox_exe, - main_execve_wrapper_exe, - zsh_path, + main_execve_wrapper_exe: None, + zsh_path: None, hide_agent_reasoning: cfg.hide_agent_reasoning.unwrap_or(false), show_raw_agent_reasoning: cfg diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index 9cef5b075a..78eaf3d217 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -1,8 +1,8 @@ use std::collections::BTreeMap; use std::collections::HashSet; +use std::ffi::OsStr; use std::io; use std::path::Path; -use std::path::PathBuf; use codex_config::ConfigPathContext; use codex_config::permissions_toml::FilesystemPermissionToml; @@ -48,6 +48,23 @@ pub(crate) const BUILT_IN_WORKSPACE_PROFILE: &str = BUILT_IN_PERMISSION_PROFILE_ pub(crate) const BUILT_IN_DANGER_FULL_ACCESS_PROFILE: &str = BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; +/// Keep the active apply_patch aliases readable without granting access to other +/// sessions or the rest of CODEX_HOME. Preserve the alias directory, not its target. +pub(super) fn active_arg0_helper_dir( + codex_home: &Path, + path_env: &OsStr, +) -> Option { + let arg0_root = codex_home.join("tmp").join("arg0"); + std::env::split_paths(path_env) + .find(|path| { + path.parent() == Some(arg0_root.as_path()) + && path + .file_name() + .is_some_and(|name| name.to_string_lossy().starts_with("codex-arg0")) + }) + .and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok()) +} + pub(crate) fn default_builtin_permission_profile_name( active_project: &ProjectConfig, windows_sandbox_level: WindowsSandboxLevel, @@ -508,37 +525,6 @@ pub(crate) fn reject_unknown_builtin_permission_profile(profile_name: &str) -> i Ok(()) } -/// Returns a list of paths that must be readable by shell tools in order -/// for Codex to function. These should always be added to the -/// `FileSystemSandboxPolicy` for a thread. -pub(crate) fn get_readable_roots_required_for_codex_runtime( - codex_home: &Path, - zsh_path: Option<&PathBuf>, - main_execve_wrapper_exe: Option<&PathBuf>, -) -> Vec { - let arg0_root = AbsolutePathBuf::from_absolute_path(codex_home.join("tmp").join("arg0")).ok(); - let zsh_path = zsh_path.and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok()); - let execve_wrapper_root = main_execve_wrapper_exe.and_then(|path| { - let path = AbsolutePathBuf::from_absolute_path(path).ok()?; - if let Some(arg0_root) = arg0_root.as_ref() - && path.as_path().starts_with(arg0_root.as_path()) - { - path.parent() - } else { - Some(path) - } - }); - - let mut readable_roots = Vec::new(); - if let Some(zsh_path) = zsh_path { - readable_roots.push(zsh_path); - } - if let Some(execve_wrapper_root) = execve_wrapper_root { - readable_roots.push(execve_wrapper_root); - } - readable_roots -} - fn compile_network_sandbox_policy( network: Option<&NetworkToml>, base_network_sandbox_policy: NetworkSandboxPolicy, diff --git a/codex-rs/core/src/config/permissions_tests.rs b/codex-rs/core/src/config/permissions_tests.rs index 6b385ff25b..0e2468b8b1 100644 --- a/codex-rs/core/src/config/permissions_tests.rs +++ b/codex-rs/core/src/config/permissions_tests.rs @@ -1,7 +1,4 @@ use super::*; -use crate::config::Config; -use crate::config::ConfigOverrides; -use codex_config::config_toml::ConfigToml; use codex_config::permissions_toml::FilesystemPermissionToml; use codex_config::permissions_toml::FilesystemPermissionsToml; use codex_config::permissions_toml::NetworkDomainPermissionToml; @@ -17,12 +14,30 @@ use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; -use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathConvention; use pretty_assertions::assert_eq; use std::collections::BTreeMap; use tempfile::TempDir; +#[test] +fn active_arg0_helper_dir_selects_only_the_first_session_alias_directory() -> anyhow::Result<()> { + let home = TempDir::new()?; + let arg0_root = home.path().join("tmp/arg0"); + let active = arg0_root.join("codex-arg0-active"); + let paths = std::env::join_paths([ + home.path().join("bin"), + arg0_root.clone(), + arg0_root.join("unrelated"), + active.clone(), + arg0_root.join("codex-arg0-parent"), + ])?; + assert_eq!( + active_arg0_helper_dir(home.path(), &paths), + Some(AbsolutePathBuf::from_absolute_path(active)?) + ); + Ok(()) +} + #[test] fn windows_verbatim_path_prefix_does_not_count_as_glob_syntax() { assert!(!contains_glob_chars_for_platform( @@ -35,74 +50,6 @@ fn windows_verbatim_path_prefix_does_not_count_as_glob_syntax() { )); } -#[tokio::test] -async fn restricted_read_implicitly_allows_helper_executables() -> std::io::Result<()> { - let temp_dir = TempDir::new()?; - let cwd = temp_dir.path().join("workspace"); - let codex_home = temp_dir.path().join(".codex"); - let zsh_path = temp_dir.path().join("runtime").join("zsh"); - let arg0_root = codex_home.join("tmp").join("arg0"); - let allowed_arg0_dir = arg0_root.join("codex-arg0-session"); - let sibling_arg0_dir = arg0_root.join("codex-arg0-other-session"); - let execve_wrapper = allowed_arg0_dir.join("codex-execve-wrapper"); - std::fs::create_dir_all(&cwd)?; - std::fs::create_dir_all(zsh_path.parent().expect("zsh path should have parent"))?; - std::fs::create_dir_all(&allowed_arg0_dir)?; - std::fs::create_dir_all(&sibling_arg0_dir)?; - std::fs::write(&zsh_path, "")?; - std::fs::write(&execve_wrapper, "")?; - - let config = Config::load_from_base_config_with_overrides( - ConfigToml { - default_permissions: Some("workspace".to_string()), - permissions: Some(PermissionsToml { - entries: BTreeMap::from([( - "workspace".to_string(), - PermissionProfileToml { - description: None, - extends: None, - workspace_roots: None, - filesystem: Some(FilesystemPermissionsToml { - glob_scan_max_depth: None, - entries: BTreeMap::new(), - }), - network: None, - }, - )]), - }), - ..Default::default() - }, - ConfigOverrides { - cwd: Some(cwd.clone()), - default_zsh_path: Some(AbsolutePathBuf::try_from(zsh_path.clone())?), - main_execve_wrapper_exe: Some(execve_wrapper), - ..Default::default() - }, - AbsolutePathBuf::from_absolute_path(&codex_home)?, - ) - .await?; - - let expected_zsh = AbsolutePathBuf::try_from(zsh_path)?; - let expected_allowed_arg0_dir = AbsolutePathBuf::try_from(allowed_arg0_dir)?; - let expected_sibling_arg0_dir = AbsolutePathBuf::try_from(sibling_arg0_dir)?; - let policy = config.permissions.file_system_sandbox_policy(); - - assert!( - policy.can_read_local_path_with_cwd(expected_zsh.as_path(), &cwd), - "expected zsh helper path to be readable, policy: {policy:?}" - ); - assert!( - policy.can_read_local_path_with_cwd(expected_allowed_arg0_dir.as_path(), &cwd), - "expected active arg0 helper dir to be readable, policy: {policy:?}" - ); - assert!( - !policy.can_read_local_path_with_cwd(expected_sibling_arg0_dir.as_path(), &cwd), - "expected sibling arg0 helper dir to remain unreadable, policy: {policy:?}" - ); - - Ok(()) -} - #[test] fn network_toml_ignores_legacy_network_list_keys() { let parsed = toml::from_str::( diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 21e414ed01..79d564e338 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -755,8 +755,8 @@ fn dangerous_command_match_for_heuristics( } /// If a command is not matched by any execpolicy rule, derive a [`Decision`]. -#[cfg(any(test, unix))] -pub(crate) fn render_decision_for_unmatched_command( +#[cfg(test)] +fn render_decision_for_unmatched_command( command: &[String], context: UnmatchedCommandContext<'_>, ) -> Decision { diff --git a/codex-rs/core/src/exec_policy/executable_identity.rs b/codex-rs/core/src/exec_policy/executable_identity.rs index 47363da0e0..c85fa184bb 100644 --- a/codex-rs/core/src/exec_policy/executable_identity.rs +++ b/codex-rs/core/src/exec_policy/executable_identity.rs @@ -8,7 +8,6 @@ use crate::tools::sandboxing::ExecApprovalRequirement; use codex_shell_command::is_dangerous_command::DangerousCommandPlatform; use codex_shell_command::powershell::extract_powershell_command; use codex_shell_command::powershell::parse_powershell_script_into_plain_commands; -use codex_tools::UnifiedExecShellMode; use std::path::Path; impl ExecPolicyManager { @@ -16,11 +15,10 @@ impl ExecPolicyManager { &self, mut request: ExecApprovalRequest<'_>, configured_shell: &Shell, - shell_mode: &UnifiedExecShellMode, command_platform: DangerousCommandPlatform, ) -> ExecApprovalRequirement { let command = request.command; - let executable = shell_approval_command(command, configured_shell, shell_mode); + let executable = shell_approval_command(command, configured_shell); if executable.len() == command.len() { return self .create_exec_approval_requirement_for_command_platform(request, command_platform) @@ -53,11 +51,7 @@ impl ExecPolicyManager { } } -fn shell_approval_command<'a>( - command: &'a [String], - configured_shell: &Shell, - shell_mode: &UnifiedExecShellMode, -) -> &'a [String] { +fn shell_approval_command<'a>(command: &'a [String], configured_shell: &Shell) -> &'a [String] { let Some(executable) = command.first() else { return command; }; @@ -90,10 +84,7 @@ fn shell_approval_command<'a>( #[cfg(not(windows))] let is_configured_shell = executable_path == configured_shell.shell_path.as_path(); - if is_configured_shell - || is_system_shell - || matches!(shell_mode, UnifiedExecShellMode::ZshFork(_)) - { + if is_configured_shell || is_system_shell { command } else { // An unfamiliar executable can ignore its arguments, so evaluate the diff --git a/codex-rs/core/src/exec_policy/executable_identity_tests.rs b/codex-rs/core/src/exec_policy/executable_identity_tests.rs index 9f90cd629b..57be815416 100644 --- a/codex-rs/core/src/exec_policy/executable_identity_tests.rs +++ b/codex-rs/core/src/exec_policy/executable_identity_tests.rs @@ -1,7 +1,6 @@ use super::shell_approval_command; use crate::shell::Shell; use crate::shell::ShellType; -use codex_tools::UnifiedExecShellMode; use pretty_assertions::assert_eq; use std::path::PathBuf; @@ -37,10 +36,7 @@ fn parent_directory_traversal_is_not_a_trusted_system_shell() { "ls".to_string(), ]; - assert_eq!( - shell_approval_command(&command, &shell, &UnifiedExecShellMode::Direct), - &command[..1], - ); + assert_eq!(shell_approval_command(&command, &shell), &command[..1],); } #[cfg(windows)] @@ -65,7 +61,7 @@ fn windows_shell_identity_is_case_insensitive() { let command = vec![executable, "-Command".to_string(), "echo safe".to_string()]; assert_eq!( - shell_approval_command(&command, &configured_shell, &UnifiedExecShellMode::Direct), + shell_approval_command(&command, &configured_shell), command.as_slice(), ); } diff --git a/codex-rs/core/src/guardian/approval_request.rs b/codex-rs/core/src/guardian/approval_request.rs index ee6fc74330..63893f6a30 100644 --- a/codex-rs/core/src/guardian/approval_request.rs +++ b/codex-rs/core/src/guardian/approval_request.rs @@ -1,14 +1,9 @@ -#[cfg(unix)] -use std::path::Path; - use codex_analytics::GuardianReviewedAction; use codex_protocol::approvals::GuardianAssessmentAction; use codex_protocol::approvals::GuardianCommandSource; use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::request_permissions::RequestPermissionProfile; -#[cfg(unix)] -use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::LegacyAppPathString; use codex_utils_path_uri::PathUri; use serde::Serialize; @@ -39,16 +34,6 @@ pub(crate) enum GuardianApprovalRequest { sandbox_permissions: crate::sandboxing::SandboxPermissions, additional_permissions: Option, }, - #[cfg(unix)] - Execve { - id: String, - environment_id: String, - source: GuardianCommandSource, - program: String, - argv: Vec, - cwd: AbsolutePathBuf, - additional_permissions: Option, - }, ApplyPatch { id: String, environment_id: String, @@ -97,8 +82,6 @@ impl GuardianApprovalRequest { | Self::ApplyPatch { environment_id, .. } | Self::RequestPermissions { environment_id, .. } | Self::NetworkAccess { environment_id, .. } => Some(environment_id), - #[cfg(unix)] - Self::Execve { environment_id, .. } => Some(environment_id), Self::McpToolCall { .. } => None, } } @@ -164,17 +147,6 @@ struct WriteStdinApprovalAction<'a> { tty: bool, } -#[cfg(unix)] -#[derive(Serialize)] -struct ExecveApprovalAction<'a> { - tool: &'a str, - program: &'a str, - argv: &'a [String], - cwd: &'a Path, - #[serde(skip_serializing_if = "Option::is_none")] - additional_permissions: Option<&'a AdditionalPermissionProfile>, -} - #[derive(Serialize)] struct McpToolCallApprovalAction<'a> { tool: &'static str, @@ -255,14 +227,6 @@ fn command_assessment_action( } } -#[cfg(unix)] -fn guardian_command_source_tool_name(source: GuardianCommandSource) -> &'static str { - match source { - GuardianCommandSource::Shell => "shell", - GuardianCommandSource::UnifiedExec => "exec_command", - } -} - pub(crate) fn guardian_approval_request_to_json( action: &GuardianApprovalRequest, ) -> serde_json::Result { @@ -303,22 +267,6 @@ pub(crate) fn guardian_approval_request_to_json( additional_permissions: additional_permissions.as_ref(), tty: *tty, }), - #[cfg(unix)] - GuardianApprovalRequest::Execve { - id: _, - environment_id: _, - source, - program, - argv, - cwd, - additional_permissions, - } => serialize_guardian_action(ExecveApprovalAction { - tool: guardian_command_source_tool_name(*source), - program, - argv, - cwd, - additional_permissions: additional_permissions.as_ref(), - }), GuardianApprovalRequest::ApplyPatch { id: _, environment_id: _, @@ -419,19 +367,6 @@ pub(crate) fn guardian_assessment_action( stdin: input.clone(), cwd: cwd.clone(), }, - #[cfg(unix)] - GuardianApprovalRequest::Execve { - source, - program, - argv, - cwd, - .. - } => GuardianAssessmentAction::Execve { - source: *source, - program: program.clone(), - argv: argv.clone(), - cwd: cwd.clone(), - }, GuardianApprovalRequest::ApplyPatch { cwd, files, .. } => { GuardianAssessmentAction::ApplyPatch { cwd: cwd.clone().into(), @@ -495,15 +430,6 @@ pub(crate) fn guardian_reviewed_action( GuardianApprovalRequest::WriteStdin { tty, .. } => { GuardianReviewedAction::WriteStdin { tty: *tty } } - #[cfg(unix)] - GuardianApprovalRequest::Execve { - source, - additional_permissions, - .. - } => GuardianReviewedAction::Execve { - source: *source, - additional_permissions: additional_permissions.as_ref().map(Into::into), - }, GuardianApprovalRequest::ApplyPatch { .. } => GuardianReviewedAction::ApplyPatch {}, GuardianApprovalRequest::NetworkAccess { protocol, port, .. } => { GuardianReviewedAction::NetworkAccess { @@ -539,8 +465,6 @@ pub(crate) fn guardian_request_target_item_id(request: &GuardianApprovalRequest) | GuardianApprovalRequest::McpToolCall { id, .. } | GuardianApprovalRequest::RequestPermissions { id, .. } => Some(id), GuardianApprovalRequest::NetworkAccess { .. } => None, - #[cfg(unix)] - GuardianApprovalRequest::Execve { id, .. } => Some(id), } } @@ -555,8 +479,6 @@ pub(crate) fn guardian_request_turn_id<'a>( | GuardianApprovalRequest::WriteStdin { .. } | GuardianApprovalRequest::ApplyPatch { .. } | GuardianApprovalRequest::McpToolCall { .. } => default_turn_id, - #[cfg(unix)] - GuardianApprovalRequest::Execve { .. } => default_turn_id, } } diff --git a/codex-rs/core/src/guardian/coverage.rs b/codex-rs/core/src/guardian/coverage.rs index f505bcb198..d0b2d12434 100644 --- a/codex-rs/core/src/guardian/coverage.rs +++ b/codex-rs/core/src/guardian/coverage.rs @@ -8,8 +8,6 @@ impl GuardianApprovalRequest { pub(crate) fn guardian_scope(&self) -> GuardianScope { match self { Self::ExecCommand { .. } | Self::WriteStdin { .. } => GuardianScope::Shell, - #[cfg(unix)] - Self::Execve { .. } => GuardianScope::Shell, Self::ApplyPatch { .. } => GuardianScope::FileChanges, Self::McpToolCall { server, .. } => GuardianScope::for_mcp_server(server), Self::NetworkAccess { .. } => GuardianScope::Network, @@ -22,8 +20,6 @@ impl ApprovalAction { pub(crate) fn guardian_scope(&self) -> GuardianScope { match self { Self::ExecCommand { .. } | Self::WriteStdin { .. } => GuardianScope::Shell, - #[cfg(unix)] - Self::Execve { .. } => GuardianScope::Shell, Self::ApplyPatch { .. } => GuardianScope::FileChanges, Self::McpToolCall { server, diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index fd432462d8..212a66cb21 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -147,8 +147,6 @@ pub(crate) async fn build_guardian_prompt_items_with_parent_turn( has_trigger: trigger.is_some(), }, GuardianApprovalRequest::WriteStdin { .. } => PlannedActionKind::TerminalInput, - #[cfg(unix)] - GuardianApprovalRequest::Execve { .. } => PlannedActionKind::Command, GuardianApprovalRequest::ExecCommand { .. } | GuardianApprovalRequest::ApplyPatch { .. } | GuardianApprovalRequest::McpToolCall { .. } diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index e86bd8c662..355360d160 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -76,19 +76,6 @@ async fn plugin_attribution_for_guardian_request( .and_then(|cwd| turn.plugin_attribution_for_command(command, &cwd)) } } - #[cfg(unix)] - GuardianApprovalRequest::Execve { - program, argv, cwd, .. - } => { - let command = if argv.is_empty() { - vec![program.clone()] - } else { - std::iter::once(program.clone()) - .chain(argv.iter().skip(1).cloned()) - .collect() - }; - turn.plugin_attribution_for_command(&command, cwd) - } _ => None, } } diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 836983bc75..80763c9db0 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -214,8 +214,6 @@ pub struct GuardianReviewSessionReuseKey { cwd: PathUri, mcp_servers: Constrained>, codex_linux_sandbox_exe: Option, - main_execve_wrapper_exe: Option, - zsh_path: Option, features: ManagedFeatures, environment_ids: Vec, } @@ -259,8 +257,6 @@ impl GuardianReviewSessionReuseKey { cwd: PathUri::from_abs_path(&spawn_config.cwd), mcp_servers: spawn_config.mcp_servers.clone(), codex_linux_sandbox_exe: spawn_config.codex_linux_sandbox_exe.clone(), - main_execve_wrapper_exe: spawn_config.main_execve_wrapper_exe.clone(), - zsh_path: spawn_config.zsh_path.clone(), features: spawn_config.features.clone(), environment_ids: Vec::new(), } diff --git a/codex-rs/core/src/guardian/runtime.rs b/codex-rs/core/src/guardian/runtime.rs index 7a494d7b74..9cb154233e 100644 --- a/codex-rs/core/src/guardian/runtime.rs +++ b/codex-rs/core/src/guardian/runtime.rs @@ -28,9 +28,6 @@ impl From for ReviewAction { let tool_call_id = match &request { // Stdin's display target is the launch; freshness belongs to this write. GuardianApprovalRequest::WriteStdin { approval_id, .. } => Some(approval_id.clone()), - // Intercepts retain the launch ID, not the current triggering call. - #[cfg(unix)] - GuardianApprovalRequest::Execve { .. } => None, GuardianApprovalRequest::ExecCommand { .. } | GuardianApprovalRequest::ApplyPatch { .. } | GuardianApprovalRequest::NetworkAccess { .. } diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 14b8a49c25..3644e42cde 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -648,16 +648,6 @@ async fn approval_permissions_use_the_owning_environment() -> anyhow::Result<()> sandbox_permissions: SandboxPermissions::UseDefault, additional_permissions: None, }, - #[cfg(unix)] - GuardianApprovalRequest::Execve { - id: "shell".to_string(), - environment_id: "secondary".to_string(), - source: codex_protocol::approvals::GuardianCommandSource::UnifiedExec, - program: "cat".to_string(), - argv: Vec::new(), - cwd, - additional_permissions: None, - }, ]; let mut missing_context = context.clone(); missing_context.environments.environments.truncate(1); diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index e4face0152..4714566430 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -401,7 +401,6 @@ use codex_protocol::turn_input::TurnStartOptions; use codex_protocol::user_input::UserInput; use codex_skills_extension::HostSkillsService; use codex_tools::ToolName; -use codex_tools::UnifiedExecShellMode; use codex_utils_absolute_path::AbsolutePathBuf; #[cfg(test)] use codex_utils_stream_parser::ProposedPlanSegment; diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 44ec95ff60..e93643f1d5 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -32,12 +32,6 @@ pub(super) async fn spawn_review_thread( let _ = review_features.disable(Feature::WebSearchCached); let _ = review_features.disable(Feature::Goals); let review_web_search_mode = WebSearchMode::Disabled; - let unified_exec_shell_mode = UnifiedExecShellMode::for_session( - review_features.get(), - crate::tools::tool_user_shell_type(sess.services.user_shell.as_ref()), - sess.services.shell_zsh_path.as_ref(), - sess.services.main_execve_wrapper_exe.as_ref(), - ); let review_prompt = resolved.prompt.clone(); let model_info = review_model_info.clone(); @@ -142,7 +136,6 @@ pub(super) async fn spawn_review_thread( per_turn_config, step_settings, available_models, - unified_exec_shell_mode, turn_metadata_state, ); diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 6210785bf9..08743217ed 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1238,9 +1238,17 @@ impl Session { session_source: session_configuration.session_source.clone(), cwd: session_configuration.cwd().to_path_buf(), rollout_path: rollout_path.clone(), - model: session_configuration.step_settings.collaboration_mode.model().to_string(), + model: session_configuration + .step_settings + .collaboration_mode + .model() + .to_string(), provider_name: config.model_provider_id.clone(), - approval_policy: session_configuration.step_settings.approval_policy.value().to_string(), + approval_policy: session_configuration + .step_settings + .approval_policy + .value() + .to_string(), sandbox_policy: format!( "{:?}", session_configuration.sandbox_policy(environment_selections) @@ -1280,7 +1288,9 @@ impl Session { let effective_config = config.config_layer_stack.effective_config(); let config_path = config.codex_home.join(CONFIG_TOML_FILE); if let Some(event) = unstable_features_warning_event( - effective_config.get("features").and_then(TomlValue::as_table), + effective_config + .get("features") + .and_then(TomlValue::as_table), config.suppress_unstable_features_warning, &config.features, &config_path.display().to_string(), @@ -1295,7 +1305,11 @@ impl Session { let account_email = telemetry_auth.and_then(CodexAuth::get_account_email); let originator = session_configuration.originator.clone(); let terminal_type = user_agent(); - let session_model = session_configuration.step_settings.collaboration_mode.model().to_string(); + let session_model = session_configuration + .step_settings + .collaboration_mode + .model() + .to_string(); let auth_env_telemetry = collect_auth_env_telemetry( session_configuration.provider.info(), auth_manager.codex_api_key_env_enabled(), @@ -1333,11 +1347,15 @@ impl Session { slug: Some(session_model), }; crate::config::emit_session_start_metrics(config.as_ref(), &session_telemetry); - let is_worktree = session_configuration.cwd().canonicalize().ok().and_then(|cwd| { - codex_git_utils::repository_identity(&cwd).and_then(|_| { - get_git_repo_root(&cwd).map(|root| root.join(".git").is_file()) - }) - }); + let is_worktree = session_configuration + .cwd() + .canonicalize() + .ok() + .and_then(|cwd| { + codex_git_utils::repository_identity(&cwd).and_then(|_| { + get_git_repo_root(&cwd).map(|root| root.join(".git").is_file()) + }) + }); let is_worktree_tag = match is_worktree { Some(true) => "true", Some(false) => "false", @@ -1355,16 +1373,16 @@ impl Session { ); let mcp_server_names = - codex_mcp::effective_mcp_servers( - &mcp_projection.config, - auth.as_ref(), - ) + codex_mcp::effective_mcp_servers(&mcp_projection.config, auth.as_ref()) .into_iter() .filter_map(|(name, server)| server.enabled().then_some(name)) .collect::>(); session_telemetry.conversation_starts( config.model_provider.name.as_str(), - session_configuration.step_settings.collaboration_mode.reasoning_effort(), + session_configuration + .step_settings + .collaboration_mode + .reasoning_effort(), config .model_reasoning_summary .unwrap_or(ReasoningSummaryConfig::Auto), @@ -1377,33 +1395,10 @@ impl Session { mcp_server_names.iter().map(String::as_str).collect(), ); - let use_zsh_fork_shell = config.features.enabled(Feature::ShellZshFork); - let default_shell = if let Some(user_shell_override) = - session_configuration.user_shell_override.clone() - { - user_shell_override - } else if use_zsh_fork_shell { - let zsh_path = config.zsh_path.as_ref().ok_or_else(|| { - anyhow::anyhow!( - "zsh fork feature enabled, but no packaged zsh fork is available for this install" - ) - })?; - if zsh_path.is_file() { - shell::Shell { - shell_type: shell::ShellType::Zsh, - shell_path: zsh_path.clone(), - } - } else { - shell::get_shell(shell::ShellType::Zsh).ok_or_else(|| { - anyhow::anyhow!( - "zsh fork feature enabled, but packaged zsh fork `{}` is not usable", - zsh_path.display() - ) - })? - } - } else { - shell::default_user_shell() - }; + let default_shell = session_configuration + .user_shell_override + .clone() + .unwrap_or_else(shell::default_user_shell); let credential_broker_available = config.features.enabled(Feature::NetworkProxy) && config .config_layer_stack @@ -1426,16 +1421,7 @@ impl Session { .is_some_and(crate::config::NetworkProxySpec::credential_broker_enabled); let prefer_executor_shell_snapshots = config.features.enabled(Feature::ShellSnapshotV2) && config.features.enabled(Feature::ShellTool) - && config.features.enabled(Feature::UnifiedExec) - && matches!( - codex_tools::UnifiedExecShellMode::for_session( - config.features.get(), - crate::tools::tool_user_shell_type(&default_shell), - config.zsh_path.as_ref(), - config.main_execve_wrapper_exe.as_ref(), - ), - codex_tools::UnifiedExecShellMode::Direct - ); + && config.features.enabled(Feature::UnifiedExec); let use_executor_shell_snapshots = prefer_executor_shell_snapshots && !credential_broker_active; let shell_snapshot = if config.features.enabled(Feature::ShellSnapshot) @@ -1508,12 +1494,12 @@ impl Session { let (agents_md_result, instruction_warnings) = instruction_refresh; // TODO(anp): Present AGENTS.md discovery errors more clearly to the user. agents_md_result?; - post_session_configured_events.extend( - instruction_warnings.into_iter().map(|message| Event { + post_session_configured_events.extend(instruction_warnings.into_iter().map( + |message| Event { id: INITIAL_SUBMIT_ID.to_owned(), msg: EventMsg::Warning(WarningEvent { message }), - }), - ); + }, + )); for err in &plugin_skill_errors { error!( "failed to load skill {}: {}", @@ -1541,7 +1527,8 @@ impl Session { .requirements_toml() .network .is_some(); - let managed_network_requirements_enabled = config.managed_network_requirements_enabled(); + let managed_network_requirements_enabled = + config.managed_network_requirements_enabled(); let network_approval = Arc::new(NetworkApprovalService::default()); // The managed proxy can call back into core for allowlist-miss decisions. let network_policy_decider_session = if managed_network_requirements_configured { @@ -1567,35 +1554,34 @@ impl Session { Arc::clone(network_policy_decider_session), ) }); - let (network_proxy, session_network_proxy) = - if let Some(spec) = config - .permissions - .network - .as_ref() - .filter(|spec| spec.enabled()) - { - let current_exec_policy = exec_policy.current(); - let (network_proxy, session_network_proxy) = Self::start_managed_network_proxy( - spec, - current_exec_policy.as_ref(), - config.permissions.permission_profile(), - config.effective_local_windows_sandbox_type(), - network_policy_decider.as_ref().map(Arc::clone), - blocked_request_observer.as_ref().map(Arc::clone), - managed_network_requirements_configured, - network_proxy_audit_metadata.clone(), - ) - .instrument(info_span!( - "session_init.network_proxy", - otel.name = "session_init.network_proxy", - session_init.managed_network_requirements_enabled = - managed_network_requirements_enabled, - )) - .await?; - (Some(network_proxy), Some(session_network_proxy)) - } else { - (None, None) - }; + let (network_proxy, session_network_proxy) = if let Some(spec) = config + .permissions + .network + .as_ref() + .filter(|spec| spec.enabled()) + { + let current_exec_policy = exec_policy.current(); + let (network_proxy, session_network_proxy) = Self::start_managed_network_proxy( + spec, + current_exec_policy.as_ref(), + config.permissions.permission_profile(), + config.effective_local_windows_sandbox_type(), + network_policy_decider.as_ref().map(Arc::clone), + blocked_request_observer.as_ref().map(Arc::clone), + managed_network_requirements_configured, + network_proxy_audit_metadata.clone(), + ) + .instrument(info_span!( + "session_init.network_proxy", + otel.name = "session_init.network_proxy", + session_init.managed_network_requirements_enabled = + managed_network_requirements_enabled, + )) + .await?; + (Some(network_proxy), Some(session_network_proxy)) + } else { + (None, None) + }; if let Some(network_proxy) = network_proxy.as_ref() && config .permissions @@ -1684,32 +1670,34 @@ impl Session { let mcp_resource_client = Arc::new(McpResourceClient::new(Arc::clone(&mcp_runtime))); let extension_metrics = extension_metrics::from_session_telemetry(session_telemetry.clone()); - let workspace_routing = thread_extension_data - .get_or_init(|| config.workspace_routing_context()); + let workspace_routing = + thread_extension_data.get_or_init(|| config.workspace_routing_context()); for contributor in extensions.thread_lifecycle_contributors() { - contributor.on_thread_start(codex_extension_api::ThreadStartInput { - config: config.as_ref(), - session_source: &session_configuration.session_source, - persistent_thread_state_available: state_db_ctx.is_some(), - environments: environment_selections, - mcp_resource_client: Some(Arc::clone(&mcp_resource_client)), - extension_metrics: Some(Arc::clone(&extension_metrics)), - session_store: &session_extension_data, - thread_store: &thread_extension_data, - }).await; + contributor + .on_thread_start(codex_extension_api::ThreadStartInput { + config: config.as_ref(), + session_source: &session_configuration.session_source, + persistent_thread_state_available: state_db_ctx.is_some(), + environments: environment_selections, + mcp_resource_client: Some(Arc::clone(&mcp_resource_client)), + extension_metrics: Some(Arc::clone(&extension_metrics)), + session_store: &session_extension_data, + thread_store: &thread_extension_data, + }) + .await; } - let executed_tool_calls = crate::state::ExecutedToolCalls::new( - &config.features, - &initial_history, - ); - let codex_responses_headers = thread_extension_data.get::(); + let executed_tool_calls = + crate::state::ExecutedToolCalls::new(&config.features, &initial_history); + let codex_responses_headers = + thread_extension_data.get::(); // Ephemeral title requests use request-level effort even when managed settings // enable overrides. The client-supplied tag selects cache behavior, not permissions. - let title_request = config.ephemeral && matches!( - session_configuration.thread_source.as_ref(), - Some(ThreadSource::Feature(feature)) if feature == "thread_title" - ); + let title_request = config.ephemeral + && matches!( + session_configuration.thread_source.as_ref(), + Some(ThreadSource::Feature(feature)) if feature == "thread_title" + ); let reasoning_effort_override_enabled = config.features.enabled(Feature::ReasoningEffortOverride) && !title_request; let services = SessionServices { @@ -1721,8 +1709,6 @@ impl Session { config.background_terminal_max_timeout, ), elicitations: crate::elicitation::ElicitationService::new(), - shell_zsh_path: config.zsh_path.clone(), - main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), analytics_events_client, hooks: arc_swap::ArcSwap::from_pointee(hooks), rollout_thread_trace, @@ -1781,7 +1767,8 @@ impl Session { config.features.enabled(Feature::EnableRequestCompression), config.features.enabled(Feature::RuntimeMetrics), Self::build_model_client_beta_features_header(config.as_ref()), - /*concurrent_reasoning_summaries_enabled*/ config + /*concurrent_reasoning_summaries_enabled*/ + config .features .enabled(Feature::ConcurrentReasoningSummaries), attestation_provider, @@ -1831,18 +1818,22 @@ impl Session { multi_agent_version, mcp_refresh: McpRefresh::new(), mcp_tool_approval_metadata: Default::default(), - mcp_elicitation_reviewer_handle: OnceLock::new(), + mcp_elicitation_reviewer_handle: OnceLock::new(), mcp_elicitation_lifecycle_handle: OnceLock::new(), mcp_prewarm_tx, mcp_prewarm_shutdown: CancellationToken::new(), mcp_prewarm_task: std::sync::Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), - realtime_history: (session_configuration.history_mode == ThreadHistoryMode::Paginated + realtime_history: (session_configuration.history_mode + == ThreadHistoryMode::Paginated && services.live_thread.is_some()) .then(|| Mutex::new(Default::default())), active_turn: Mutex::new(None), async_hook_results, - input_queue: InputQueue::with_controller(thread_id, Arc::clone(&services.agent_control)), + input_queue: InputQueue::with_controller( + thread_id, + Arc::clone(&services.agent_control), + ), services, git_enrichment_policy, fork_persistence, @@ -1915,7 +1906,9 @@ impl Session { session_source: &session_configuration.session_source, originator: &session_configuration.originator, disabled_plugin_ids: &session_configuration.disabled_plugin_ids, - environments: McpEnvironmentScope::Selected(&resolved_environment_selections), + environments: McpEnvironmentScope::Selected( + &resolved_environment_selections, + ), }, /*ready_selected_capability_roots*/ &[], /*executor_capability_discovery*/ None, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 7b5a34e4aa..0019592ea6 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -6613,144 +6613,6 @@ async fn absolute_cwd_update_with_turn_environment_is_allowed() { ); } -#[tokio::test] -async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { - let codex_home = tempfile::tempdir().expect("create temp dir"); - let mut config = build_test_config(codex_home.path()).await; - config - .features - .enable(Feature::ShellZshFork) - .expect("test config should allow shell_zsh_fork"); - config.zsh_path = None; - let config = Arc::new(config); - - let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = models_manager_with_provider( - config.codex_home.to_path_buf(), - auth_manager.clone(), - config.model_provider.clone(), - ); - let model = get_model_offline_for_tests(config.model.as_deref()); - let model_info = - construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config()); - let collaboration_mode = CollaborationMode { - mode: ModeKind::Default, - settings: Settings { - model, - reasoning_effort: config.model_reasoning_effort.clone(), - developer_instructions: None, - }, - }; - let session_configuration = SessionConfiguration { - turn_extension_init: Default::default(), - provider: create_model_provider( - config.model_provider.clone(), - Some(Arc::clone(&auth_manager)), - ), - environments: Vec::new(), - step_settings: Arc::new(StepSettings { - collaboration_mode, - reasoning_summary: config.model_reasoning_summary, - service_tier: None, - personality: config.personality, - approval_policy: config.permissions.approval_policy.clone(), - approvals_reviewer: config.approvals_reviewer, - }), - model_info_overrides: config.to_models_manager_config().into(), - developer_instructions: config.developer_instructions.clone(), - base_instructions: config - .base_instructions - .clone() - .unwrap_or_else(|| render_model_instructions(&model_info)), - permission_profile_state: config.permissions.permission_profile_state().clone(), - allow_login_shell: config.permissions.allow_login_shell, - shell_environment_policy: config.permissions.shell_environment_policy.clone(), - windows_sandbox_level: WindowsSandboxLevel::from_config(&config), - windows_sandbox_type: config.permissions.windows_sandbox_type, - use_legacy_landlock: config.features.use_legacy_landlock(), - legacy_fallback_cwd: config.cwd.clone(), - runtime_workspace_roots: config.workspace_roots.clone(), - codex_home: config.codex_home.clone(), - thread_name: None, - disabled_plugin_ids: Vec::new(), - original_config_do_not_use: Arc::clone(&config), - metrics_service_name: None, - app_server_client_name: None, - app_server_client_version: None, - trusted_guardian_reviewer: false, - session_source: SessionSource::Exec, - history_mode: Default::default(), - forked_from_thread_id: None, - parent_thread_id: None, - thread_source: None, - originator: "test_originator".to_string(), - dynamic_tools: Vec::new(), - user_shell_override: None, - }; - - let (tx_event, _rx_event) = async_channel::unbounded(); - let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); - let plugins_manager = Arc::new(plugins_manager_for_config( - &config, - Arc::clone(&auth_manager), - )); - let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let skills_service = Arc::new(HostSkillsService::new( - config.codex_home.clone(), - /*bundled_skills_enabled*/ true, - )); - let environment_manager = Arc::new(EnvironmentManager::default_for_tests()); - let result = Session::new( - /*startup*/ None, - session_configuration, - /*environment_selections*/ &[], - Arc::clone(&config), - SessionInstructions::default(), - "11111111-1111-4111-8111-111111111111".to_string(), - auth_manager, - models_manager, - Arc::default(), - model_info, - Arc::new(ExecPolicyManager::default()), - tx_event, - agent_status_tx, - InitialHistory::New, - ForkPersistence::Copied, - SessionSource::Exec, - skills_service, - plugins_manager, - mcp_manager, - Arc::new(codex_code_mode::DisabledCodeModeSessionProvider), - Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), - codex_extension_api::ExtensionDataInit::default(), - ClientMcpExtensions::default(), - LocalAgentControl::default().into(), - /*reserved_thread_id*/ None, - environment_manager, - /*inherited_environments*/ None, - /*analytics_events_client*/ None, - crate::passthrough_image_store(), - Arc::new(codex_thread_store::LocalThreadStore::new( - codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()), - /*state_db*/ None, - )), - codex_rollout_trace::ThreadTraceContext::disabled(), - /*attestation_provider*/ None, - /*external_time_provider*/ None, - Some(config.multi_agent_version_from_features()), - GitEnrichmentPolicy::Fresh, - codex_sandboxing::WindowsSandboxProxySettingsMode::Reconcile, - ) - .await; - - let err = match result { - Ok(_) => panic!("expected startup to fail"), - Err(err) => err, - }; - let msg = format!("{err:#}"); - assert!(msg.contains("zsh fork feature enabled, but no packaged zsh fork is available")); -} - async fn build_initial_context( session: &Session, turn_context: &Arc, @@ -6978,8 +6840,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { config.background_terminal_max_timeout, ), elicitations: crate::elicitation::ElicitationService::new(), - shell_zsh_path: None, - main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), analytics_events_client: AnalyticsEventsClient::new( Arc::clone(&auth_manager), config.chatgpt_base_url.trim_end_matches('/').to_string(), @@ -7132,9 +6992,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { session_configuration.provider.clone(), &session_configuration, config.multi_agent_version_from_features(), - session.services.user_shell.as_ref(), - session.services.shell_zsh_path.as_ref(), - session.services.main_execve_wrapper_exe.as_ref(), per_turn_config, Arc::new(super::step_settings::ResolvedStepSettings::new( Arc::clone(&session_configuration.step_settings), @@ -9246,8 +9103,6 @@ where config.background_terminal_max_timeout, ), elicitations: crate::elicitation::ElicitationService::new(), - shell_zsh_path: None, - main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), analytics_events_client: AnalyticsEventsClient::new( Arc::clone(&auth_manager), config.chatgpt_base_url.trim_end_matches('/').to_string(), @@ -9400,9 +9255,6 @@ where session_configuration.provider.clone(), &session_configuration, config.multi_agent_version_from_features(), - session.services.user_shell.as_ref(), - session.services.shell_zsh_path.as_ref(), - session.services.main_execve_wrapper_exe.as_ref(), per_turn_config, Arc::new(super::step_settings::ResolvedStepSettings::new( Arc::clone(&session_configuration.step_settings), diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index d658e96e35..1e705c88eb 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -367,7 +367,6 @@ pub struct TurnContext { // so owner-provided environment settings govern the remaining sandbox decisions. pub(crate) windows_sandbox_level: WindowsSandboxLevel, pub(crate) available_models: Vec, - pub(crate) unified_exec_shell_mode: UnifiedExecShellMode, pub(crate) final_output_json_schema: Option, pub(crate) dynamic_tools: Vec, pub(crate) turn_metadata_state: Arc, @@ -415,7 +414,6 @@ impl TurnContext { config: Arc, step_settings: Arc, available_models: Vec, - unified_exec_shell_mode: UnifiedExecShellMode, turn_metadata_state: Arc, ) -> Self { let session_telemetry = step_settings.telemetry(&self.session_telemetry); @@ -444,7 +442,6 @@ impl TurnContext { originator: self.originator.clone(), initial_environments: self.initial_environments.clone(), available_models, - unified_exec_shell_mode, current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), @@ -853,7 +850,6 @@ impl TurnContext { network: self.network.clone(), windows_sandbox_level: self.windows_sandbox_level, available_models, - unified_exec_shell_mode: self.unified_exec_shell_mode.clone(), final_output_json_schema: self.final_output_json_schema.clone(), dynamic_tools: self.dynamic_tools.clone(), turn_metadata_state: self.turn_metadata_state.clone(), @@ -1023,9 +1019,6 @@ impl Session { provider: SharedModelProvider, session_configuration: &SessionConfiguration, multi_agent_version: MultiAgentVersion, - user_shell: &shell::Shell, - shell_zsh_path: Option<&PathBuf>, - main_execve_wrapper_exe: Option<&PathBuf>, per_turn_config: Config, step_settings: Arc, models_manager: &SharedModelsManager, @@ -1039,12 +1032,6 @@ impl Session { let session_telemetry_for_context = step_settings.telemetry(session_telemetry); let session_source = session_configuration.session_source.clone(); let available_models = models_manager.try_list_models().unwrap_or_default(); - let unified_exec_shell_mode = UnifiedExecShellMode::for_session( - per_turn_config.features.get(), - crate::tools::tool_user_shell_type(user_shell), - shell_zsh_path, - main_execve_wrapper_exe, - ); let mut per_turn_config = per_turn_config; let configured_token_budget = per_turn_config.token_budget.clone(); @@ -1134,7 +1121,6 @@ impl Session { network, windows_sandbox_level: session_configuration.windows_sandbox_level, available_models, - unified_exec_shell_mode, final_output_json_schema: None, dynamic_tools: session_configuration.dynamic_tools.clone(), turn_metadata_state, @@ -1350,9 +1336,6 @@ impl Session { session_configuration.provider.clone(), &session_configuration, multi_agent_version, - self.services.user_shell.as_ref(), - self.services.shell_zsh_path.as_ref(), - self.services.main_execve_wrapper_exe.as_ref(), per_turn_config, step_settings, &self.services.models_manager, diff --git a/codex-rs/core/src/spawn.rs b/codex-rs/core/src/spawn.rs index 79526b8bfd..3ac3493f9b 100644 --- a/codex-rs/core/src/spawn.rs +++ b/codex-rs/core/src/spawn.rs @@ -80,11 +80,6 @@ pub(crate) async fn spawn_child_async(request: SpawnChildRequest<'_>) -> std::io if let Some(network) = network { network.apply_to_env(&mut env); } - // macOS fd cleanup must keep the shell escalation socket. - #[cfg(target_os = "macos")] - let inherited_fd = env - .get(codex_shell_escalation::ESCALATE_SOCKET_ENV_VAR) - .and_then(|fd| fd.parse().ok()); cmd.env_clear(); cmd.envs(env); @@ -113,9 +108,9 @@ pub(crate) async fn spawn_child_async(request: SpawnChildRequest<'_>) -> std::io // current parent dies." codex_utils_pty::process_group::set_parent_death_signal(parent_pid)?; } - // macOS cannot receive the fd with close-on-exec set atomically. + // Close descriptors accidentally inherited by the child. #[cfg(target_os = "macos")] - codex_utils_pty::pty::close_inherited_fds_except(inherited_fd.as_slice()); + codex_utils_pty::pty::close_inherited_fds_except(&[]); Ok(()) }); } diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index bd723c453f..3671386bd4 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -1,5 +1,4 @@ use std::collections::HashMap; -use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex as StdMutex; @@ -56,10 +55,6 @@ pub(crate) struct SessionServices { pub(crate) mcp_handler_cache: McpHandlerCache, pub(crate) unified_exec_manager: UnifiedExecProcessManager, pub(crate) elicitations: ElicitationService, - #[cfg_attr(not(unix), allow(dead_code))] - pub(crate) shell_zsh_path: Option, - #[cfg_attr(not(unix), allow(dead_code))] - pub(crate) main_execve_wrapper_exe: Option, pub(crate) analytics_events_client: AnalyticsEventsClient, pub(crate) hooks: ArcSwap, pub(crate) rollout_thread_trace: ThreadTraceContext, diff --git a/codex-rs/core/src/tools/approvals.rs b/codex-rs/core/src/tools/approvals.rs index e892a6e805..16197b56d3 100644 --- a/codex-rs/core/src/tools/approvals.rs +++ b/codex-rs/core/src/tools/approvals.rs @@ -26,8 +26,6 @@ use codex_otel::ToolDecisionSource; use codex_prompts::ResolvedModelMessages; use codex_protocol::approvals::ExecApprovalKind; use codex_protocol::approvals::ExecPolicyAmendment; -#[cfg(unix)] -use codex_protocol::approvals::GuardianCommandSource; use codex_protocol::approvals::NetworkApprovalContext; use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::config_types::ApprovalsReviewer; @@ -89,18 +87,6 @@ pub(crate) enum ApprovalAction { sandbox_permissions: SandboxPermissions, additional_permissions: Option, }, - #[cfg(unix)] - Execve { - id: String, - approval_id: String, - environment_id: String, - source: GuardianCommandSource, - program: AbsolutePathBuf, - argv: Vec, - command: Vec, - cwd: AbsolutePathBuf, - additional_permissions: Option, - }, ApplyPatch { id: String, environment_id: String, @@ -195,11 +181,6 @@ impl ApprovalAction { "additional_permissions": additional_permissions, }), }, - #[cfg(unix)] - Self::Execve { command, .. } => PermissionRequestPayload::bash( - codex_shell_command::parse_command::shlex_join(command), - /*description*/ None, - ), Self::ApplyPatch { patch, .. } => PermissionRequestPayload { tool_name: HookToolName::apply_patch(), tool_input: serde_json::json!({ "command": patch }), @@ -255,8 +236,6 @@ impl ApprovalAction { sandbox_permissions: *sandbox_permissions, additional_permissions: additional_permissions.clone(), })], - #[cfg(unix)] - Self::Execve { .. } => Vec::new(), Self::McpToolCall { .. } | Self::NetworkAccess { .. } | Self::RequestPermissions { .. } @@ -331,25 +310,6 @@ impl ApprovalAction { sandbox_permissions, additional_permissions, }, - #[cfg(unix)] - Self::Execve { - id, - environment_id, - source, - program, - argv, - cwd, - additional_permissions, - .. - } => crate::guardian::GuardianApprovalRequest::Execve { - id, - environment_id, - source, - program: program.to_string_lossy().into_owned(), - argv, - cwd, - additional_permissions, - }, Self::ApplyPatch { id, environment_id, @@ -495,8 +455,6 @@ impl Session { let is_mcp_tool_call = matches!(&action, ApprovalAction::McpToolCall { .. }); let is_network_approval = matches!(&action, ApprovalAction::NetworkAccess { .. }); let permission_request_run_id = match &action { - #[cfg(unix)] - ApprovalAction::Execve { approval_id, .. } => approval_id.clone(), ApprovalAction::NetworkAccess { hook_run_id, .. } => hook_run_id.clone(), _ if ctx.retry_reason.is_some() => format!("{}:retry", ctx.call_id), _ => ctx.call_id.clone(), @@ -763,33 +721,6 @@ impl Session { ) .await } - #[cfg(unix)] - ApprovalAction::Execve { - approval_id, - environment_id, - command, - cwd, - additional_permissions, - .. - } => { - self.request_command_approval( - ctx.review_context.turn(), - ExecApprovalKind::Command, - ctx.review_context.model_context(), - ctx.call_id.clone(), - Some(approval_id.clone()), - Some(environment_id.clone()), - command.clone(), - cwd.clone().into(), - /*reason*/ None, - /*network_approval_context*/ None, - /*proposed_execpolicy_amendment*/ None, - additional_permissions.clone(), - Some(vec![ReviewDecision::Approved, ReviewDecision::Abort]), - /*plugin_attribution_override*/ None, - ) - .await - } ApprovalAction::ApplyPatch { changes, permissions_preapproved, diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 763fa15576..6eeaf861c1 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -1,16 +1,13 @@ use crate::sandboxing::SandboxPermissions; use crate::shell::Shell; use crate::shell::ShellInvocation; -use crate::shell::ShellType; use crate::shell::get_shell_by_model_provided_path; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; use crate::tools::hook_names::HookToolName; use crate::tools::registry::PostToolUsePayload; -use codex_exec_server::Environment; use codex_protocol::models::AdditionalPermissionProfile; -use codex_tools::UnifiedExecShellMode; use serde::Deserialize; use std::path::PathBuf; use std::sync::Arc; @@ -100,7 +97,6 @@ fn post_unified_exec_tool_use_payload( pub(crate) fn get_command( args: &ExecCommandArgs, session_shell: Arc, - shell_mode: &UnifiedExecShellMode, allow_login_shell: bool, ) -> Result { let use_login_shell = match args.login { @@ -113,24 +109,11 @@ pub(crate) fn get_command( None => allow_login_shell, }; - let shell = match shell_mode { - UnifiedExecShellMode::Direct => args - .shell - .as_ref() - .map(|shell_str| get_shell_by_model_provided_path(&PathBuf::from(shell_str))) - .unwrap_or_else(|| session_shell.as_ref().clone()), - UnifiedExecShellMode::ZshFork(zsh_fork_config) => { - if args.shell.is_some() { - return Err( - "`shell` is not supported for local zsh-fork exec; omit `shell` to use zsh-fork, or target a remote environment where `shell` is supported.".to_string(), - ); - } - Shell { - shell_type: ShellType::Zsh, - shell_path: zsh_fork_config.shell_zsh_path.as_path().to_path_buf(), - } - } - }; + let shell = args + .shell + .as_ref() + .map(|shell_str| get_shell_by_model_provided_path(&PathBuf::from(shell_str))) + .unwrap_or_else(|| session_shell.as_ref().clone()); Ok(ResolvedCommand { command: shell.derive_exec_args(&args.cmd, use_login_shell), shell: ShellInvocation { @@ -140,17 +123,6 @@ pub(crate) fn get_command( }) } -pub(crate) fn shell_mode_for_environment( - turn_shell_mode: &UnifiedExecShellMode, - environment: &Environment, -) -> UnifiedExecShellMode { - if environment.is_remote() { - UnifiedExecShellMode::Direct - } else { - turn_shell_mode.clone() - } -} - #[cfg(test)] #[path = "unified_exec_tests.rs"] mod tests; diff --git a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index faf8b90848..502848230d 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -51,7 +51,6 @@ use super::ExecCommandArgs; use super::ExecCommandEnvironmentArgs; use super::get_command; use super::post_unified_exec_tool_use_payload; -use super::shell_mode_for_environment; // A byte limit is a conservative hard token bound even for byte-fallback tokenizers. const EXEC_COMMAND_REJECTION_MAX_BYTES: usize = 900; @@ -257,8 +256,6 @@ impl ExecCommandHandler { &turn_environment.selection.environment_id, ) .await; - let shell_mode = - shell_mode_for_environment(&turn.unified_exec_shell_mode, environment.as_ref()); // Remote environments may use a different OS and must build commands with their native // shell; fall back to the session shell when the environment did not report one. let shell = turn_environment @@ -285,13 +282,9 @@ impl ExecCommandHandler { ))); } } - let resolved_command = get_command( - &args, - shell, - &shell_mode, - turn_environment.config().allow_login_shell, - ) - .map_err(FunctionCallError::RespondToModel)?; + let resolved_command = + get_command(&args, shell, turn_environment.config().allow_login_shell) + .map_err(FunctionCallError::RespondToModel)?; let command = resolved_command.command; let ExecCommandArgs { mut tty, @@ -420,7 +413,6 @@ impl ExecCommandHandler { cwd, sandbox_cwd: native_environment_cwd, turn_environment: turn_environment.clone(), - shell_mode, network: context.step_context.turn.network.clone(), tty, sandbox_permissions: effective_additional_permissions.sandbox_permissions, diff --git a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs index d834c24982..ecff8f5039 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs @@ -2,10 +2,8 @@ use super::*; use crate::shell::ShellType; use crate::shell::default_user_shell; use crate::shell::get_shell; +#[cfg(not(windows))] use codex_exec_server::Environment; -use codex_tools::UnifiedExecShellMode; -use codex_tools::ZshForkConfig; -use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; @@ -58,7 +56,6 @@ fn test_get_command_uses_default_shell_when_unspecified() -> anyhow::Result<()> let resolved = get_command( &args, Arc::new(default_user_shell()), - &UnifiedExecShellMode::Direct, /*allow_login_shell*/ true, ) .map_err(anyhow::Error::msg)?; @@ -80,7 +77,6 @@ fn test_get_command_respects_explicit_bash_shell() -> anyhow::Result<()> { let resolved = get_command( &args, Arc::new(default_user_shell()), - &UnifiedExecShellMode::Direct, /*allow_login_shell*/ true, ) .map_err(anyhow::Error::msg)?; @@ -122,13 +118,8 @@ fn test_get_command_keeps_path_prepends_after_login_startup() -> anyhow::Result< r#"{"cmd":"sh -c 'test \"$CODEX_TEST_LOGIN_PATH_RESET\" = 1 && printf \"%s\\n\" \"$PATH\" && command -v rg'"}"#, )?; let shell = Arc::new(get_shell(ShellType::Bash).expect("bash should be available")); - let resolved = get_command( - &args, - Arc::clone(&shell), - &UnifiedExecShellMode::Direct, - /*allow_login_shell*/ true, - ) - .map_err(anyhow::Error::msg)?; + let resolved = get_command(&args, Arc::clone(&shell), /*allow_login_shell*/ true) + .map_err(anyhow::Error::msg)?; assert_eq!( resolved.command, shell.derive_exec_args(&args.cmd, /*use_login_shell*/ true) @@ -188,19 +179,15 @@ fn test_get_command_runs_login_shell_when_path_is_readonly() -> anyhow::Result<( r#"{"cmd":"shopt -q login_shell || exit 90; case $- in *e*) ;; *) exit 91 ;; esac; /bin/sh -c 'printf \"%s\\n\" \"$PATH\"'; exit 23","login":true}"#, )?; let shell = Arc::new(get_shell(ShellType::Bash).expect("bash should be available")); - let resolved = get_command( - &args, - shell, - &UnifiedExecShellMode::Direct, - /*allow_login_shell*/ true, - ) - .map_err(anyhow::Error::msg)?; + let resolved = + get_command(&args, shell, /*allow_login_shell*/ true).map_err(anyhow::Error::msg)?; let command = resolved .shell .derive_exec_args_with_path_prepends(&args.cmd, &[path_dir]) .expect("the reported directory can be put on PATH"); let output = std::process::Command::new(&command[0]) .args(&command[1..]) + .env("HOME", temp_dir.path()) .env("BASH_ENV", startup) .env("CODEX_TEST_LOGIN_HOME", temp_dir.path()) .env_remove("SHLVL") @@ -249,13 +236,8 @@ fn test_get_command_does_not_change_nested_login_or_powershell() -> anyhow::Resu shell_type, shell_path: PathBuf::from(shell_path), }); - let resolved = get_command( - &args, - Arc::clone(&shell), - &UnifiedExecShellMode::Direct, - /*allow_login_shell*/ true, - ) - .map_err(anyhow::Error::msg)?; + let resolved = get_command(&args, Arc::clone(&shell), /*allow_login_shell*/ true) + .map_err(anyhow::Error::msg)?; assert_eq!( ( resolved.command, @@ -294,7 +276,6 @@ fn test_get_command_resolves_powershell_by_type() -> anyhow::Result<()> { let resolved = get_command( &args, Arc::new(default_user_shell()), - &UnifiedExecShellMode::Direct, /*allow_login_shell*/ true, ) .map_err(anyhow::Error::msg)?; @@ -319,7 +300,6 @@ fn test_get_command_respects_explicit_cmd_shell() -> anyhow::Result<()> { let resolved = get_command( &args, Arc::new(default_user_shell()), - &UnifiedExecShellMode::Direct, /*allow_login_shell*/ true, ) .map_err(anyhow::Error::msg)?; @@ -337,7 +317,6 @@ fn test_get_command_rejects_explicit_login_when_disallowed() -> anyhow::Result<( let err = get_command( &args, Arc::new(default_user_shell()), - &UnifiedExecShellMode::Direct, /*allow_login_shell*/ false, ) .expect_err("explicit login should be rejected"); @@ -389,71 +368,6 @@ async fn exec_command_rejects_login_when_selected_environment_disallows_it() { ); } -#[test] -fn test_get_command_rejects_explicit_shell_in_zsh_fork_mode() -> anyhow::Result<()> { - let json = r#"{"cmd": "echo hello", "shell": "/bin/bash"}"#; - let args: ExecCommandArgs = parse_arguments(json)?; - let shell_zsh_path = AbsolutePathBuf::from_absolute_path(if cfg!(windows) { - r"C:\opt\codex\zsh" - } else { - "/opt/codex/zsh" - })?; - let shell_mode = UnifiedExecShellMode::ZshFork(ZshForkConfig { - shell_zsh_path, - main_execve_wrapper_exe: AbsolutePathBuf::from_absolute_path(if cfg!(windows) { - r"C:\opt\codex\codex-execve-wrapper" - } else { - "/opt/codex/codex-execve-wrapper" - })?, - }); - - let err = get_command( - &args, - Arc::new(default_user_shell()), - &shell_mode, - /*allow_login_shell*/ true, - ) - .expect_err("explicit shell should be rejected"); - - assert!( - err.contains("`shell` is not supported for local zsh-fork exec"), - "unexpected error: {err}" - ); - Ok(()) -} - -#[tokio::test] -async fn shell_mode_for_environment_uses_direct_mode_for_remote_environments() -> anyhow::Result<()> -{ - let shell_zsh_path = AbsolutePathBuf::from_absolute_path(if cfg!(windows) { - r"C:\opt\codex\zsh" - } else { - "/opt/codex/zsh" - })?; - let shell_mode = UnifiedExecShellMode::ZshFork(ZshForkConfig { - shell_zsh_path, - main_execve_wrapper_exe: AbsolutePathBuf::from_absolute_path(if cfg!(windows) { - r"C:\opt\codex\codex-execve-wrapper" - } else { - "/opt/codex/codex-execve-wrapper" - })?, - }); - let local_environment = Environment::default_for_tests(); - let remote_environment = - Environment::create_for_tests(Some("ws://127.0.0.1:1/remote-exec-server".to_string()))?; - - assert_eq!( - shell_mode_for_environment(&shell_mode, &local_environment), - shell_mode - ); - assert_eq!( - shell_mode_for_environment(&shell_mode, &remote_environment), - UnifiedExecShellMode::Direct - ); - - Ok(()) -} - #[tokio::test] #[cfg(not(windows))] async fn exec_command_reuses_foreign_windows_grant() { diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index 41ed0189b8..501ce5fe4e 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -60,18 +60,6 @@ pub(crate) fn flat_tool_name(tool_name: &ToolName) -> Cow<'_, str> { } } -pub(crate) fn tool_user_shell_type( - user_shell: &crate::shell::Shell, -) -> codex_tools::ToolUserShellType { - match user_shell.shell_type { - crate::shell::ShellType::Zsh => codex_tools::ToolUserShellType::Zsh, - crate::shell::ShellType::Bash => codex_tools::ToolUserShellType::Bash, - crate::shell::ShellType::PowerShell => codex_tools::ToolUserShellType::PowerShell, - crate::shell::ShellType::Sh => codex_tools::ToolUserShellType::Sh, - crate::shell::ShellType::Cmd => codex_tools::ToolUserShellType::Cmd, - } -} - pub(crate) fn requested_tool_mode(turn_context: &TurnContext, model_info: &ModelInfo) -> ToolMode { model_info.tool_mode.unwrap_or_else(|| { if turn_context.config.features.enabled(Feature::CodeModeOnly) { diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index 17a427be2f..359bc770f2 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -35,7 +35,6 @@ use std::path::Path; pub(crate) mod apply_patch; pub(crate) mod unified_exec; -pub(crate) mod zsh_fork; const SNAPSHOT_ORIGINAL_BASH_ENV_ENV_KEY: &str = "CODEX_NETWORK_PROXY_SNAPSHOT_ORIGINAL_BASH_ENV"; const SNAPSHOT_ORIGINAL_POSIX_ENV_ENV_KEY: &str = "CODEX_NETWORK_PROXY_SNAPSHOT_ORIGINAL_POSIX_ENV"; @@ -141,18 +140,6 @@ pub(crate) fn apply_package_path_prepend( runtime_path_prepends.prepend(env, path_dir.as_path()); } -#[cfg(unix)] -pub(crate) fn apply_zsh_fork_path_prepend( - env: &mut HashMap, - runtime_path_prepends: &mut RuntimePathPrepends, - shell_zsh_path: &Path, -) { - let Some(zsh_bin_dir) = shell_zsh_path.parent() else { - return; - }; - runtime_path_prepends.prepend(env, zsh_bin_dir); -} - pub(crate) fn prepare_powershell_command_for_windows_sandbox( command: &[String], shell_type: Option<&ShellType>, diff --git a/codex-rs/core/src/tools/runtimes/mod_tests.rs b/codex-rs/core/src/tools/runtimes/mod_tests.rs index e1a25d1d82..d5933281d8 100644 --- a/codex-rs/core/src/tools/runtimes/mod_tests.rs +++ b/codex-rs/core/src/tools/runtimes/mod_tests.rs @@ -253,56 +253,6 @@ fn runtime_path_prepends_ignores_empty_path_entry() { ); } -#[cfg(unix)] -#[test] -fn apply_zsh_fork_path_prepend_uses_shell_parent() { - let mut env = HashMap::from([("PATH".to_string(), "/usr/bin:/bin".to_string())]); - let mut runtime_path_prepends = RuntimePathPrepends::default(); - - apply_zsh_fork_path_prepend( - &mut env, - &mut runtime_path_prepends, - PathBuf::from("/package/codex-resources/zsh/bin/zsh").as_path(), - ); - - let expected = "/package/codex-resources/zsh/bin:/usr/bin:/bin"; - assert_eq!(env.get("PATH").map(String::as_str), Some(expected)); - assert_eq!( - runtime_path_prepends, - RuntimePathPrepends { - entries: vec!["/package/codex-resources/zsh/bin".to_string()] - } - ); -} - -#[cfg(unix)] -#[test] -fn apply_zsh_fork_path_prepend_moves_existing_shell_parent_to_front() { - let mut env = HashMap::from([( - "PATH".to_string(), - "/usr/bin:/package/codex-resources/zsh/bin:/bin:/package/codex-resources/zsh/bin" - .to_string(), - )]); - let mut runtime_path_prepends = RuntimePathPrepends::default(); - - apply_zsh_fork_path_prepend( - &mut env, - &mut runtime_path_prepends, - PathBuf::from("/package/codex-resources/zsh/bin/zsh").as_path(), - ); - - assert_eq!( - env.get("PATH").map(String::as_str), - Some("/package/codex-resources/zsh/bin:/usr/bin:/bin") - ); - assert_eq!( - runtime_path_prepends, - RuntimePathPrepends { - entries: vec!["/package/codex-resources/zsh/bin".to_string()] - } - ); -} - #[test] fn explicit_escalation_keeps_user_proxy_env_without_codex_marker() { let env = HashMap::from([ @@ -1764,56 +1714,6 @@ fn run_snapshot_path_probe_with_runtime_path_prepend( )) } -#[cfg(unix)] -#[test] -fn maybe_wrap_shell_lc_with_snapshot_preserves_zsh_fork_path_prepend() { - let dir = tempdir().expect("create temp dir"); - let snapshot_path = dir.path().join("snapshot.sh"); - std::fs::write( - &snapshot_path, - "# Snapshot file\nexport PATH='/snapshot/bin'\n", - ) - .expect("write snapshot"); - let (session_shell, shell_snapshot) = - shell_with_snapshot(ShellType::Bash, "/bin/bash", snapshot_path.abs()); - let command = vec![ - "/bin/bash".to_string(), - "-lc".to_string(), - "printf '%s' \"$PATH\"".to_string(), - ]; - let zsh_path = dir - .path() - .join("codex-resources") - .join("zsh") - .join("bin") - .join("zsh"); - let zsh_bin_dir = zsh_path.parent().expect("zsh path should have parent"); - let mut env = HashMap::from([("PATH".to_string(), "/worktree/bin".to_string())]); - let explicit_env_overrides = HashMap::new(); - let mut runtime_path_prepends = RuntimePathPrepends::default(); - apply_zsh_fork_path_prepend(&mut env, &mut runtime_path_prepends, zsh_path.as_path()); - let rewritten = maybe_wrap_shell_lc_with_snapshot( - &command, - &session_shell, - Some(&shell_snapshot), - &explicit_env_overrides, - &env, - &runtime_path_prepends, - ); - let output = Command::new(&rewritten[0]) - .args(&rewritten[1..]) - .env("PATH", env.get("PATH").expect("PATH should be set")) - .output() - .expect("run rewritten command"); - - assert!(output.status.success(), "command failed: {output:?}"); - assert_eq!( - String::from_utf8_lossy(&output.stdout), - format!("{}:/snapshot/bin", zsh_bin_dir.display()), - "zsh fork path prepend should replay ahead of snapshot PATH" - ); -} - #[test] fn maybe_wrap_shell_lc_with_snapshot_does_not_embed_override_values_in_argv() { let dir = tempdir().expect("create temp dir"); diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 57320382fb..61d66c6927 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -21,12 +21,9 @@ use crate::shell_snapshot::snapshot_read_permissions; use crate::tools::flat_tool_name; use crate::tools::network_approval::NetworkApprovalSpec; use crate::tools::runtimes::RuntimePathPrepends; -#[cfg(unix)] -use crate::tools::runtimes::apply_zsh_fork_path_prepend; use crate::tools::runtimes::exec_env_for_sandbox_permissions; use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; use crate::tools::runtimes::prepare_powershell_command_for_windows_sandbox; -use crate::tools::runtimes::zsh_fork; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalAction; use crate::tools::sandboxing::ExecApprovalRequirement; @@ -38,10 +35,8 @@ use crate::tools::sandboxing::ToolRuntime; use crate::tools::sandboxing::executor_windows_sandbox_selection; use crate::tools::sandboxing::managed_network_for_sandbox_permissions; use crate::tools::sandboxing::sandbox_permissions_preserving_denied_reads; -use crate::unified_exec::NoopSpawnLifecycle; use crate::unified_exec::TerminalPermissions; use crate::unified_exec::TerminalSandboxSource; -use crate::unified_exec::UnifiedExecError; use crate::unified_exec::UnifiedExecProcess; use crate::unified_exec::UnifiedExecProcessManager; use codex_core_plugins::PluginMetricsSidecar; @@ -50,13 +45,11 @@ use codex_network_proxy::CREDENTIAL_BROKER_ACTIVE_ENV_KEY; use codex_network_proxy::ManagedNetworkSandboxContext; use codex_network_proxy::NetworkProxy; use codex_protocol::error::CodexErr; -use codex_protocol::error::SandboxErr; use codex_protocol::models::AdditionalPermissionProfile; use codex_sandboxing::SandboxCommand; use codex_sandboxing::SandboxablePreference; use codex_sandboxing::policy_transforms::merge_permission_profiles; use codex_shell_command::powershell::prefix_powershell_script_with_utf8; -use codex_tools::UnifiedExecShellMode; use codex_utils_path_uri::PathUri; use std::collections::HashMap; use std::io; @@ -90,8 +83,6 @@ pub struct UnifiedExecRequest { pub tty: bool, pub sandbox_permissions: SandboxPermissions, pub additional_permissions: Option, - #[cfg(unix)] - pub additional_permissions_preapproved: bool, pub justification: Option, pub exec_approval_requirement: ExecApprovalRequirement, } @@ -113,7 +104,6 @@ pub struct UnifiedExecApprovalKey { /// unified-exec side while delegating process startup to the manager. pub struct UnifiedExecRuntime<'a> { manager: &'a UnifiedExecProcessManager, - shell_mode: UnifiedExecShellMode, } pub(crate) struct UnifiedExecAttempt { @@ -157,11 +147,8 @@ fn build_unified_exec_sandbox_command( impl<'a> UnifiedExecRuntime<'a> { /// Creates a runtime bound to the shared unified-exec process manager. - pub fn new(manager: &'a UnifiedExecProcessManager, shell_mode: UnifiedExecShellMode) -> Self { - Self { - manager, - shell_mode, - } + pub fn new(manager: &'a UnifiedExecProcessManager) -> Self { + Self { manager } } } @@ -301,24 +288,13 @@ impl<'a> ToolRuntime for UnifiedExecRunt .network .as_ref() .is_some_and(crate::config::NetworkProxySpec::credential_broker_enabled); - if credential_broker_available - && managed_network.is_some() - && matches!(self.shell_mode, UnifiedExecShellMode::ZshFork(_)) - { - return Err(ToolError::Rejected( - "credential brokerage does not yet support shell_zsh_fork; disable shell_zsh_fork to use the broker".to_string(), - )); - } let shell = if credential_broker_available { requested_shell } else { environment_shell }; - let snapshot_metrics = snapshot_metrics::SnapshotMetrics::start( - req, - &ctx.step_context.turn.config, - &self.shell_mode, - ); + let snapshot_metrics = + snapshot_metrics::SnapshotMetrics::start(req, &ctx.step_context.turn.config); let shell_snapshot = if environment_is_remote || credential_broker_available && launch_sandbox_permissions.requires_escalated_permissions() @@ -510,13 +486,6 @@ impl<'a> ToolRuntime for UnifiedExecRunt &mut runtime_path_prepends, ); } - if let UnifiedExecShellMode::ZshFork(zsh_fork_config) = &self.shell_mode { - apply_zsh_fork_path_prepend( - &mut env, - &mut runtime_path_prepends, - zsh_fork_config.shell_zsh_path.as_path(), - ); - } runtime_path_prepends }; #[cfg(not(unix))] @@ -666,78 +635,6 @@ impl<'a> ToolRuntime for UnifiedExecRunt permissions }; - if let UnifiedExecShellMode::ZshFork(zsh_fork_config) = &self.shell_mode { - let command = build_unified_exec_sandbox_command( - &command, - &req.cwd, - &env, - managed_network_context.clone(), - additional_permissions.clone(), - ) - .map_err(|error| match error { - ToolError::Rejected(_) => { - ToolError::Rejected("missing command line for PTY".to_string()) - } - error @ ToolError::Codex(_) => error, - })?; - let options = unified_exec_options(attempt.network_denial_cancellation_token.clone()); - let mut exec_env = attempt - .env_for( - command, - options, - managed_network, - Some(&req.turn_environment.selection.environment_id), - ) - .await - .map_err(ToolError::Codex)?; - exec_env.exec_server_env_config = req.exec_server_env_config.clone(); - match zsh_fork::maybe_prepare_unified_exec(req, attempt, ctx, exec_env, zsh_fork_config) - .await? - { - Some(prepared) => { - if req.turn_environment.environment.is_remote() { - return Err(ToolError::Rejected( - "unified_exec zsh-fork is not supported for remote environments" - .to_string(), - )); - } - let process = self - .manager - .open_session_with_prepared_exec_env( - req.process_id, - &prepared.exec_request, - Some(ctx), - windows_sandbox_proxy_settings_mode, - /*network_policy_decider*/ None, - req.tty, - prepared.spawn_lifecycle, - req.turn_environment.environment.as_ref(), - ) - .await - .map_err(|err| match err { - UnifiedExecError::SandboxDenied { output, .. } => { - ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { - output: Box::new(output), - network_policy_decision: None, - })) - } - other => ToolError::Rejected(other.to_string()), - }); - let mut process = with_launch_failure_events(process, req, ctx).await?; - process._shell_snapshot = shell_snapshot; - return Ok(UnifiedExecAttempt { - process, - metrics_sidecar, - permissions, - }); - } - None => { - tracing::warn!( - "UnifiedExec ZshFork backend specified, but conditions for using it were not met, falling back to direct execution", - ); - } - } - } let command = build_unified_exec_sandbox_command( &command, &req.cwd, @@ -767,7 +664,6 @@ impl<'a> ToolRuntime for UnifiedExecRunt req.shell_snapshot.clone(), windows_sandbox_proxy_settings_mode, req.tty, - Box::new(NoopSpawnLifecycle), req.turn_environment.environment.as_ref(), ) .await; @@ -796,7 +692,6 @@ mod tests { use codex_protocol::protocol::EnvironmentConfig; use codex_protocol::protocol::EnvironmentConfigState; use codex_protocol::protocol::TurnEnvironmentSelection; - use codex_tools::ZshForkConfig; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; @@ -858,7 +753,7 @@ mod tests { #[tokio::test] async fn approval_key_includes_environment_id() { let manager = UnifiedExecProcessManager::default(); - let runtime = UnifiedExecRuntime::new(&manager, UnifiedExecShellMode::Direct); + let runtime = UnifiedExecRuntime::new(&manager); let mut request = test_request( SandboxPermissions::UseDefault, ExecApprovalRequirement::Skip { @@ -889,7 +784,7 @@ mod tests { let sandbox_cwd = AbsolutePathBuf::try_from(sandbox_dir.path().to_path_buf()) .expect("absolute sandbox temp dir"); let manager = UnifiedExecProcessManager::default(); - let runtime = UnifiedExecRuntime::new(&manager, UnifiedExecShellMode::Direct); + let runtime = UnifiedExecRuntime::new(&manager); let request = UnifiedExecRequest { command: vec!["pwd".to_string()], shell: ShellInvocation { @@ -912,8 +807,6 @@ mod tests { tty: false, sandbox_permissions: SandboxPermissions::UseDefault, additional_permissions: None, - #[cfg(unix)] - additional_permissions_preapproved: false, justification: None, exec_approval_requirement: ExecApprovalRequirement::Skip { bypass_sandbox: false, @@ -927,72 +820,6 @@ mod tests { ); } - #[tokio::test] - async fn zsh_fork_first_attempt_preserves_parent_sandbox_override() { - let manager = UnifiedExecProcessManager::default(); - let request = test_request( - SandboxPermissions::RequireEscalated, - ExecApprovalRequirement::NeedsApproval { - reason: None, - proposed_execpolicy_amendment: None, - }, - ); - let direct_runtime = UnifiedExecRuntime::new(&manager, UnifiedExecShellMode::Direct); - let zsh_fork_runtime = UnifiedExecRuntime::new(&manager, zsh_fork_mode()); - - assert_eq!( - direct_runtime.sandbox_permissions(&request), - SandboxPermissions::RequireEscalated, - "direct unified exec should preserve a parent require_escalated request" - ); - assert_eq!( - zsh_fork_runtime.sandbox_permissions(&request), - SandboxPermissions::RequireEscalated, - "zsh-fork unified exec should preserve the same parent require_escalated request" - ); - } - - #[tokio::test] - async fn zsh_fork_first_attempt_preserves_additional_permissions_request() { - let manager = UnifiedExecProcessManager::default(); - let request = test_request( - SandboxPermissions::WithAdditionalPermissions, - ExecApprovalRequirement::NeedsApproval { - reason: None, - proposed_execpolicy_amendment: None, - }, - ); - let zsh_fork_runtime = UnifiedExecRuntime::new(&manager, zsh_fork_mode()); - - assert_eq!( - zsh_fork_runtime.sandbox_permissions(&request), - SandboxPermissions::WithAdditionalPermissions, - "zsh-fork unified exec should keep bounded additional-permissions requests sandboxed" - ); - } - - #[tokio::test] - async fn zsh_fork_execpolicy_allow_preserves_parent_sandbox_override() { - let manager = UnifiedExecProcessManager::default(); - let request = test_request( - SandboxPermissions::UseDefault, - ExecApprovalRequirement::Skip { - bypass_sandbox: true, - proposed_execpolicy_amendment: None, - }, - ); - let runtime = UnifiedExecRuntime::new(&manager, zsh_fork_mode()); - - assert_eq!( - runtime.exec_approval_requirement(&request), - Some(ExecApprovalRequirement::Skip { - bypass_sandbox: true, - proposed_execpolicy_amendment: None, - }), - "zsh-fork unified exec should preserve exec-policy allow decisions that bypass the sandbox" - ); - } - fn test_request( sandbox_permissions: SandboxPermissions, exec_approval_requirement: ExecApprovalRequirement, @@ -1021,19 +848,8 @@ mod tests { tty: false, sandbox_permissions, additional_permissions: None, - #[cfg(unix)] - additional_permissions_preapproved: false, justification: None, exec_approval_requirement, } } - - fn zsh_fork_mode() -> UnifiedExecShellMode { - let cwd = std::env::current_dir().expect("read current dir"); - UnifiedExecShellMode::ZshFork(ZshForkConfig { - shell_zsh_path: AbsolutePathBuf::try_from(cwd.join("zsh")).expect("absolute zsh path"), - main_execve_wrapper_exe: AbsolutePathBuf::try_from(cwd.join("execve-wrapper")) - .expect("absolute wrapper path"), - }) - } } diff --git a/codex-rs/core/src/tools/runtimes/unified_exec/snapshot_metrics.rs b/codex-rs/core/src/tools/runtimes/unified_exec/snapshot_metrics.rs index 2f5ff3ecd1..e5b8c8f60a 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec/snapshot_metrics.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec/snapshot_metrics.rs @@ -6,7 +6,6 @@ use std::time::Instant; use codex_features::Feature; use codex_otel::SessionTelemetry; -use codex_tools::UnifiedExecShellMode; use super::UnifiedExecRequest; use crate::config::Config; @@ -17,11 +16,7 @@ pub(super) struct SnapshotMetrics { } impl SnapshotMetrics { - pub(super) fn start( - req: &UnifiedExecRequest, - config: &Config, - shell_mode: &UnifiedExecShellMode, - ) -> Option { + pub(super) fn start(req: &UnifiedExecRequest, config: &Config) -> Option { let brokered = config .permissions .network @@ -33,7 +28,6 @@ impl SnapshotMetrics { || req.shell_snapshot.is_some() || req.turn_environment.environment.is_remote() || !config.features.enabled(Feature::ShellSnapshot) - || !matches!(shell_mode, UnifiedExecShellMode::Direct) || !req.shell.is_posix_login() || (req.cwd != req.turn_environment.selection.cwd && !brokered) { diff --git a/codex-rs/core/src/tools/runtimes/zsh_fork.rs b/codex-rs/core/src/tools/runtimes/zsh_fork.rs deleted file mode 100644 index 4daa0f18b0..0000000000 --- a/codex-rs/core/src/tools/runtimes/zsh_fork.rs +++ /dev/null @@ -1,105 +0,0 @@ -use crate::sandboxing::ExecRequest; -use crate::tools::runtimes::unified_exec::UnifiedExecRequest; -use crate::tools::sandboxing::SandboxAttempt; -use crate::tools::sandboxing::ToolCtx; -use crate::tools::sandboxing::ToolError; -use crate::unified_exec::SpawnLifecycleHandle; -use codex_tools::ZshForkConfig; - -pub(crate) struct PreparedUnifiedExecSpawn { - pub(crate) exec_request: ExecRequest, - pub(crate) spawn_lifecycle: SpawnLifecycleHandle, -} - -/// Prepares unified exec to launch through the zsh-fork backend when the -/// request matches a wrapped `zsh -c/-lc` command on a supported platform. -/// -/// Returns the transformed `ExecRequest` plus a spawn lifecycle that keeps the -/// escalation server alive for the session and performs post-spawn cleanup. -/// Returns `Ok(None)` when unified exec should use its normal spawn path. -pub(crate) async fn maybe_prepare_unified_exec( - req: &UnifiedExecRequest, - attempt: &SandboxAttempt<'_>, - ctx: &ToolCtx, - exec_request: ExecRequest, - zsh_fork_config: &ZshForkConfig, -) -> Result, ToolError> { - imp::maybe_prepare_unified_exec(req, attempt, ctx, exec_request, zsh_fork_config).await -} - -#[cfg(unix)] -#[path = "zsh_fork/unix_escalation.rs"] -mod unix_escalation; - -#[cfg(unix)] -mod imp { - use super::*; - use crate::tools::runtimes::zsh_fork::unix_escalation; - use crate::unified_exec::SpawnLifecycle; - use codex_shell_escalation::ESCALATE_SOCKET_ENV_VAR; - use codex_shell_escalation::EscalationSession; - - #[derive(Debug)] - struct ZshForkSpawnLifecycle { - escalation_session: EscalationSession, - } - - impl SpawnLifecycle for ZshForkSpawnLifecycle { - fn inherited_fds(&self) -> Vec { - self.escalation_session - .env() - .get(ESCALATE_SOCKET_ENV_VAR) - .and_then(|fd| fd.parse().ok()) - .into_iter() - .collect() - } - - fn after_spawn(&mut self) { - self.escalation_session.close_client_socket(); - } - } - - pub(super) async fn maybe_prepare_unified_exec( - req: &UnifiedExecRequest, - attempt: &SandboxAttempt<'_>, - ctx: &ToolCtx, - exec_request: ExecRequest, - zsh_fork_config: &ZshForkConfig, - ) -> Result, ToolError> { - let Some(prepared) = unix_escalation::prepare_unified_exec_zsh_fork( - req, - attempt, - ctx, - exec_request, - zsh_fork_config.shell_zsh_path.as_path(), - zsh_fork_config.main_execve_wrapper_exe.as_path(), - ) - .await? - else { - return Ok(None); - }; - - Ok(Some(PreparedUnifiedExecSpawn { - exec_request: prepared.exec_request, - spawn_lifecycle: Box::new(ZshForkSpawnLifecycle { - escalation_session: prepared.escalation_session, - }), - })) - } -} - -#[cfg(not(unix))] -mod imp { - use super::*; - - pub(super) async fn maybe_prepare_unified_exec( - req: &UnifiedExecRequest, - attempt: &SandboxAttempt<'_>, - ctx: &ToolCtx, - exec_request: ExecRequest, - zsh_fork_config: &ZshForkConfig, - ) -> Result, ToolError> { - let _ = (req, attempt, ctx, exec_request, zsh_fork_config); - Ok(None) - } -} diff --git a/codex-rs/core/src/tools/runtimes/zsh_fork/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/zsh_fork/unix_escalation.rs deleted file mode 100644 index ef7d30e35d..0000000000 --- a/codex-rs/core/src/tools/runtimes/zsh_fork/unix_escalation.rs +++ /dev/null @@ -1,879 +0,0 @@ -use crate::exec::ExecCapturePolicy; -use crate::exec::ExecExpiration; -use crate::guardian::GuardianReviewContext; -use crate::sandboxing::ExecOptions; -use crate::sandboxing::ExecRequest; -use crate::sandboxing::SandboxPermissions; -use crate::tools::approvals::ApprovalAction; -use crate::tools::approvals::ApprovalContext; -use crate::tools::runtimes::exec_env_for_sandbox_permissions; -use crate::tools::sandboxing::SandboxAttempt; -use crate::tools::sandboxing::ToolCtx; -use crate::tools::sandboxing::ToolError; -use crate::tools::sandboxing::unsandboxed_execution_allowed; -use codex_execpolicy::Decision; -use codex_execpolicy::Evaluation; -use codex_execpolicy::MatchOptions; -use codex_execpolicy::Policy; -use codex_execpolicy::RuleMatch; -use codex_prompts::ResolvedModelMessages; -use codex_protocol::config_types::WindowsSandboxLevel; -use codex_protocol::error::CodexErr; -use codex_protocol::models::AdditionalPermissionProfile; -use codex_protocol::models::PermissionProfile; -use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::GuardianCommandSource; -use codex_protocol::protocol::NetworkPolicyRuleAction; -use codex_protocol::protocol::ReviewDecision; -use codex_sandboxing::SandboxCommand; -use codex_sandboxing::SandboxManager; -use codex_sandboxing::SandboxTransformRequest; -use codex_sandboxing::SandboxType; -use codex_sandboxing::SandboxablePreference; -use codex_shell_command::bash::parse_shell_lc_plain_commands; -use codex_shell_escalation::EscalateServer; -use codex_shell_escalation::EscalationDecision; -use codex_shell_escalation::EscalationExecution; -use codex_shell_escalation::EscalationPermissions; -use codex_shell_escalation::EscalationPolicy; -use codex_shell_escalation::EscalationPolicyFuture; -use codex_shell_escalation::EscalationSession; -use codex_shell_escalation::ExecResult; -use codex_shell_escalation::PreparedExec; -use codex_shell_escalation::ResolvedPermissionProfile; -use codex_shell_escalation::ShellCommandExecutor; -use codex_shell_escalation::ShellCommandExecutorFuture; -use codex_shell_escalation::Stopwatch; -use codex_tools::ToolName; -use codex_utils_absolute_path::AbsolutePathBuf; -use codex_utils_path_uri::PathUri; -use std::collections::HashMap; -use std::io; -use std::path::PathBuf; -use std::sync::Arc; -use tokio::sync::RwLock; -use tokio_util::sync::CancellationToken; -use tracing::error; -use uuid::Uuid; - -pub(crate) struct PreparedUnifiedExecZshFork { - pub(crate) exec_request: ExecRequest, - pub(crate) escalation_session: EscalationSession, -} - -const PROMPT_CONFLICT_REASON: &str = - "approval required by policy, but AskForApproval is set to Never"; -const REJECT_SANDBOX_APPROVAL_REASON: &str = - "approval required by policy, but AskForApproval::Granular.sandbox_approval is false"; -const REJECT_RULES_APPROVAL_REASON: &str = - "approval required by policy rule, but AskForApproval::Granular.rules is false"; -fn approval_sandbox_permissions( - sandbox_permissions: SandboxPermissions, - additional_permissions_preapproved: bool, -) -> SandboxPermissions { - if additional_permissions_preapproved - && matches!( - sandbox_permissions, - SandboxPermissions::WithAdditionalPermissions - ) - { - SandboxPermissions::UseDefault - } else { - sandbox_permissions - } -} - -pub(crate) async fn prepare_unified_exec_zsh_fork( - req: &crate::tools::runtimes::unified_exec::UnifiedExecRequest, - attempt: &SandboxAttempt<'_>, - ctx: &ToolCtx, - exec_request: ExecRequest, - shell_zsh_path: &std::path::Path, - main_execve_wrapper_exe: &std::path::Path, -) -> Result, ToolError> { - let parsed = match extract_shell_script(&exec_request.command) { - Ok(parsed) => parsed, - Err(err) => { - tracing::warn!("ZshFork unified exec fallback: {err:?}"); - return Ok(None); - } - }; - if parsed.program != shell_zsh_path.to_string_lossy() { - tracing::warn!( - "ZshFork backend specified, but unified exec command targets `{}` instead of `{}`.", - parsed.program, - shell_zsh_path.display(), - ); - return Ok(None); - } - - let exec_policy = Arc::new(RwLock::new( - ctx.session - .services - .exec_policy - .current_for_environment( - req.turn_environment.config().exec_policy.as_ref(), - ctx.step_context.turn.allow_prefix_rules(), - ) - .as_ref() - .clone(), - )); - // TODO(anp): Keep PathUri through the zsh-fork executor boundary. - let cwd = exec_request - .cwd - .to_abs_path() - .map_err(|err| ToolError::Rejected(err.to_string()))?; - // TODO(anp): Keep PathUri through the zsh-fork sandbox policy boundary. - let sandbox_policy_cwd = exec_request - .windows_sandbox_policy_cwd - .to_abs_path() - .map_err(|err| ToolError::Rejected(err.to_string()))?; - let command_executor = CoreShellCommandExecutor { - sandbox_manager: attempt.manager.clone(), - command: exec_request.command.clone(), - cwd, - permission_profile: exec_request.permission_profile.clone(), - sandbox: exec_request.sandbox, - env: exec_request.env.clone(), - network: exec_request.network.clone(), - network_environment_id: exec_request.network_environment_id.clone(), - windows_sandbox_level: exec_request.windows_sandbox_level, - arg0: exec_request.arg0.clone(), - sandbox_policy_cwd, - windows_sandbox_workspace_roots: exec_request.windows_sandbox_workspace_roots.clone(), - codex_linux_sandbox_exe: ctx.step_context.turn.config.codex_linux_sandbox_exe.clone(), - use_legacy_landlock: req.turn_environment.config().use_legacy_landlock, - }; - let escalation_policy = CoreShellActionProvider { - policy: Arc::clone(&exec_policy), - session: Arc::clone(&ctx.session), - review_context: GuardianReviewContext::from(&ctx.step_context), - call_id: ctx.call_id.clone(), - environment_id: req.turn_environment.selection.environment_id.clone(), - source: GuardianCommandSource::UnifiedExec, - tool_name: ctx.tool_name.clone(), - approval_policy: ctx.step_context.settings.approval_policy(), - permission_profile: exec_request.permission_profile.clone(), - sandbox_permissions: req.sandbox_permissions, - approval_sandbox_permissions: approval_sandbox_permissions( - req.sandbox_permissions, - req.additional_permissions_preapproved, - ), - prompt_permissions: req.additional_permissions.clone(), - stopwatch: Stopwatch::unlimited(), - }; - - let escalate_server = EscalateServer::new( - shell_zsh_path.to_path_buf(), - main_execve_wrapper_exe.to_path_buf(), - escalation_policy, - ); - let escalation_session = escalate_server - .start_session(CancellationToken::new(), Arc::new(command_executor)) - .map_err(|err| ToolError::Rejected(err.to_string()))?; - let mut exec_request = exec_request; - exec_request.env.extend(escalation_session.env().clone()); - Ok(Some(PreparedUnifiedExecZshFork { - exec_request, - escalation_session, - })) -} - -struct CoreShellActionProvider { - policy: Arc>, - session: Arc, - review_context: GuardianReviewContext, - call_id: String, - environment_id: String, - source: GuardianCommandSource, - tool_name: ToolName, - approval_policy: AskForApproval, - permission_profile: PermissionProfile, - sandbox_permissions: SandboxPermissions, - approval_sandbox_permissions: SandboxPermissions, - prompt_permissions: Option, - stopwatch: Stopwatch, -} - -#[allow(clippy::large_enum_variant)] -enum DecisionSource { - PrefixRule, - /// Derived from the unmatched-command sandbox fallback. - UnmatchedCommandFallback, -} - -fn execve_prompt_is_rejected_by_policy( - approval_policy: AskForApproval, - decision_source: &DecisionSource, -) -> Option<&'static str> { - match (approval_policy, decision_source) { - (AskForApproval::Never, _) => Some(PROMPT_CONFLICT_REASON), - (AskForApproval::Granular(granular_config), DecisionSource::PrefixRule) - if !granular_config.allows_rules_approval() => - { - Some(REJECT_RULES_APPROVAL_REASON) - } - (AskForApproval::Granular(granular_config), DecisionSource::UnmatchedCommandFallback) - if !granular_config.allows_sandbox_approval() => - { - Some(REJECT_SANDBOX_APPROVAL_REASON) - } - _ => None, - } -} - -impl CoreShellActionProvider { - fn decision_driven_by_policy(matched_rules: &[RuleMatch], decision: Decision) -> bool { - matched_rules.iter().any(|rule_match| { - !matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. }) - && rule_match.decision() == decision - }) - } - - fn shell_request_escalation_execution( - sandbox_permissions: SandboxPermissions, - permission_profile: &PermissionProfile, - additional_permissions: Option<&AdditionalPermissionProfile>, - ) -> EscalationExecution { - match sandbox_permissions { - SandboxPermissions::UseDefault => EscalationExecution::TurnDefault, - SandboxPermissions::RequireEscalated => { - if unsandboxed_execution_allowed(&permission_profile.file_system_sandbox_policy()) { - EscalationExecution::Unsandboxed - } else { - EscalationExecution::TurnDefault - } - } - SandboxPermissions::WithAdditionalPermissions => additional_permissions - .map(|_| { - // Shell request additional permissions were already normalized and - // merged into the first-attempt sandbox policy. - EscalationExecution::Permissions( - EscalationPermissions::ResolvedPermissionProfile( - ResolvedPermissionProfile { - permission_profile: permission_profile.clone(), - }, - ), - ) - }) - .unwrap_or(EscalationExecution::TurnDefault), - } - } - - async fn prompt( - &self, - program: &AbsolutePathBuf, - argv: &[String], - workdir: &AbsolutePathBuf, - stopwatch: &Stopwatch, - additional_permissions: Option, - ) -> anyhow::Result { - let command = join_program_and_argv(program, argv); - let action = ApprovalAction::Execve { - id: self.call_id.clone(), - approval_id: Uuid::new_v4().to_string(), - environment_id: self.environment_id.clone(), - source: self.source, - program: program.clone(), - argv: argv.to_vec(), - command, - cwd: workdir.clone(), - additional_permissions, - }; - match stopwatch - .pause_for(async { - let (turn_context, settings, _, strict_auto_review) = self - .session - .active_turn_context_and_strict_auto_review() - .await - .ok_or_else(|| { - ToolError::Rejected( - "cannot approve intercepted execution without an active turn" - .to_string(), - ) - })?; - let approval_ctx = ApprovalContext { - review_context: GuardianReviewContext::from_resolved_settings( - Arc::clone(&turn_context), - &settings, - self.review_context.environments(), - ), - // The running process can outlive its launching tool or code-mode cell. - cancellation_token: None, - call_id: self.call_id.clone(), - tool_name: self.tool_name.clone(), - strict_auto_review, - approval_reason: None, - retry_reason: None, - network_approval_context: None, - }; - self.session.request_approval(action, approval_ctx).await - }) - .await - { - Ok(decision) => Ok(decision), - Err(ToolError::Rejected(rejection)) => Ok(ReviewDecision::denied(rejection)), - Err(ToolError::Codex(err)) => Err(err.into()), - } - } - - #[allow(clippy::too_many_arguments)] - async fn process_decision( - &self, - decision: Decision, - needs_escalation: bool, - program: &AbsolutePathBuf, - argv: &[String], - workdir: &AbsolutePathBuf, - prompt_permissions: Option, - escalation_execution: EscalationExecution, - decision_source: DecisionSource, - ) -> anyhow::Result { - let action = match decision { - Decision::Forbidden => { - EscalationDecision::deny(Some("Execution forbidden by policy".to_string())) - } - Decision::Prompt => { - if execve_prompt_is_rejected_by_policy(self.approval_policy, &decision_source) - .is_some() - { - EscalationDecision::deny(Some("Execution forbidden by policy".to_string())) - } else { - let decision = self - .prompt(program, argv, workdir, &self.stopwatch, prompt_permissions) - .await?; - match decision { - ReviewDecision::Approved - | ReviewDecision::ApprovedForSession - | ReviewDecision::ApprovedExecpolicyAmendment { .. } => { - if needs_escalation { - EscalationDecision::escalate(escalation_execution.clone()) - } else { - EscalationDecision::run() - } - } - ReviewDecision::NetworkPolicyAmendment { - network_policy_amendment, - } => match network_policy_amendment.action { - NetworkPolicyRuleAction::Allow => { - if needs_escalation { - EscalationDecision::escalate(escalation_execution.clone()) - } else { - EscalationDecision::run() - } - } - NetworkPolicyRuleAction::Deny => { - EscalationDecision::deny(Some("User denied execution".to_string())) - } - }, - ReviewDecision::Denied { rejection } => { - EscalationDecision::deny(Some(rejection)) - } - ReviewDecision::TimedOut => EscalationDecision::deny(Some( - ResolvedModelMessages::from_model( - self.review_context.turn().model_info(), - ) - .auto_review() - .timeout_instructions - .to_string(), - )), - ReviewDecision::ApprovedMcpPolicyAmendment => { - error!("Shell escalation received ApprovedMcpPolicyAmendment"); - - EscalationDecision::deny(Some( - "Error while requesting approval".to_string(), - )) - } - ReviewDecision::Abort => { - EscalationDecision::deny(Some("User cancelled execution".to_string())) - } - } - } - } - Decision::Allow => { - if needs_escalation { - EscalationDecision::escalate(escalation_execution) - } else { - EscalationDecision::run() - } - } - }; - tracing::debug!( - "Policy decision for command {program:?} is {decision:?}, leading to escalation action {action:?}", - ); - Ok(action) - } -} - -// Shell-wrapper parsing is weaker than direct exec interception because it can -// only see the script text, not the final resolved executable path. Keep it -// disabled by default so path-sensitive rules rely on the later authoritative -// execve interception. -const ENABLE_INTERCEPTED_EXEC_POLICY_SHELL_WRAPPER_PARSING: bool = false; - -impl CoreShellActionProvider { - async fn determine_action( - &self, - program: &AbsolutePathBuf, - argv: &[String], - workdir: &AbsolutePathBuf, - ) -> anyhow::Result { - tracing::debug!( - "Determining escalation action for command {program:?} with args {argv:?} in {workdir:?}" - ); - - let evaluation = { - let policy = self.policy.read().await; - evaluate_intercepted_exec_policy( - &policy, - program, - argv, - InterceptedExecPolicyContext { - approval_policy: self.approval_policy, - permission_profile: self.permission_profile.clone(), - windows_sandbox_level: self.review_context.turn().windows_sandbox_level, - sandbox_permissions: self.approval_sandbox_permissions, - enable_shell_wrapper_parsing: - ENABLE_INTERCEPTED_EXEC_POLICY_SHELL_WRAPPER_PARSING, - }, - ) - }; - // When true, means the Evaluation was due to *.rules, not the - // fallback function. - let decision_driven_by_policy = - Self::decision_driven_by_policy(&evaluation.matched_rules, evaluation.decision); - let unsandboxed_allowed = - unsandboxed_execution_allowed(&self.permission_profile.file_system_sandbox_policy()); - let needs_escalation = match self.sandbox_permissions { - SandboxPermissions::UseDefault => unsandboxed_allowed && decision_driven_by_policy, - SandboxPermissions::RequireEscalated => unsandboxed_allowed, - SandboxPermissions::WithAdditionalPermissions => true, - }; - - let decision_source = if decision_driven_by_policy { - DecisionSource::PrefixRule - } else { - DecisionSource::UnmatchedCommandFallback - }; - let escalation_execution = match decision_source { - DecisionSource::PrefixRule if unsandboxed_allowed => EscalationExecution::Unsandboxed, - DecisionSource::PrefixRule => EscalationExecution::TurnDefault, - DecisionSource::UnmatchedCommandFallback => Self::shell_request_escalation_execution( - self.sandbox_permissions, - &self.permission_profile, - self.prompt_permissions.as_ref(), - ), - }; - self.process_decision( - evaluation.decision, - needs_escalation, - program, - argv, - workdir, - self.prompt_permissions.clone(), - escalation_execution, - decision_source, - ) - .await - } -} - -impl EscalationPolicy for CoreShellActionProvider { - fn determine_action<'a>( - &'a self, - program: &'a AbsolutePathBuf, - argv: &'a [String], - workdir: &'a AbsolutePathBuf, - ) -> EscalationPolicyFuture<'a> { - Box::pin(CoreShellActionProvider::determine_action( - self, program, argv, workdir, - )) - } -} - -fn evaluate_intercepted_exec_policy( - policy: &Policy, - program: &AbsolutePathBuf, - argv: &[String], - context: InterceptedExecPolicyContext, -) -> Evaluation { - let InterceptedExecPolicyContext { - approval_policy, - permission_profile, - windows_sandbox_level, - sandbox_permissions, - enable_shell_wrapper_parsing, - } = context; - let commands = if enable_shell_wrapper_parsing { - // In this codepath, the first argument in `commands` could be a bare - // name like `find` instead of an absolute path like `/usr/bin/find`. - // It could also be a shell built-in like `echo`. - commands_for_intercepted_exec_policy(program, argv) - } else { - // In this codepath, `commands` has a single entry where the program - // is always an absolute path. - vec![join_program_and_argv(program, argv)] - }; - - let fallback = |cmd: &[String]| { - crate::exec_policy::render_decision_for_unmatched_command( - cmd, - crate::exec_policy::UnmatchedCommandContext { - approval_policy, - permission_profile: &permission_profile, - windows_sandbox_level, - sandbox_permissions, - command_origin: crate::exec_policy::ExecPolicyCommandOrigin::Generic, - }, - ) - }; - - policy.check_multiple_with_options( - commands.iter(), - &fallback, - &MatchOptions { - resolve_host_executables: true, - }, - ) -} - -#[derive(Clone)] -struct InterceptedExecPolicyContext { - approval_policy: AskForApproval, - permission_profile: PermissionProfile, - // TODO(anp): Reconcile this policy input with TurnEnvironment::sandbox_context - // so intercepted commands use the selected environment's Windows backend. - windows_sandbox_level: WindowsSandboxLevel, - sandbox_permissions: SandboxPermissions, - enable_shell_wrapper_parsing: bool, -} - -fn commands_for_intercepted_exec_policy( - program: &AbsolutePathBuf, - argv: &[String], -) -> Vec> { - if let [_, flag, script] = argv { - let shell_command = [ - program.to_string_lossy().to_string(), - flag.clone(), - script.clone(), - ]; - if let Some(commands) = parse_shell_lc_plain_commands(&shell_command) - && !commands.is_empty() - { - return commands; - } - } - - vec![join_program_and_argv(program, argv)] -} - -// TODO(anp): Capture these Windows and Landlock settings from -// TurnEnvironment::sandbox_context when preparing this executor, preserving its snapshot. -struct CoreShellCommandExecutor { - sandbox_manager: SandboxManager, - command: Vec, - cwd: AbsolutePathBuf, - permission_profile: PermissionProfile, - sandbox: SandboxType, - env: HashMap, - network: Option, - network_environment_id: Option, - windows_sandbox_level: WindowsSandboxLevel, - arg0: Option, - sandbox_policy_cwd: AbsolutePathBuf, - windows_sandbox_workspace_roots: Vec, - codex_linux_sandbox_exe: Option, - use_legacy_landlock: bool, -} - -struct PrepareSandboxedExecParams<'a> { - command: Vec, - workdir: &'a AbsolutePathBuf, - env: HashMap, - permission_profile: &'a PermissionProfile, - additional_permissions: Option, -} - -impl ShellCommandExecutor for CoreShellCommandExecutor { - fn run( - &self, - _command: Vec, - _cwd: PathBuf, - env_overlay: HashMap, - cancel_rx: CancellationToken, - after_spawn: Option>, - ) -> ShellCommandExecutorFuture<'_, ExecResult> { - Box::pin(CoreShellCommandExecutor::run( - self, - env_overlay, - cancel_rx, - after_spawn, - )) - } - - fn prepare_escalated_exec<'a>( - &'a self, - program: &'a AbsolutePathBuf, - argv: &'a [String], - workdir: &'a AbsolutePathBuf, - env: HashMap, - execution: EscalationExecution, - ) -> ShellCommandExecutorFuture<'a, PreparedExec> { - Box::pin(CoreShellCommandExecutor::prepare_escalated_exec( - self, program, argv, workdir, env, execution, - )) - } -} - -impl CoreShellCommandExecutor { - async fn run( - &self, - env_overlay: HashMap, - cancel_rx: CancellationToken, - after_spawn: Option>, - ) -> anyhow::Result { - let mut exec_env = self.env.clone(); - // `env_overlay` comes from `EscalationSession::env()`, so merge only the - // wrapper/socket variables into the base shell environment. - for var in ["CODEX_ESCALATE_SOCKET", "EXEC_WRAPPER"] { - if let Some(value) = env_overlay.get(var) { - exec_env.insert(var.to_string(), value.clone()); - } - } - - let result = crate::sandboxing::execute_exec_request_with_after_spawn( - crate::sandboxing::ExecRequest { - command: self.command.clone(), - cwd: self.cwd.clone().into(), - env: exec_env, - exec_server_env_config: None, - exec_server_shell_snapshot: None, - network: self.network.clone(), - network_environment_id: self.network_environment_id.clone(), - expiration: ExecExpiration::Cancellation(cancel_rx), - capture_policy: ExecCapturePolicy::ShellTool, - sandbox: self.sandbox, - windows_sandbox_policy_cwd: self.sandbox_policy_cwd.clone().into(), - windows_sandbox_workspace_roots: self.windows_sandbox_workspace_roots.clone(), - windows_sandbox_level: self.windows_sandbox_level, - permission_profile: self.permission_profile.clone(), - windows_sandbox_filesystem_overrides: None, - arg0: self.arg0.clone(), - exec_server_sandbox: None, - exec_server_enforce_managed_network: false, - exec_server_managed_network: None, - exec_server_network_proxy: None, - }, - /*stdout_stream*/ None, - after_spawn, - ) - .await?; - - Ok(ExecResult { - exit_code: result.exit_code, - stdout: result.stdout.text, - stderr: result.stderr.text, - output: result.aggregated_output.text, - duration: result.duration, - timed_out: result.timed_out, - }) - } - - async fn prepare_escalated_exec( - &self, - program: &AbsolutePathBuf, - argv: &[String], - workdir: &AbsolutePathBuf, - mut env: HashMap, - execution: EscalationExecution, - ) -> anyhow::Result { - let command = join_program_and_argv(program, argv); - let Some(first_arg) = argv.first() else { - return Err(anyhow::anyhow!( - "intercepted exec request must contain argv[0]" - )); - }; - - let prepared = match execution { - EscalationExecution::Unsandboxed => { - if let Some(network) = self.network.as_ref() { - network.restore_brokered_credentials(&mut env, &mut []); - } - PreparedExec { - command, - cwd: workdir.to_path_buf(), - env: exec_env_for_sandbox_permissions( - &env, - SandboxPermissions::RequireEscalated, - ), - arg0: Some(first_arg.clone()), - } - } - EscalationExecution::TurnDefault => { - self.prepare_sandboxed_exec(PrepareSandboxedExecParams { - command, - workdir, - env, - permission_profile: &self.permission_profile, - additional_permissions: None, - }) - .await? - } - EscalationExecution::Permissions( - EscalationPermissions::AdditionalPermissionProfile(permission_profile), - ) => { - // Merge additive permissions into the existing turn/request sandbox policy. - self.prepare_sandboxed_exec(PrepareSandboxedExecParams { - command, - workdir, - env, - permission_profile: &self.permission_profile, - additional_permissions: Some(permission_profile), - }) - .await? - } - EscalationExecution::Permissions(EscalationPermissions::ResolvedPermissionProfile( - permissions, - )) => { - // Use a fully specified permission profile instead of merging into the turn policy. - self.prepare_sandboxed_exec(PrepareSandboxedExecParams { - command, - workdir, - env, - permission_profile: &permissions.permission_profile, - additional_permissions: None, - }) - .await? - } - }; - - Ok(prepared) - } - - #[allow(clippy::too_many_arguments)] - async fn prepare_sandboxed_exec( - &self, - params: PrepareSandboxedExecParams<'_>, - ) -> anyhow::Result { - let PrepareSandboxedExecParams { - command, - workdir, - env, - permission_profile, - additional_permissions, - } = params; - let (program, args) = command - .split_first() - .ok_or_else(|| anyhow::anyhow!("prepared command must not be empty"))?; - let sandbox_manager = &self.sandbox_manager; - let sandbox = sandbox_manager.select_initial( - permission_profile, - SandboxablePreference::Auto, - SandboxType::None, - self.network.is_some(), - ); - let cwd = PathUri::from_abs_path(workdir); - let sandbox_policy_cwd = PathUri::from_abs_path(&self.sandbox_policy_cwd); - let command = SandboxCommand { - program: program.clone().into(), - args: args.to_vec(), - cwd, - env, - managed_network: None, - additional_permissions, - }; - let options = ExecOptions { - expiration: ExecExpiration::DefaultTimeout, - capture_policy: ExecCapturePolicy::ShellTool, - }; - let exec_request = sandbox_manager.transform(SandboxTransformRequest { - command, - permissions: permission_profile, - sandbox, - enforce_managed_network: self.network.is_some(), - environment_id: self.network_environment_id.as_deref(), - network: self.network.as_ref(), - sandbox_policy_cwd: &sandbox_policy_cwd, - sandbox_exe: self.codex_linux_sandbox_exe.as_deref(), - use_legacy_landlock: self.use_legacy_landlock, - windows_sandbox_level: self.windows_sandbox_level, - })?; - let mut exec_request = crate::sandboxing::ExecRequest::from_sandbox_exec_request( - exec_request, - options, - self.windows_sandbox_workspace_roots.clone(), - ) - .await?; - if let Some(network) = exec_request.network.as_ref() { - network - .apply_to_env_for_optional_environment( - &mut exec_request.env, - self.network_environment_id.as_deref(), - ) - .map_err(|err| { - let environment_id = - self.network_environment_id.as_deref().unwrap_or("default"); - CodexErr::Io(io::Error::other(format!( - "failed to prepare network proxy for environment `{environment_id}`: {err}" - ))) - })?; - } - - Ok(PreparedExec { - command: exec_request.command, - // TODO(anp): Keep PathUri through the execve-wrapper boundary. - cwd: exec_request.cwd.to_abs_path()?.to_path_buf(), - env: exec_request.env, - arg0: exec_request.arg0, - }) - } -} - -#[derive(Debug, Eq, PartialEq)] -struct ParsedShellCommand { - program: String, - script: String, - login: bool, -} - -fn extract_shell_script(command: &[String]) -> Result { - // Commands reaching zsh-fork can be wrapped by environment/sandbox helpers, so - // we search for the first `-c`/`-lc` triple anywhere in the argv rather - // than assuming it is the first positional form. - if let Some((program, script, login)) = command.windows(3).find_map(|parts| match parts { - [program, flag, script] if flag == "-c" => { - Some((program.to_owned(), script.to_owned(), false)) - } - [program, flag, script] if flag == "-lc" => { - Some((program.to_owned(), script.to_owned(), true)) - } - _ => None, - }) { - return Ok(ParsedShellCommand { - program, - script, - login, - }); - } - - Err(ToolError::Rejected( - "unexpected shell command format for zsh-fork execution".to_string(), - )) -} - -/// Convert an intercepted exec `(program, argv)` into a command vector suitable -/// for display and policy parsing. -/// -/// The intercepted `argv` includes `argv[0]`, but once we have normalized the -/// executable path in `program`, we should replace the original `argv[0]` -/// rather than duplicating it as an apparent user argument. -fn join_program_and_argv(program: &AbsolutePathBuf, argv: &[String]) -> Vec { - std::iter::once(program.to_string_lossy().to_string()) - .chain(argv.iter().skip(1).cloned()) - .collect::>() -} - -#[cfg(test)] -#[path = "unix_escalation_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/tools/runtimes/zsh_fork/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/zsh_fork/unix_escalation_tests.rs deleted file mode 100644 index ca282cd696..0000000000 --- a/codex-rs/core/src/tools/runtimes/zsh_fork/unix_escalation_tests.rs +++ /dev/null @@ -1,967 +0,0 @@ -use super::CoreShellActionProvider; -use super::CoreShellCommandExecutor; -use super::InterceptedExecPolicyContext; -use super::ParsedShellCommand; -use super::commands_for_intercepted_exec_policy; -use super::evaluate_intercepted_exec_policy; -use super::extract_shell_script; -use super::join_program_and_argv; -use crate::config::Constrained; -use crate::guardian::GuardianReviewContext; -use crate::sandboxing::SandboxPermissions; -use crate::session::tests::make_session_and_context; -use crate::tools::runtimes::tests::test_credential_broker_network_proxy; -use anyhow::Context; -use codex_execpolicy::Decision; -use codex_execpolicy::Evaluation; -use codex_execpolicy::PolicyParser; -use codex_execpolicy::RuleMatch; -use codex_hooks::HooksConfig; -use codex_network_proxy::PROXY_ACTIVE_ENV_KEY; -use codex_network_proxy::PROXY_ENV_KEYS; -use codex_protocol::config_types::WindowsSandboxLevel; -use codex_protocol::models::AdditionalPermissionProfile; -use codex_protocol::models::FileSystemPermissions; -use codex_protocol::models::PermissionProfile; -use codex_protocol::permissions::FileSystemAccessMode; -use codex_protocol::permissions::FileSystemPath; -use codex_protocol::permissions::FileSystemSandboxEntry; -use codex_protocol::permissions::FileSystemSandboxPolicy; -use codex_protocol::permissions::FileSystemSpecialPath; -use codex_protocol::permissions::NetworkSandboxPolicy; -use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::GranularApprovalConfig; -use codex_protocol::protocol::GuardianCommandSource; -use codex_sandboxing::SandboxType; -use codex_sandboxing::policy_transforms::effective_permission_profile; -use codex_shell_escalation::EscalationExecution; -use codex_shell_escalation::EscalationPermissions; -use codex_shell_escalation::ResolvedPermissionProfile; -use codex_tools::ToolName; -use codex_utils_absolute_path::AbsolutePathBuf; -use pretty_assertions::assert_eq; -use serde_json::Value; -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::Duration; -use tokio::sync::RwLock; - -fn host_absolute_path(segments: &[&str]) -> String { - let mut path = if cfg!(windows) { - PathBuf::from(r"C:\") - } else { - PathBuf::from("/") - }; - for segment in segments { - path.push(segment); - } - path.to_string_lossy().into_owned() -} - -fn starlark_string(value: &str) -> String { - value.replace('\\', "\\\\").replace('"', "\\\"") -} - -fn read_only_file_system_sandbox_policy() -> FileSystemSandboxPolicy { - FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - missing_path_behavior: None, - }]) -} - -fn denied_read_file_system_sandbox_policy() -> FileSystemSandboxPolicy { - FileSystemSandboxPolicy::restricted(vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - missing_path_behavior: None, - }, - FileSystemSandboxEntry { - path: FileSystemPath::GlobPattern { - pattern: "**/*.env".to_string(), - }, - access: FileSystemAccessMode::Deny, - missing_path_behavior: None, - }, - ]) -} - -fn test_sandbox_cwd() -> AbsolutePathBuf { - AbsolutePathBuf::try_from(host_absolute_path(&["workspace"])).unwrap() -} - -#[test] -fn execve_prompt_rejection_keeps_prefix_rules_on_rules_flag() { - assert_eq!( - super::execve_prompt_is_rejected_by_policy( - AskForApproval::Granular(GranularApprovalConfig { - sandbox_approval: true, - rules: false, - skill_approval: true, - request_permissions: true, - mcp_elicitations: true, - }), - &super::DecisionSource::PrefixRule, - ), - Some("approval required by policy rule, but AskForApproval::Granular.rules is false"), - ); -} - -#[test] -fn execve_prompt_rejection_keeps_unmatched_commands_on_sandbox_flag() { - assert_eq!( - super::execve_prompt_is_rejected_by_policy( - AskForApproval::Granular(GranularApprovalConfig { - sandbox_approval: false, - rules: true, - skill_approval: true, - request_permissions: true, - mcp_elicitations: true, - }), - &super::DecisionSource::UnmatchedCommandFallback, - ), - Some("approval required by policy, but AskForApproval::Granular.sandbox_approval is false"), - ); -} - -#[test] -fn approval_sandbox_permissions_only_downgrades_preapproved_additional_permissions() { - assert_eq!( - super::approval_sandbox_permissions( - SandboxPermissions::WithAdditionalPermissions, - /*additional_permissions_preapproved*/ true - ), - SandboxPermissions::UseDefault, - ); - assert_eq!( - super::approval_sandbox_permissions( - SandboxPermissions::WithAdditionalPermissions, - /*additional_permissions_preapproved*/ false - ), - SandboxPermissions::WithAdditionalPermissions, - ); - assert_eq!( - super::approval_sandbox_permissions( - SandboxPermissions::RequireEscalated, - /*additional_permissions_preapproved*/ true - ), - SandboxPermissions::RequireEscalated, - ); -} - -#[test] -fn extract_shell_script_preserves_login_flag() { - assert_eq!( - extract_shell_script(&["/bin/zsh".into(), "-lc".into(), "echo hi".into()]).unwrap(), - ParsedShellCommand { - program: "/bin/zsh".to_string(), - script: "echo hi".to_string(), - login: true, - } - ); - assert_eq!( - extract_shell_script(&["/bin/zsh".into(), "-c".into(), "echo hi".into()]).unwrap(), - ParsedShellCommand { - program: "/bin/zsh".to_string(), - script: "echo hi".to_string(), - login: false, - } - ); -} - -#[test] -fn extract_shell_script_supports_wrapped_command_prefixes() { - assert_eq!( - extract_shell_script(&[ - "/usr/bin/env".into(), - "CODEX_EXECVE_WRAPPER=1".into(), - "/bin/zsh".into(), - "-lc".into(), - "echo hello".into() - ]) - .unwrap(), - ParsedShellCommand { - program: "/bin/zsh".to_string(), - script: "echo hello".to_string(), - login: true, - } - ); - - assert_eq!( - extract_shell_script(&[ - "sandbox-exec".into(), - "-p".into(), - "sandbox_policy".into(), - "/bin/zsh".into(), - "-c".into(), - "pwd".into(), - ]) - .unwrap(), - ParsedShellCommand { - program: "/bin/zsh".to_string(), - script: "pwd".to_string(), - login: false, - } - ); -} - -#[test] -fn extract_shell_script_rejects_unsupported_shell_invocation() { - let err = extract_shell_script(&[ - "sandbox-exec".into(), - "-fc".into(), - "echo not supported".into(), - ]) - .unwrap_err(); - assert!(matches!(err, super::ToolError::Rejected(_))); - assert_eq!( - match err { - super::ToolError::Rejected(reason) => reason, - _ => "".to_string(), - }, - "unexpected shell command format for zsh-fork execution" - ); -} - -#[test] -fn join_program_and_argv_replaces_original_argv_zero() { - assert_eq!( - join_program_and_argv( - &AbsolutePathBuf::from_absolute_path("/tmp/tool").unwrap(), - &["./tool".into(), "--flag".into(), "value".into()], - ), - vec!["/tmp/tool", "--flag", "value"] - ); - assert_eq!( - join_program_and_argv( - &AbsolutePathBuf::from_absolute_path("/tmp/tool").unwrap(), - &["./tool".into()] - ), - vec!["/tmp/tool"] - ); -} - -#[test] -fn commands_for_intercepted_exec_policy_parses_plain_shell_wrappers() { - let program = AbsolutePathBuf::try_from(host_absolute_path(&["bin", "bash"])).unwrap(); - let candidate_commands = commands_for_intercepted_exec_policy( - &program, - &["not-bash".into(), "-lc".into(), "git status && pwd".into()], - ); - - assert_eq!( - candidate_commands, - vec![ - vec!["git".to_string(), "status".to_string()], - vec!["pwd".to_string()], - ] - ); -} - -#[test] -fn commands_for_intercepted_exec_policy_preserves_unparsed_shell_wrappers() { - let program = AbsolutePathBuf::try_from(host_absolute_path(&["bin", "bash"])).unwrap(); - for script in ["", " \n\t", "cat <<'EOF'\nhello\nEOF"] { - let argv = ["not-bash".into(), "-lc".into(), script.into()]; - assert_eq!( - commands_for_intercepted_exec_policy(&program, &argv), - vec![join_program_and_argv(&program, &argv)] - ); - } -} - -#[test] -fn shell_request_escalation_execution_is_explicit() { - let requested_permissions = AdditionalPermissionProfile { - file_system: Some(FileSystemPermissions::from_read_write_roots( - /*read*/ None, - Some(vec![ - AbsolutePathBuf::from_absolute_path("/tmp/output").unwrap(), - ]), - )), - ..Default::default() - }; - let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Path { - path: AbsolutePathBuf::from_absolute_path("/tmp/original/output") - .unwrap() - .into(), - }, - access: FileSystemAccessMode::Write, - missing_path_behavior: None, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Path { - path: AbsolutePathBuf::from_absolute_path("/tmp/secret") - .unwrap() - .into(), - }, - access: FileSystemAccessMode::Deny, - missing_path_behavior: None, - }, - ]); - let network_sandbox_policy = NetworkSandboxPolicy::Restricted; - let permission_profile = PermissionProfile::from_runtime_permissions( - &file_system_sandbox_policy, - network_sandbox_policy, - ); - let read_only_permission_profile = PermissionProfile::from_runtime_permissions( - &read_only_file_system_sandbox_policy(), - network_sandbox_policy, - ); - - assert_eq!( - CoreShellActionProvider::shell_request_escalation_execution( - crate::sandboxing::SandboxPermissions::UseDefault, - &permission_profile, - /*additional_permissions*/ None, - ), - EscalationExecution::TurnDefault, - ); - assert_eq!( - CoreShellActionProvider::shell_request_escalation_execution( - crate::sandboxing::SandboxPermissions::RequireEscalated, - &read_only_permission_profile, - /*additional_permissions*/ None, - ), - EscalationExecution::Unsandboxed, - ); - assert_eq!( - CoreShellActionProvider::shell_request_escalation_execution( - crate::sandboxing::SandboxPermissions::RequireEscalated, - &permission_profile, - /*additional_permissions*/ None, - ), - EscalationExecution::TurnDefault, - ); - assert_eq!( - CoreShellActionProvider::shell_request_escalation_execution( - crate::sandboxing::SandboxPermissions::WithAdditionalPermissions, - &permission_profile, - Some(&requested_permissions), - ), - EscalationExecution::Permissions(EscalationPermissions::ResolvedPermissionProfile( - ResolvedPermissionProfile { permission_profile }, - )), - ); -} - -#[tokio::test] -async fn unsandboxed_intercepted_exec_strips_managed_network_env() -> anyhow::Result<()> { - let workdir = test_sandbox_cwd(); - let network = test_credential_broker_network_proxy().await?; - let executor = CoreShellCommandExecutor { - sandbox_manager: codex_sandboxing::SandboxManager::new(), - command: Vec::new(), - cwd: workdir.clone(), - permission_profile: PermissionProfile::workspace_write(), - sandbox: SandboxType::None, - env: HashMap::new(), - network: Some(network.clone()), - network_environment_id: None, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - arg0: None, - sandbox_policy_cwd: workdir.clone(), - windows_sandbox_workspace_roots: vec![workdir.clone()], - codex_linux_sandbox_exe: None, - use_legacy_landlock: false, - }; - let mut env = HashMap::new(); - env.insert("GH_TOKEN".to_string(), "ghp-real".to_string()); - env.insert(PROXY_ACTIVE_ENV_KEY.to_string(), "1".to_string()); - for key in PROXY_ENV_KEYS { - env.insert((*key).to_string(), format!("proxy-{key}")); - } - network.apply_to_env(&mut env); - assert_ne!(env.get("GH_TOKEN").map(String::as_str), Some("ghp-real")); - let dummy = env.get("GH_TOKEN").expect("brokered token").clone(); - - let prepared = executor - .prepare_escalated_exec( - &AbsolutePathBuf::from_absolute_path("/usr/bin/curl")?, - &[ - "curl".to_string(), - "-H".to_string(), - format!("Authorization: Bearer {dummy}"), - "example.com".to_string(), - ], - &workdir, - env, - EscalationExecution::Unsandboxed, - ) - .await?; - - assert!( - !prepared.env.contains_key(PROXY_ACTIVE_ENV_KEY), - "unsandboxed intercepted exec should strip the managed-network active marker" - ); - for key in PROXY_ENV_KEYS { - assert!( - !prepared.env.contains_key(*key), - "unsandboxed intercepted exec should strip managed-network proxy env var {key}" - ); - } - assert_eq!( - prepared.env.get("GH_TOKEN").map(String::as_str), - Some("ghp-real") - ); - assert_eq!( - prepared.command, - vec![ - "/usr/bin/curl".to_string(), - "-H".to_string(), - format!("Authorization: Bearer {dummy}"), - "example.com".to_string(), - ] - ); - - Ok(()) -} - -#[tokio::test] -async fn preapproved_additional_permissions_escalate_intercepted_exec() -> anyhow::Result<()> { - let (session, turn_context) = make_session_and_context().await; - let requested_permissions = AdditionalPermissionProfile { - file_system: Some(FileSystemPermissions::from_read_write_roots( - /*read*/ None, - Some(vec![ - AbsolutePathBuf::from_absolute_path("/tmp/output").unwrap(), - ]), - )), - ..Default::default() - }; - let workdir = test_sandbox_cwd(); - let permission_profile = effective_permission_profile( - &PermissionProfile::workspace_write(), - Some(&requested_permissions), - ); - let provider = CoreShellActionProvider { - policy: Arc::new(RwLock::new(codex_execpolicy::Policy::empty())), - session: Arc::new(session), - review_context: GuardianReviewContext::from(Arc::new(turn_context)), - call_id: "preapproved-additional-permissions".to_string(), - environment_id: "local".to_string(), - source: GuardianCommandSource::UnifiedExec, - tool_name: ToolName::plain("exec_command"), - approval_policy: AskForApproval::OnRequest, - permission_profile: permission_profile.clone(), - sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, - approval_sandbox_permissions: SandboxPermissions::UseDefault, - prompt_permissions: Some(requested_permissions), - stopwatch: codex_shell_escalation::Stopwatch::new(Duration::from_secs(1)), - }; - - let action = codex_shell_escalation::EscalationPolicy::determine_action( - &provider, - &AbsolutePathBuf::from_absolute_path("/usr/bin/printf")?, - &["printf".to_string(), "hello".to_string()], - &workdir, - ) - .await?; - - let expected = codex_shell_escalation::EscalationDecision::Escalate( - EscalationExecution::Permissions(EscalationPermissions::ResolvedPermissionProfile( - ResolvedPermissionProfile { permission_profile }, - )), - ); - assert_eq!( - action, expected, - "preapproved with_additional_permissions should escalate through the resolved permission profile" - ); - - Ok(()) -} - -#[tokio::test(flavor = "current_thread")] -async fn execve_permission_request_hook_short_circuits_prompt() -> anyhow::Result<()> { - let (session, mut turn_context) = make_session_and_context().await; - std::fs::create_dir_all(&turn_context.config.codex_home) - .context("recreate codex home for hook fixtures")?; - let script_path = turn_context - .config - .codex_home - .join("permission_request_hook.py"); - let log_path = turn_context - .config - .codex_home - .join("permission_request_hook_log.jsonl"); - codex_utils_cargo_bin::write_executable( - &script_path, - &format!( - "#!/bin/sh\ncat > {log_path}\nprintf '%s\\n' '{response}'\n", - log_path = shlex::try_quote(log_path.to_string_lossy().as_ref())?, - response = "{\"hookSpecificOutput\":{\"hookEventName\":\"PermissionRequest\",\"decision\":{\"behavior\":\"allow\"}}}", - ), - ) - .with_context(|| format!("write hook script to {}", script_path.display()))?; - std::fs::write( - turn_context.config.codex_home.join("hooks.json"), - serde_json::json!({ - "hooks": { - "PermissionRequest": [{ - "hooks": [{ - "type": "command", - "command": script_path.display().to_string(), - }] - }] - } - }) - .to_string(), - ) - .context("write hooks.json")?; - let config_toml_path = turn_context - .config - .codex_home - .join(codex_config::CONFIG_TOML_FILE); - let hook_list = codex_hooks::list_hooks(HooksConfig { - feature_enabled: true, - config_layer_stack: Some(turn_context.config.config_layer_stack.clone()), - ..HooksConfig::default() - }); - assert_eq!(hook_list.hooks.len(), 1); - let trusted_config_layer_stack = turn_context.config.config_layer_stack.with_user_config( - &config_toml_path, - serde_json::from_value(serde_json::json!({ - "hooks": { - "state": { - hook_list.hooks[0].key.clone(): { - "trusted_hash": hook_list.hooks[0].current_hash.clone(), - }, - }, - }, - })) - .context("build trusted hook state")?, - )?; - - let mut hook_shell_argv = session - .user_shell() - .derive_exec_args("", /*use_login_shell*/ false); - let hook_shell_program = hook_shell_argv.remove(0); - let _ = hook_shell_argv.pop(); - let hooks = session.hooks().reconfigured(HooksConfig { - feature_enabled: true, - config_layer_stack: Some(trusted_config_layer_stack), - shell_program: Some(hook_shell_program), - shell_args: hook_shell_argv, - ..HooksConfig::default() - }); - session.services.hooks.store(Arc::new(hooks)); - - Arc::make_mut(&mut turn_context.config) - .permissions - .approval_policy = Constrained::allow_any(AskForApproval::OnRequest); - Arc::make_mut(&mut turn_context.config) - .permissions - .set_permission_profile(PermissionProfile::from_runtime_permissions( - &read_only_file_system_sandbox_policy(), - NetworkSandboxPolicy::Restricted, - )) - .expect("test setup should allow updating permission profile"); - let workdir = AbsolutePathBuf::try_from(std::env::current_dir()?)?; - let target = std::env::temp_dir().join("execve-hook-short-circuit.txt"); - let target_str = target.display().to_string(); - let command = vec!["touch".to_string(), target_str.clone()]; - let expected_hook_command = - codex_shell_command::parse_command::shlex_join(&["/usr/bin/touch".to_string(), target_str]); - - struct PendingApprovalTask; - - impl crate::tasks::SessionTask for PendingApprovalTask { - fn kind(&self) -> crate::state::TaskKind { - crate::state::TaskKind::Regular - } - - fn span_name(&self) -> &'static str { - "session_task.pending_execve_approval" - } - - async fn run( - self: Arc, - _session: Arc, - _turn_context: Arc, - _input: Vec, - cancellation_token: tokio_util::sync::CancellationToken, - ) -> crate::tasks::SessionTaskResult { - cancellation_token.cancelled().await; - Ok(None) - } - } - - let session = Arc::new(session); - let turn_context = Arc::new(turn_context); - session - .spawn_task(Arc::clone(&turn_context), Vec::new(), PendingApprovalTask) - .await; - let provider = CoreShellActionProvider { - policy: std::sync::Arc::new(RwLock::new(codex_execpolicy::Policy::empty())), - session: Arc::clone(&session), - review_context: GuardianReviewContext::from(turn_context), - call_id: "execve-hook-call".to_string(), - environment_id: "local".to_string(), - source: GuardianCommandSource::UnifiedExec, - tool_name: ToolName::plain("exec_command"), - approval_policy: AskForApproval::OnRequest, - permission_profile: PermissionProfile::read_only(), - sandbox_permissions: SandboxPermissions::RequireEscalated, - approval_sandbox_permissions: SandboxPermissions::RequireEscalated, - prompt_permissions: None, - stopwatch: codex_shell_escalation::Stopwatch::new(Duration::from_secs(1)), - }; - - let action = tokio::time::timeout( - Duration::from_secs(5), - codex_shell_escalation::EscalationPolicy::determine_action( - &provider, - &AbsolutePathBuf::from_absolute_path("/usr/bin/touch") - .context("build touch absolute path")?, - &command, - &workdir, - ), - ) - .await - .context("timed out waiting for execve permission hook decision")??; - assert!(matches!( - action, - codex_shell_escalation::EscalationDecision::Escalate( - codex_shell_escalation::EscalationExecution::Unsandboxed - ) - )); - - let hook_inputs: Vec = std::fs::read_to_string(&log_path) - .with_context(|| format!("read hook log at {}", log_path.display()))? - .lines() - .map(serde_json::from_str) - .collect::>() - .context("parse hook log")?; - assert_eq!(hook_inputs.len(), 1); - assert_eq!( - hook_inputs[0]["tool_input"]["command"], - expected_hook_command - ); - assert_eq!( - hook_inputs[0]["tool_input"]["description"], - serde_json::Value::Null - ); - - Ok(()) -} - -#[test] -fn evaluate_intercepted_exec_policy_uses_wrapper_command_when_shell_wrapper_parsing_disabled() { - let policy_src = r#"prefix_rule(pattern = ["npm", "publish"], decision = "prompt")"#; - let mut parser = PolicyParser::new(); - parser.parse("test.rules", policy_src).unwrap(); - let policy = parser.build(); - let program = AbsolutePathBuf::try_from(host_absolute_path(&["bin", "zsh"])).unwrap(); - - let enable_intercepted_exec_policy_shell_wrapper_parsing = false; - let evaluation = evaluate_intercepted_exec_policy( - &policy, - &program, - &[ - "zsh".to_string(), - "-lc".to_string(), - "npm publish".to_string(), - ], - InterceptedExecPolicyContext { - approval_policy: AskForApproval::OnRequest, - permission_profile: PermissionProfile::read_only(), - windows_sandbox_level: WindowsSandboxLevel::Disabled, - sandbox_permissions: SandboxPermissions::UseDefault, - enable_shell_wrapper_parsing: enable_intercepted_exec_policy_shell_wrapper_parsing, - }, - ); - - assert!( - matches!( - evaluation.matched_rules.as_slice(), - [RuleMatch::HeuristicsRuleMatch { command, decision: Decision::Allow }] - if command == &vec![ - program.to_string_lossy().to_string(), - "-lc".to_string(), - "npm publish".to_string(), - ] - ), - r#"This is allowed because when shell wrapper parsing is disabled, -the policy evaluation does not try to parse the shell command and instead -matches the whole command line with the resolved program path, which in this -case is `/bin/zsh` followed by some arguments. - -Because there is no policy rule for `/bin/zsh` or `zsh`, the decision is to -allow the command and let the sandbox be responsible for enforcing any -restrictions. - -That said, if /bin/zsh is the zsh-fork, then the execve wrapper should -ultimately intercept the `npm publish` command and apply the policy rules to it. -"# - ); -} - -#[test] -fn evaluate_intercepted_exec_policy_matches_inner_shell_commands_when_enabled() { - let policy_src = r#"prefix_rule(pattern = ["npm", "publish"], decision = "prompt")"#; - let mut parser = PolicyParser::new(); - parser.parse("test.rules", policy_src).unwrap(); - let policy = parser.build(); - let program = AbsolutePathBuf::try_from(host_absolute_path(&["bin", "bash"])).unwrap(); - - let enable_intercepted_exec_policy_shell_wrapper_parsing = true; - let evaluation = evaluate_intercepted_exec_policy( - &policy, - &program, - &[ - "bash".to_string(), - "-lc".to_string(), - "npm publish".to_string(), - ], - InterceptedExecPolicyContext { - approval_policy: AskForApproval::OnRequest, - permission_profile: PermissionProfile::read_only(), - windows_sandbox_level: WindowsSandboxLevel::Disabled, - sandbox_permissions: SandboxPermissions::UseDefault, - enable_shell_wrapper_parsing: enable_intercepted_exec_policy_shell_wrapper_parsing, - }, - ); - - assert_eq!( - evaluation, - Evaluation { - decision: Decision::Prompt, - matched_rules: vec![RuleMatch::PrefixRuleMatch { - matched_prefix: vec!["npm".to_string(), "publish".to_string()], - decision: Decision::Prompt, - resolved_program: None, - justification: None, - }], - } - ); -} - -#[test] -fn intercepted_exec_policy_uses_host_executable_mappings() { - let git_path = host_absolute_path(&["usr", "bin", "git"]); - let git_path_literal = starlark_string(&git_path); - let policy_src = format!( - r#" -prefix_rule(pattern = ["git", "status"], decision = "prompt") -host_executable(name = "git", paths = ["{git_path_literal}"]) -"# - ); - let mut parser = PolicyParser::new(); - parser.parse("test.rules", &policy_src).unwrap(); - let policy = parser.build(); - let program = AbsolutePathBuf::try_from(git_path).unwrap(); - - let evaluation = evaluate_intercepted_exec_policy( - &policy, - &program, - &["git".to_string(), "status".to_string()], - InterceptedExecPolicyContext { - approval_policy: AskForApproval::OnRequest, - permission_profile: PermissionProfile::read_only(), - windows_sandbox_level: WindowsSandboxLevel::Disabled, - sandbox_permissions: SandboxPermissions::UseDefault, - enable_shell_wrapper_parsing: false, - }, - ); - - assert_eq!( - evaluation, - Evaluation { - decision: Decision::Prompt, - matched_rules: vec![RuleMatch::PrefixRuleMatch { - matched_prefix: vec!["git".to_string(), "status".to_string()], - decision: Decision::Prompt, - resolved_program: Some(program), - justification: None, - }], - } - ); - assert!(CoreShellActionProvider::decision_driven_by_policy( - &evaluation.matched_rules, - evaluation.decision - )); -} - -#[tokio::test(flavor = "current_thread")] -async fn denied_reads_keep_prefix_rule_allow_inside_sandbox() -> anyhow::Result<()> { - let cat_path = host_absolute_path(&["usr", "bin", "cat"]); - let cat_path_literal = starlark_string(&cat_path); - let policy_src = format!( - r#" -prefix_rule(pattern = ["{cat_path_literal}"], decision = "allow") -"# - ); - let mut parser = PolicyParser::new(); - parser.parse("test.rules", &policy_src).unwrap(); - let policy = parser.build(); - - let (session, turn_context) = make_session_and_context().await; - let file_system_sandbox_policy = denied_read_file_system_sandbox_policy(); - let permission_profile = PermissionProfile::from_runtime_permissions( - &file_system_sandbox_policy, - NetworkSandboxPolicy::Restricted, - ); - let workdir = test_sandbox_cwd(); - let provider = CoreShellActionProvider { - policy: Arc::new(RwLock::new(policy)), - session: Arc::new(session), - review_context: GuardianReviewContext::from(Arc::new(turn_context)), - call_id: "deny-read-prefix-allow".to_string(), - environment_id: "local".to_string(), - source: GuardianCommandSource::UnifiedExec, - tool_name: ToolName::plain("exec_command"), - approval_policy: AskForApproval::OnRequest, - permission_profile, - sandbox_permissions: SandboxPermissions::UseDefault, - approval_sandbox_permissions: SandboxPermissions::UseDefault, - prompt_permissions: None, - stopwatch: codex_shell_escalation::Stopwatch::new(Duration::from_secs(1)), - }; - - let action = codex_shell_escalation::EscalationPolicy::determine_action( - &provider, - &AbsolutePathBuf::try_from(cat_path).unwrap(), - &["cat".to_string(), "/tmp/visible.txt".to_string()], - &workdir, - ) - .await?; - - assert_eq!(action, codex_shell_escalation::EscalationDecision::Run); - Ok(()) -} - -#[tokio::test(flavor = "current_thread")] -async fn denied_reads_keep_granular_sandbox_rejection_for_escalation() -> anyhow::Result<()> { - let (session, turn_context) = make_session_and_context().await; - let file_system_sandbox_policy = denied_read_file_system_sandbox_policy(); - let permission_profile = PermissionProfile::from_runtime_permissions( - &file_system_sandbox_policy, - NetworkSandboxPolicy::Restricted, - ); - let workdir = test_sandbox_cwd(); - let provider = CoreShellActionProvider { - policy: Arc::new(RwLock::new(PolicyParser::new().build())), - session: Arc::new(session), - review_context: GuardianReviewContext::from(Arc::new(turn_context)), - call_id: "deny-read-granular-sandbox-reject".to_string(), - environment_id: "local".to_string(), - source: GuardianCommandSource::UnifiedExec, - tool_name: ToolName::plain("exec_command"), - approval_policy: AskForApproval::Granular(GranularApprovalConfig { - sandbox_approval: false, - rules: true, - skill_approval: true, - request_permissions: true, - mcp_elicitations: true, - }), - permission_profile, - sandbox_permissions: SandboxPermissions::RequireEscalated, - approval_sandbox_permissions: SandboxPermissions::RequireEscalated, - prompt_permissions: None, - stopwatch: codex_shell_escalation::Stopwatch::new(Duration::from_secs(1)), - }; - - let action = codex_shell_escalation::EscalationPolicy::determine_action( - &provider, - &AbsolutePathBuf::try_from(host_absolute_path(&["usr", "bin", "printf"])).unwrap(), - &["printf".to_string(), "hello".to_string()], - &workdir, - ) - .await?; - - assert_eq!( - action, - codex_shell_escalation::EscalationDecision::Deny { - reason: Some("Execution forbidden by policy".to_string()) - } - ); - Ok(()) -} - -#[test] -fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default() { - let policy = PolicyParser::new().build(); - let program = AbsolutePathBuf::try_from(host_absolute_path(&["usr", "bin", "printf"])).unwrap(); - let argv = ["printf".to_string(), "hello".to_string()]; - let approval_policy = AskForApproval::OnRequest; - let permission_profile = PermissionProfile::workspace_write(); - - let preapproved = evaluate_intercepted_exec_policy( - &policy, - &program, - &argv, - InterceptedExecPolicyContext { - approval_policy, - permission_profile: permission_profile.clone(), - windows_sandbox_level: WindowsSandboxLevel::Disabled, - sandbox_permissions: super::approval_sandbox_permissions( - SandboxPermissions::WithAdditionalPermissions, - /*additional_permissions_preapproved*/ true, - ), - enable_shell_wrapper_parsing: false, - }, - ); - let fresh_request = evaluate_intercepted_exec_policy( - &policy, - &program, - &argv, - InterceptedExecPolicyContext { - approval_policy, - permission_profile, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, - enable_shell_wrapper_parsing: false, - }, - ); - - assert_eq!(preapproved.decision, Decision::Allow); - assert_eq!(fresh_request.decision, Decision::Prompt); -} - -#[test] -fn intercepted_exec_policy_rejects_disallowed_host_executable_mapping() { - let allowed_git = host_absolute_path(&["usr", "bin", "git"]); - let other_git = host_absolute_path(&["opt", "homebrew", "bin", "git"]); - let allowed_git_literal = starlark_string(&allowed_git); - let policy_src = format!( - r#" -prefix_rule(pattern = ["git", "status"], decision = "prompt") -host_executable(name = "git", paths = ["{allowed_git_literal}"]) -"# - ); - let mut parser = PolicyParser::new(); - parser.parse("test.rules", &policy_src).unwrap(); - let policy = parser.build(); - let program = AbsolutePathBuf::try_from(other_git.clone()).unwrap(); - - let evaluation = evaluate_intercepted_exec_policy( - &policy, - &program, - &["git".to_string(), "status".to_string()], - InterceptedExecPolicyContext { - approval_policy: AskForApproval::OnRequest, - permission_profile: PermissionProfile::read_only(), - windows_sandbox_level: WindowsSandboxLevel::Disabled, - sandbox_permissions: SandboxPermissions::UseDefault, - enable_shell_wrapper_parsing: false, - }, - ); - - assert!(matches!( - evaluation.matched_rules.as_slice(), - [RuleMatch::HeuristicsRuleMatch { command, .. }] - if command == &vec![other_git, "status".to_string()] - )); - assert!(!CoreShellActionProvider::decision_driven_by_policy( - &evaluation.matched_rules, - evaluation.decision - )); -} diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 5c83302ca6..00fe53e244 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -88,7 +88,6 @@ use codex_tools::ToolExecutor; use codex_tools::ToolExposures; use codex_tools::ToolName; use codex_tools::ToolSpec; -use codex_tools::UnifiedExecShellMode; use codex_tools::can_request_original_image_detail; use codex_tools::collect_code_mode_exec_prompt_tool_definitions; use codex_tools::collect_request_plugin_install_entries; @@ -1287,15 +1286,7 @@ fn add_shell_tools(context: &CoreToolPlanContext<'_>, registry: &mut ToolRegistr .environments .turn_environments() .any(|environment| environment.config().allow_login_shell), - include_shell_parameter: stable_environment_tools - || !matches!( - &turn_context.unified_exec_shell_mode, - UnifiedExecShellMode::ZshFork(_) - ) - || context - .environments - .turn_environments() - .any(|environment| environment.environment.is_remote()), + include_shell_parameter: true, allow_tty: features.enabled(Feature::UnifiedExecTty), exec_permission_approvals_enabled, include_environment_id, diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 1ccb81393b..1a8a066187 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -960,7 +960,6 @@ async fn request_user_input_stays_direct_in_code_mode_only() { async fn shell_family_registers_only_unified_exec_tools() { let plan = probe(|turn| { set_features(turn, &[Feature::ShellTool]); - set_feature(turn, Feature::ShellZshFork, /*enabled*/ false); update_turn_settings_for_test(turn, |settings| { Arc::make_mut(&mut settings.model_info).shell_type = ConfigShellToolType::UnifiedExec; }); @@ -984,7 +983,6 @@ async fn exec_command_guidance_follows_executor_platform_and_fallbacks() { ] { let plan = probe(|turn| { set_features(turn, &[Feature::ShellTool, Feature::UnifiedExec]); - set_feature(turn, Feature::ShellZshFork, /*enabled*/ false); update_turn_settings_for_test(turn, |settings| { Arc::make_mut(&mut settings.model_info).shell_type = ConfigShellToolType::UnifiedExec; @@ -1081,40 +1079,6 @@ async fn dynamic_tools_cannot_reclaim_the_reserved_exec_command_name() { ); } -#[tokio::test] -async fn shell_zsh_fork_keeps_unified_exec_available() { - let without_composition = probe(|turn| { - set_features(turn, &[Feature::ShellTool]); - set_feature(turn, Feature::ShellZshFork, /*enabled*/ true); - set_feature(turn, Feature::UnifiedExecZshFork, /*enabled*/ false); - update_turn_settings_for_test(turn, |settings| { - Arc::make_mut(&mut settings.model_info).shell_type = ConfigShellToolType::UnifiedExec; - }); - }) - .await; - - without_composition.assert_visible_contains(&["exec_command", "write_stdin"]); - without_composition.assert_registered_contains(&["exec_command", "write_stdin"]); - - let composed = probe(|turn| { - set_features( - turn, - &[ - Feature::ShellTool, - Feature::ShellZshFork, - Feature::UnifiedExecZshFork, - ], - ); - update_turn_settings_for_test(turn, |settings| { - Arc::make_mut(&mut settings.model_info).shell_type = ConfigShellToolType::UnifiedExec; - }); - }) - .await; - - composed.assert_visible_contains(&["exec_command", "write_stdin"]); - composed.assert_registered_contains(&["exec_command", "write_stdin"]); -} - #[tokio::test] async fn multiple_environments_expose_environment_selectors() { let multiple_environments = probe(|turn| { @@ -1804,7 +1768,6 @@ async fn unified_tool_runtimes_preserve_source_order_and_collision_priority() { let plan = probe_with( |turn| { set_features(turn, &[Feature::ShellTool]); - set_feature(turn, Feature::ShellZshFork, /*enabled*/ false); update_turn_settings_for_test(turn, |settings| { Arc::make_mut(&mut settings.model_info).shell_type = ConfigShellToolType::UnifiedExec; diff --git a/codex-rs/core/src/unified_exec/async_watcher_tests.rs b/codex-rs/core/src/unified_exec/async_watcher_tests.rs index 8443994551..5e9973c75e 100644 --- a/codex-rs/core/src/unified_exec/async_watcher_tests.rs +++ b/codex-rs/core/src/unified_exec/async_watcher_tests.rs @@ -9,7 +9,6 @@ use super::utf8_boundary; use crate::session::tests::make_session_and_context_with_rx; use crate::unified_exec::UnifiedExecContext; use crate::unified_exec::UnifiedExecProcessManager; -use crate::unified_exec::process::NoopSpawnLifecycle; use crate::unified_exec::process::OutputBuffers; use crate::unified_exec::process::UnifiedExecProcess; use codex_protocol::items::CommandExecutionStatus; @@ -48,10 +47,7 @@ async fn streaming_output_harness() -> anyhow::Result { #[cfg(windows)] tty: false, }); - let process = Arc::new( - UnifiedExecProcess::from_spawned(spawned, SandboxType::None, Box::new(NoopSpawnLifecycle)) - .await?, - ); + let process = Arc::new(UnifiedExecProcess::from_spawned(spawned, SandboxType::None).await?); let (session, turn, rx_event) = make_session_and_context_with_rx().await; let context = UnifiedExecContext::new( session, diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 28af9b0229..c4ea0991ab 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -30,7 +30,6 @@ use std::sync::Weak; use codex_network_proxy::NetworkProxy; use codex_protocol::models::AdditionalPermissionProfile; -use codex_tools::UnifiedExecShellMode; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_path_uri::PathUri; use rand::Rng; @@ -62,10 +61,6 @@ pub(crate) fn set_deterministic_process_ids_for_tests(enabled: bool) { } pub(crate) use errors::UnifiedExecError; -pub(crate) use process::NoopSpawnLifecycle; -#[cfg(unix)] -pub(crate) use process::SpawnLifecycle; -pub(crate) use process::SpawnLifecycleHandle; pub(crate) use process::UnifiedExecProcess; pub(crate) use stdin_approval::TerminalPermissions; pub(crate) use stdin_approval::TerminalSandboxSource; @@ -121,7 +116,6 @@ pub(crate) struct ExecCommandRequest { pub cwd: PathUri, pub sandbox_cwd: PathUri, pub turn_environment: TurnEnvironment, - pub shell_mode: UnifiedExecShellMode, pub network: Option, pub tty: bool, pub sandbox_permissions: SandboxPermissions, diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index 1b8af5bc0d..3bc0ad0189 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -1,6 +1,5 @@ use super::*; use crate::codex_thread::BackgroundTerminalInfo; -use crate::environment_selection::TurnEnvironmentState; use crate::exec::ExecCapturePolicy; use crate::exec::ExecExpiration; use crate::sandboxing::ExecRequest; @@ -118,7 +117,6 @@ async fn exec_command_with_tty( codex_sandboxing::WindowsSandboxProxySettingsMode::Reconcile, /*network_policy_decider*/ None, tty, - Box::new(NoopSpawnLifecycle), turn.initial_environments .primary() .expect("turn environment") @@ -218,17 +216,6 @@ async fn exec_command_with_tty( }) } -#[derive(Debug)] -struct TestSpawnLifecycle { - inherited_fds: Vec, -} - -impl SpawnLifecycle for TestSpawnLifecycle { - fn inherited_fds(&self) -> Vec { - self.inherited_fds.clone() - } -} - struct BlockingTerminateExecProcess { process_id: ProcessId, terminate_started: watch::Sender, @@ -787,7 +774,6 @@ async fn completed_pipe_commands_preserve_exit_code() -> anyhow::Result<()> { codex_sandboxing::WindowsSandboxProxySettingsMode::Reconcile, /*network_policy_decider*/ None, /*tty*/ false, - Box::new(NoopSpawnLifecycle), &environment, ) .await?; @@ -830,7 +816,6 @@ async fn unified_exec_uses_remote_exec_server_when_configured() -> anyhow::Resul codex_sandboxing::WindowsSandboxProxySettingsMode::Reconcile, /*network_policy_decider*/ None, /*tty*/ true, - Box::new(NoopSpawnLifecycle), remote_test_env.environment(), ) .await?; @@ -850,56 +835,6 @@ async fn unified_exec_uses_remote_exec_server_when_configured() -> anyhow::Resul Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn remote_exec_server_rejects_inherited_fd_launches() -> anyhow::Result<()> { - skip_if_sandbox!(Ok(())); - skip_if_no_remote_env!(Ok(())); - - let remote_test_env = remote_test_env().await?; - let (_, mut turn) = make_session_and_context().await; - let TurnEnvironmentState::Ready(environment) = &mut turn.initial_environments.environments[0] - else { - panic!("expected ready primary environment"); - }; - environment.environment = Arc::new(remote_test_env.environment().clone()); - - #[allow(deprecated)] - let cwd = turn.cwd.clone(); - let request = test_exec_request( - &turn, - vec!["bash".to_string(), "-lc".to_string(), "echo ok".to_string()], - cwd, - shell_env(), - ); - - let manager = UnifiedExecProcessManager::default(); - let err = manager - .open_session_with_prepared_exec_env( - /*process_id*/ 1234, - &request, - /*tool_ctx*/ None, - codex_sandboxing::WindowsSandboxProxySettingsMode::Reconcile, - /*network_policy_decider*/ None, - /*tty*/ true, - Box::new(TestSpawnLifecycle { - inherited_fds: vec![42], - }), - turn.initial_environments - .primary() - .expect("turn environment") - .environment - .as_ref(), - ) - .await - .expect_err("expected inherited fd rejection"); - - assert_eq!( - err.to_string(), - "Failed to create unified exec process: remote exec-server does not support inherited file descriptors" - ); - Ok(()) -} - #[tokio::test] async fn stdin_approval_preserves_the_reviewed_terminal() -> anyhow::Result<()> { use crate::session::tests::make_session_and_context_with_auth_and_config_and_rx; diff --git a/codex-rs/core/src/unified_exec/process.rs b/codex-rs/core/src/unified_exec/process.rs index 8305c02894..51afaa36d0 100644 --- a/codex-rs/core/src/unified_exec/process.rs +++ b/codex-rs/core/src/unified_exec/process.rs @@ -37,27 +37,6 @@ use super::process_state::ProcessState; use crate::shell_snapshot::ShellSnapshotFile; const EARLY_EXIT_GRACE_PERIOD: Duration = Duration::from_millis(150); -pub(crate) trait SpawnLifecycle: std::fmt::Debug + Send + Sync { - /// Returns file descriptors that must stay open across the child `exec()`. - /// - /// The returned descriptors must already be valid in the parent process and - /// stay valid until `after_spawn()` runs, which is the first point where - /// the parent may release its copies. - fn inherited_fds(&self) -> Vec { - Vec::new() - } - - fn after_spawn(&mut self) {} -} - -pub(crate) type SpawnLifecycleHandle = Box; - -#[derive(Debug, Default)] -/// Spawn lifecycle that performs no extra setup around process launch. -pub(crate) struct NoopSpawnLifecycle; - -impl SpawnLifecycle for NoopSpawnLifecycle {} - /// Output pending model polling and the retained completion transcript. /// Append both under the same lock so cancellation cannot split an update. #[derive(Default)] @@ -114,7 +93,6 @@ pub(crate) struct UnifiedExecProcess { output_task: Option>, sandbox_type: Option, timed_out: AtomicBool, - _spawn_lifecycle: Option, // The shell may still need to replay this file after process startup returns. pub(crate) _shell_snapshot: Option>, } @@ -130,11 +108,7 @@ impl std::fmt::Debug for UnifiedExecProcess { } impl UnifiedExecProcess { - fn new( - process_handle: ProcessHandle, - sandbox_type: Option, - spawn_lifecycle: Option, - ) -> Self { + fn new(process_handle: ProcessHandle, sandbox_type: Option) -> Self { let output = OutputHandles { output_buffer: Arc::new(Mutex::new(OutputBuffers::default())), output_notify: Arc::new(Notify::new()), @@ -157,7 +131,6 @@ impl UnifiedExecProcess { output_task: None, sandbox_type, timed_out: AtomicBool::new(false), - _spawn_lifecycle: spawn_lifecycle, _shell_snapshot: None, } } @@ -358,7 +331,6 @@ impl UnifiedExecProcess { pub(super) async fn from_spawned( spawned: SpawnedPty, sandbox_type: SandboxType, - spawn_lifecycle: SpawnLifecycleHandle, ) -> Result { let SpawnedPty { session: process_handle, @@ -370,7 +342,6 @@ impl UnifiedExecProcess { let mut managed = Self::new( ProcessHandle::Local(Box::new(process_handle)), Some(sandbox_type), - Some(spawn_lifecycle), ); managed.output_task = Some(Self::spawn_local_output_task( output_rx, @@ -419,7 +390,7 @@ impl UnifiedExecProcess { // Older peers do not report this field. In that case, skip local // classification rather than attributing a violation to a guessed backend. let sandbox_type = started.sandbox_type; - let mut managed = Self::new(process_handle, sandbox_type, /*spawn_lifecycle*/ None); + let mut managed = Self::new(process_handle, sandbox_type); let output_handles = managed.output_handles().clone(); managed.output_task = Some(Self::spawn_exec_server_output_task( started, diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 35a94ac959..80a34ce6a1 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -65,7 +65,6 @@ use crate::unified_exec::generate_chunk_id; use crate::unified_exec::head_tail_buffer::HeadTailBuffer; use crate::unified_exec::process::OutputBuffers; use crate::unified_exec::process::OutputHandles; -use crate::unified_exec::process::SpawnLifecycleHandle; use crate::unified_exec::process::UnifiedExecProcess; use crate::unified_exec::shell_snapshot::shell_snapshot_request; use crate::unified_exec::take_plugin_metrics_sidecar; @@ -1265,7 +1264,6 @@ impl UnifiedExecProcessManager { shell_snapshot: Option, windows_sandbox_proxy_settings_mode: codex_sandboxing::WindowsSandboxProxySettingsMode, tty: bool, - spawn_lifecycle: SpawnLifecycleHandle, environment: &codex_exec_server::Environment, ) -> Result { let mut request = if environment.is_remote() || shell_snapshot.is_some() { @@ -1293,7 +1291,6 @@ impl UnifiedExecProcessManager { windows_sandbox_proxy_settings_mode, network_policy_decider, tty, - spawn_lifecycle, environment, ) .await @@ -1317,18 +1314,9 @@ impl UnifiedExecProcessManager { windows_sandbox_proxy_settings_mode: codex_sandboxing::WindowsSandboxProxySettingsMode, network_policy_decider: Option>, tty: bool, - mut spawn_lifecycle: SpawnLifecycleHandle, environment: &codex_exec_server::Environment, ) -> Result { - let inherited_fds = spawn_lifecycle.inherited_fds(); - if environment.is_remote() || request.exec_server_shell_snapshot.is_some() { - if !inherited_fds.is_empty() { - return Err(UnifiedExecError::create_process( - "remote exec-server does not support inherited file descriptors".to_string(), - )); - } - let backend = environment.get_exec_backend(); let params = exec_server_params_for_request( process_id, @@ -1355,7 +1343,6 @@ impl UnifiedExecProcessManager { None => backend.start(params).await, } .map_err(|err| UnifiedExecError::create_process(err.to_string()))?; - spawn_lifecycle.after_spawn(); return UnifiedExecProcess::from_exec_server_started(started).await; } @@ -1422,13 +1409,12 @@ impl UnifiedExecProcessManager { windows_sandbox, tty, stdin_open: tty, - inherited_fds: codex_utils_pty::ChildFds::Inherited(&inherited_fds), + inherited_fds: codex_utils_pty::ChildFds::Inherited(&[]), }) .await; - spawn_lifecycle.after_spawn(); let spawned = spawn_result.map_err(|err| UnifiedExecError::create_process(err.to_string()))?; - UnifiedExecProcess::from_spawned(spawned, request.sandbox, spawn_lifecycle).await + UnifiedExecProcess::from_spawned(spawned, request.sandbox).await } #[tracing::instrument( @@ -1467,7 +1453,7 @@ impl UnifiedExecProcessManager { }; let shell_snapshot = shell_snapshot_request(request, &cwd, context); let mut orchestrator = ToolOrchestrator::new(); - let mut runtime = UnifiedExecRuntime::new(self, request.shell_mode.clone()); + let mut runtime = UnifiedExecRuntime::new(self); let session_shell = context.session.user_shell(); let configured_shell = request .turn_environment @@ -1504,7 +1490,6 @@ impl UnifiedExecProcessManager { allow_prefix_rules: context.step_context.turn.allow_prefix_rules(), }, configured_shell, - &request.shell_mode, command_platform, ) .await; @@ -1524,8 +1509,6 @@ impl UnifiedExecProcessManager { tty: request.tty, sandbox_permissions: request.sandbox_permissions, additional_permissions: request.additional_permissions.clone(), - #[cfg(unix)] - additional_permissions_preapproved: request.additional_permissions_preapproved, justification: request.justification.clone(), exec_approval_requirement, }; diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index ce56681e68..081ab93599 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -482,7 +482,6 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() { .primary() .cloned() .expect("primary environment"), - shell_mode: codex_tools::UnifiedExecShellMode::Direct, network: None, tty: true, sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, diff --git a/codex-rs/core/src/unified_exec/shell_snapshot.rs b/codex-rs/core/src/unified_exec/shell_snapshot.rs index c7cd928984..6606f6786d 100644 --- a/codex-rs/core/src/unified_exec/shell_snapshot.rs +++ b/codex-rs/core/src/unified_exec/shell_snapshot.rs @@ -11,7 +11,6 @@ use codex_protocol::sandbox::SandboxOverride; use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxablePreference; use codex_tools::ToolName; -use codex_tools::UnifiedExecShellMode; use codex_utils_path_uri::PathUri; use tokio_util::task::AbortOnDropHandle; use uuid::Uuid; @@ -57,10 +56,6 @@ impl Session { .network .as_ref() .is_some_and(NetworkProxySpec::enabled) - || !matches!( - step_context.turn.unified_exec_shell_mode, - UnifiedExecShellMode::Direct - ) { return None; } @@ -180,7 +175,6 @@ pub(super) fn shell_snapshot_request( if !context.session.features().enabled(Feature::ShellSnapshotV2) || !request.turn_environment.shell_snapshot_v2_supported || request.turn_environment.selection.cwd != *cwd - || !matches!(request.shell_mode, UnifiedExecShellMode::Direct) || !request.shell.is_posix_login() { return None; diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 0b05675541..1f76febcee 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -35,7 +35,6 @@ pub mod test_codex; pub mod test_codex_exec; mod test_environment; pub mod tracing; -pub mod zsh_fork; pub(crate) use test_environment::TestEnvironment; pub use test_environment::TestTargetOs; diff --git a/codex-rs/core/tests/common/zsh_fork.rs b/codex-rs/core/tests/common/zsh_fork.rs deleted file mode 100644 index 225867d8c7..0000000000 --- a/codex-rs/core/tests/common/zsh_fork.rs +++ /dev/null @@ -1,131 +0,0 @@ -use std::path::Path; -use std::path::PathBuf; - -use anyhow::Result; -use codex_core::config::Config; -use codex_core::config::Constrained; -use codex_features::Feature; -use codex_protocol::models::PermissionProfile; -use codex_protocol::permissions::NetworkSandboxPolicy; -use codex_protocol::protocol::AskForApproval; - -use crate::test_codex::TestCodex; -use crate::test_codex::TestCodexBuilder; -use crate::test_codex::test_codex; - -#[derive(Clone)] -pub struct ZshForkRuntime { - zsh_path: PathBuf, - main_execve_wrapper_exe: PathBuf, -} - -impl ZshForkRuntime { - fn apply_to_config(&self, config: &mut Config, approval_policy: AskForApproval) { - config - .features - .enable(Feature::ShellTool) - .expect("test config should allow feature update"); - config - .features - .enable(Feature::ShellZshFork) - .expect("test config should allow feature update"); - config.zsh_path = Some(self.zsh_path.clone()); - config.main_execve_wrapper_exe = Some(self.main_execve_wrapper_exe.clone()); - config.permissions.allow_login_shell = false; - config.permissions.approval_policy = Constrained::allow_any(approval_policy); - } -} - -pub fn restrictive_workspace_write_profile() -> PermissionProfile { - PermissionProfile::workspace_write_with( - &[], - NetworkSandboxPolicy::Restricted, - /*exclude_tmpdir_env_var*/ true, - /*exclude_slash_tmp*/ true, - ) -} - -pub fn zsh_fork_runtime(test_name: &str) -> Result> { - let Some(zsh_path) = find_test_zsh_path()? else { - return Ok(None); - }; - if !supports_exec_wrapper_intercept(&zsh_path) { - eprintln!( - "skipping {test_name}: zsh does not support EXEC_WRAPPER intercepts ({})", - zsh_path.display() - ); - return Ok(None); - } - let Ok(main_execve_wrapper_exe) = codex_utils_cargo_bin::cargo_bin("codex-execve-wrapper") - else { - eprintln!("skipping {test_name}: unable to resolve `codex-execve-wrapper` binary"); - return Ok(None); - }; - - Ok(Some(ZshForkRuntime { - zsh_path, - main_execve_wrapper_exe, - })) -} - -pub async fn build_zsh_fork_test( - server: &wiremock::MockServer, - runtime: ZshForkRuntime, - approval_policy: AskForApproval, - permission_profile: PermissionProfile, - pre_build_hook: F, -) -> Result -where - F: FnOnce(&Path) + Send + 'static, -{ - let mut builder = zsh_fork_test_builder(runtime, approval_policy) - .with_pre_build_hook(pre_build_hook) - .with_config(move |config| { - config - .permissions - .set_permission_profile(permission_profile) - .expect("set permission profile"); - }); - builder.build(server).await -} - -pub fn zsh_fork_test_builder( - runtime: ZshForkRuntime, - approval_policy: AskForApproval, -) -> TestCodexBuilder { - test_codex().with_config(move |config| { - runtime.apply_to_config(config, approval_policy); - }) -} - -fn find_test_zsh_path() -> Result> { - let repo_root = codex_utils_cargo_bin::repo_root()?; - let dotslash_zsh = repo_root.join("codex-rs/app-server/tests/suite/zsh"); - if !dotslash_zsh.is_file() { - eprintln!( - "skipping zsh-fork test: shared zsh DotSlash file not found at {}", - dotslash_zsh.display() - ); - return Ok(None); - } - - match crate::fetch_dotslash_file(&dotslash_zsh, /*dotslash_cache*/ None) { - Ok(path) => Ok(Some(path)), - Err(error) => { - eprintln!("skipping zsh-fork test: failed to fetch zsh via dotslash: {error:#}"); - Ok(None) - } - } -} - -fn supports_exec_wrapper_intercept(zsh_path: &Path) -> bool { - let status = std::process::Command::new(zsh_path) - .arg("-fc") - .arg("/usr/bin/true") - .env("EXEC_WRAPPER", "/usr/bin/false") - .status(); - match status { - Ok(status) => !status.success(), - Err(_) => false, - } -} diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 99836ced45..074adbbd03 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -54,10 +54,6 @@ use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; use core_test_support::wait_for_event_with_timeout; -use core_test_support::zsh_fork::build_zsh_fork_test; -use core_test_support::zsh_fork::restrictive_workspace_write_profile; -use core_test_support::zsh_fork::zsh_fork_runtime; -use core_test_support::zsh_fork::zsh_fork_test_builder; use pretty_assertions::assert_eq; use regex_lite::Regex; use serde_json::Value; @@ -2741,7 +2737,6 @@ async fn spawned_subagent_execpolicy_amendment_propagates_to_parent_session() -> } #[cfg(unix)] -#[test_case("zsh_fork", false, false; "zsh_fork_unsupported")] #[test_case("direct", false, false; "explicit_zsh")] #[test_case("direct", true, false; "non_login_in_login_enabled_session")] #[test_case("direct", true, true; "login_startup")] @@ -2776,14 +2771,8 @@ async fn shell_startup_credentials_are_brokered( AskForApproval::Never }; let global_startup = matches!(shell_mode, "filtered_startup" | "startup_override"); - let direct = shell_mode == "direct" || global_startup || request_extra_permissions; - let builder = if shell_mode == "zsh_fork" { - let Some(runtime) = zsh_fork_runtime("zsh-fork credential broker environment test")? else { - return Ok(()); - }; - zsh_fork_test_builder(runtime, AskForApproval::Never) - } else { + let builder = { let Some(zsh) = codex_core::shell::get_shell(codex_core::shell::ShellType::Zsh) else { return Ok(()); }; @@ -2876,7 +2865,7 @@ ZDOTDIR = "{}" let mut builder = builder .with_home(home) .with_cloud_config_bundle(managed_network_requirements_loader()); - if direct && !allow_login_shell { + if !allow_login_shell { let Some(bash) = codex_core::shell::get_shell(codex_core::shell::ShellType::Bash) else { return Ok(()); }; @@ -2915,12 +2904,6 @@ ZDOTDIR = "{}" .features .enable(Feature::ShellSnapshot) .expect("test config should allow ShellSnapshot override"); - if direct { - config - .features - .disable(Feature::ShellZshFork) - .expect("test config should allow direct shell execution"); - } }); let server = start_mock_server().await; let mut test = builder.build(&server).await?; @@ -2940,14 +2923,14 @@ ZDOTDIR = "{}" None }; let snapshot_dir = test.home.path().join("shell_snapshots"); - let workdir = if (allow_login_shell && !login) || shell_mode == "zsh_fork" { + let workdir = if allow_login_shell && !login { test.cwd.path().to_path_buf() } else { let workdir = test.cwd.path().join("brokered-subdirectory"); fs::create_dir(&workdir)?; workdir }; - let call_id = "zsh-fork-brokered-github-credential"; + let call_id = "shell-brokered-github-credential"; let command = r#"printf '%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n' "$GH_HOST" "$GH_ENTERPRISE_TOKEN" "$CUSTOM_HOST" "$CUSTOM_API_KEY" "$CODEX_NETWORK_PROXY_CREDENTIAL_BROKER_ACTIVE" "$PWD" "$BROKERED_STARTUP_CWD" "${LOGIN_SNAPSHOT_READY-unset}" "$AUTH_HEADER""#; let command = format!( "APP_SETTING=production; EXCLUDED_SETTING=denied; [ \"$(/usr/bin/printenv APP_SETTING)\" = production ] || exit 1; if /usr/bin/printenv EXCLUDED_SETTING >/dev/null; then exit 1; fi; {command}" @@ -2960,28 +2943,19 @@ ZDOTDIR = "{}" }; let snapshot_dir_arg = shlex::try_join([snapshot_dir.to_string_lossy().as_ref()])?; let command = format!("{command}; /bin/cat {snapshot_dir_arg}/*.sh > captured-snapshot"); - let mut arguments = if direct { - let Some(zsh) = codex_core::shell::get_shell(codex_core::shell::ShellType::Zsh) else { - return Ok(()); - }; - json!({ - "cmd": command, - "shell": startup_shell.map_or_else( - || zsh.derive_exec_args("", /*use_login_shell*/ false)[0].clone(), - |path| path.display().to_string(), - ), - "workdir": workdir, - "login": login, - "yield_time_ms": 10_000, - }) - } else { - json!({ - "cmd": command, - "workdir": workdir, - "login": login, - "yield_time_ms": 10_000, - }) + let Some(zsh) = codex_core::shell::get_shell(codex_core::shell::ShellType::Zsh) else { + return Ok(()); }; + let mut arguments = json!({ + "cmd": command, + "shell": startup_shell.map_or_else( + || zsh.derive_exec_args("", /*use_login_shell*/ false)[0].clone(), + |path| path.display().to_string(), + ), + "workdir": workdir, + "login": login, + "yield_time_ms": 10_000, + }); if request_extra_permissions { let approved_dir = test.home.path().join("approved-command"); fs::create_dir(&approved_dir)?; @@ -2993,13 +2967,13 @@ ZDOTDIR = "{}" &server, vec![ sse(vec![ - ev_response_created("resp-zsh-fork-broker-1"), + ev_response_created("resp-shell-broker-1"), event, - ev_completed("resp-zsh-fork-broker-1"), + ev_completed("resp-shell-broker-1"), ]), sse(vec![ - ev_assistant_message("msg-zsh-fork-broker", "done"), - ev_completed("resp-zsh-fork-broker-2"), + ev_assistant_message("msg-shell-broker", "done"), + ev_completed("resp-shell-broker-2"), ]), ], ) @@ -3039,13 +3013,6 @@ ZDOTDIR = "{}" } let output = responses.requests()[1].function_call_output(call_id); - if shell_mode == "zsh_fork" { - let output = output.to_string(); - assert!(output.contains("credential brokerage does not yet support shell_zsh_fork")); - assert!(!workdir.join("captured-snapshot").exists()); - assert!(!output.contains(REAL_GITHUB_TOKEN)); - return Ok(()); - } let result = parse_result(&output); assert_eq!(result.exit_code, Some(0), "command failed: {result:?}"); if let Some(metrics) = metrics { @@ -3221,10 +3188,6 @@ GH_TOKEN = "{REAL_GITHUB_TOKEN}" config.features.enable(Feature::ShellSnapshot) } .expect("test config should allow ShellSnapshot override"); - config - .features - .disable(Feature::ShellZshFork) - .expect("test config should allow direct shell execution"); }); let test = builder.build(&server).await?; @@ -3439,7 +3402,6 @@ ENV = "{}" config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never); config.permissions.allow_login_shell = true; config.features.enable(Feature::ShellSnapshot).unwrap(); - config.features.disable(Feature::ShellZshFork).unwrap(); if !credential_startup { config .permissions @@ -3504,259 +3466,6 @@ ENV = "{}" Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[cfg(unix)] -async fn env_zsh_script_spawned_by_python_can_request_escalation_under_zsh_fork() -> Result<()> { - skip_if_no_network!(Ok(())); - - let Some(runtime) = zsh_fork_runtime("zsh-fork env zsh nested escalation test")? else { - return Ok(()); - }; - - let approval_policy = AskForApproval::OnRequest; - let permission_profile = restrictive_workspace_write_profile(); - let outside_dir = tempfile::tempdir_in(std::env::current_dir()?)?; - let outside_path = outside_dir.path().join("zsh-fork-env-zsh-escalated.txt"); - let outside_path_arg = shlex::try_join([outside_path.to_string_lossy().as_ref()])?; - let rules = r#"prefix_rule(pattern=["touch"], decision="prompt")"#.to_string(); - - let server = start_mock_server().await; - let outside_path_for_hook = outside_path.clone(); - let test = build_zsh_fork_test( - &server, - runtime, - approval_policy, - permission_profile.clone(), - move |home| { - let _ = fs::remove_file(&outside_path_for_hook); - let rules_dir = home.join("rules"); - fs::create_dir_all(&rules_dir).unwrap(); - fs::write(rules_dir.join("default.rules"), &rules).unwrap(); - }, - ) - .await?; - - let script_path = test.cwd.path().join("runs-under-env-zsh"); - codex_utils_cargo_bin::write_executable( - &script_path, - &format!( - "#!/usr/bin/env zsh\ntouch {outside_path_arg}\nprint -r -- nested-env-zsh-complete\n" - ), - )?; - - let script_literal = serde_json::to_string(script_path.to_string_lossy().as_ref())?; - let python_script = format!( - "import subprocess; subprocess.run([{script_literal}], check=True, close_fds=False)" - ); - let command = shlex::try_join(["python3", "-c", python_script.as_str()])?; - - let call_id = "zsh-fork-env-zsh-nested-escalation"; - let event = shell_event( - call_id, - &command, - /*timeout_ms*/ 30_000, - SandboxPermissions::UseDefault, - )?; - let _ = mount_sse_once( - &server, - sse(vec![ - ev_response_created("resp-zsh-fork-env-zsh-1"), - event, - ev_completed("resp-zsh-fork-env-zsh-1"), - ]), - ) - .await; - let results = mount_sse_once( - &server, - sse(vec![ - ev_assistant_message("msg-zsh-fork-env-zsh-1", "done"), - ev_completed("resp-zsh-fork-env-zsh-2"), - ]), - ) - .await; - - let session_model = test.session_configured.model.clone(); - let (sandbox_policy, permission_profile) = - turn_permission_fields(permission_profile, test.cwd.path()); - test.codex - .start_or_steer_turn( - TurnInputRequest::user_input(vec![UserInput::Text { - text: "run nested env zsh script through python".into(), - text_elements: Vec::new(), - }]) - .with_thread_settings(ThreadSettingsOverrides { - environments: Some(local_requests(test.config.cwd.clone())), - approval_policy: Some(approval_policy), - approvals_reviewer: Some(ApprovalsReviewer::User), - sandbox_policy: Some(sandbox_policy), - permission_profile, - collaboration_mode: Some(CollaborationMode { - mode: ModeKind::Default, - settings: Settings { - model: session_model, - reasoning_effort: None, - developer_instructions: None, - }, - }), - ..Default::default() - }), - ) - .await?; - - let approval_event = wait_for_event_with_timeout( - &test.codex, - |event| { - matches!( - event, - EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) - ) - }, - Duration::from_secs(10), - ) - .await; - let EventMsg::ExecApprovalRequest(approval) = approval_event else { - panic!("expected nested zsh script to request approval before completion"); - }; - assert!( - approval.command.iter().any(|arg| arg.ends_with("/touch")) - && approval - .command - .iter() - .any(|arg| arg == outside_path.to_string_lossy().as_ref()), - "expected approval for nested touch command, got: {:?}", - approval.command - ); - - test.codex - .submit(Op::ExecApproval { - id: approval.effective_approval_id(), - turn_id: None, - decision: ReviewDecision::Approved, - }) - .await?; - - wait_for_completion(&test).await; - - let result = parse_result(&results.single_request().function_call_output(call_id)); - assert_eq!( - result.exit_code.unwrap_or(0), - 0, - "nested env zsh script should complete successfully: {}", - result.stdout - ); - assert!( - result.stdout.contains("nested-env-zsh-complete"), - "nested script did not report completion: {}", - result.stdout - ); - assert!( - outside_path.exists(), - "approved nested touch should create the out-of-workspace file" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[cfg(unix)] -async fn matched_prefix_rule_runs_unsandboxed_under_zsh_fork() -> Result<()> { - skip_if_no_network!(Ok(())); - - let Some(runtime) = zsh_fork_runtime("zsh-fork prefix rule unsandboxed test")? else { - return Ok(()); - }; - - let approval_policy = AskForApproval::Never; - let permission_profile = restrictive_workspace_write_profile(); - let outside_dir = tempfile::tempdir_in(std::env::current_dir()?)?; - let outside_path = outside_dir - .path() - .join("zsh-fork-prefix-rule-unsandboxed.txt"); - let command = format!("touch {outside_path:?}"); - let rules = r#"prefix_rule(pattern=["touch"], decision="allow")"#.to_string(); - - let server = start_mock_server().await; - let outside_path_for_hook = outside_path.clone(); - let test = build_zsh_fork_test( - &server, - runtime, - approval_policy, - permission_profile.clone(), - move |home| { - let _ = fs::remove_file(&outside_path_for_hook); - let rules_dir = home.join("rules"); - fs::create_dir_all(&rules_dir).unwrap(); - fs::write(rules_dir.join("default.rules"), &rules).unwrap(); - }, - ) - .await?; - - let call_id = "zsh-fork-prefix-rule-unsandboxed"; - let event = shell_event( - call_id, - &command, - /*timeout_ms*/ 1_000, - SandboxPermissions::UseDefault, - )?; - let _ = mount_sse_once( - &server, - sse(vec![ - ev_response_created("resp-zsh-fork-prefix-1"), - event, - ev_completed("resp-zsh-fork-prefix-1"), - ]), - ) - .await; - let results = mount_sse_once( - &server, - sse(vec![ - ev_assistant_message("msg-zsh-fork-prefix-1", "done"), - ev_completed("resp-zsh-fork-prefix-2"), - ]), - ) - .await; - - let session_model = test.session_configured.model.clone(); - let (sandbox_policy, permission_profile) = - turn_permission_fields(permission_profile, test.cwd.path()); - test.codex - .start_or_steer_turn( - TurnInputRequest::user_input(vec![UserInput::Text { - text: "run allowed touch under zsh fork".into(), - text_elements: Vec::new(), - }]) - .with_thread_settings(ThreadSettingsOverrides { - environments: Some(local_requests(test.config.cwd.clone())), - approval_policy: Some(approval_policy), - approvals_reviewer: Some(ApprovalsReviewer::User), - sandbox_policy: Some(sandbox_policy), - permission_profile, - collaboration_mode: Some(CollaborationMode { - mode: ModeKind::Default, - settings: Settings { - model: session_model, - reasoning_effort: None, - developer_instructions: None, - }, - }), - ..Default::default() - }), - ) - .await?; - - wait_for_completion_without_approval(&test).await; - - let result = parse_result(&results.single_request().function_call_output(call_id)); - assert_eq!(result.exit_code.unwrap_or(0), 0); - assert!( - outside_path.exists(), - "expected matched prefix_rule to rerun touch unsandboxed; output: {}", - result.stdout - ); - - Ok(()) -} - /// Verifies that an allowlisted script retains the originating named profile /// when its shell tool call requests escalated, unsandboxed execution. /// @@ -3885,9 +3594,13 @@ async fn explicit_escalation_with_denied_reads(decision: Option) .with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config.features.enable(Feature::UnifiedExec).unwrap(); - config.features.disable(Feature::ShellZshFork).unwrap(); - let mut file_system = - restrictive_workspace_write_profile().file_system_sandbox_policy(); + let mut file_system = PermissionProfile::workspace_write_with( + &[], + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ) + .file_system_sandbox_policy(); file_system.entries.push(FileSystemSandboxEntry::new( config.cwd.join("secret.txt").into(), FileSystemAccessMode::Deny, @@ -3956,217 +3669,6 @@ async fn explicit_escalation_with_denied_reads(decision: Option) Ok(()) } -/// Verifies that zsh-fork applies an inner script's allow rule even when the -/// model invokes an outer wrapper, and that the escalated script retains the -/// named profile needed to reconstruct the original sandbox remotely without -/// dropping managed enterprise requirements. The script treats the inherited -/// environment value as untrusted and accepts only explicitly allowlisted -/// profile names before passing one to `codex sandbox -P`. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[cfg(unix)] -async fn zsh_fork_inner_allowed_script_inherits_active_permission_profile() -> Result<()> { - skip_if_no_network!(Ok(())); - - let Some(runtime) = zsh_fork_runtime("zsh-fork remote sandbox wrapper test")? else { - return Ok(()); - }; - - const HOST: &str = "builder.example.com"; - let approval_policy = AskForApproval::OnRequest; - let script_dir = tempfile::tempdir_in(std::env::current_dir()?)?; - let wrapper_path = script_dir.path().join("remote-bash"); - let remote_bash_path = script_dir.path().join("remote_bash.py"); - let outside_path = script_dir.path().join("remote-bash-unsandboxed-marker"); - let outside_path_literal = serde_json::to_string(&outside_path.to_string_lossy())?; - codex_utils_cargo_bin::write_executable( - &remote_bash_path, - &format!( - r#"#!/usr/bin/env python3 -import argparse -import os -from pathlib import Path -import re -import shlex -import sys - -ALLOWED_HOSTS = ("builder.example.com",) -ALLOWED_PROFILES = (":workspace",) - - -def parse_args(): - parser = argparse.ArgumentParser( - description="Print an ssh command that recreates the current Codex sandbox remotely." - ) - parser.add_argument("--host", required=True) - try: - separator = sys.argv.index("--") - except ValueError: - parser.error("the remote command must follow --") - args = parser.parse_args(sys.argv[1:separator]) - args.command = sys.argv[separator + 1:] - if not args.command: - parser.error("the remote command must not be empty") - if not re.fullmatch(r"[a-z0-9.-]+", args.host) or args.host not in ALLOWED_HOSTS: - parser.error("host is not allowlisted") - return args - - -def main(): - args = parse_args() - profile_name = os.environ.get("CODEX_PERMISSION_PROFILE") - if not profile_name: - raise SystemExit("CODEX_PERMISSION_PROFILE must not be empty") - if profile_name not in ALLOWED_PROFILES: - raise SystemExit("CODEX_PERMISSION_PROFILE is not allowlisted") - - shell_command = shlex.join(args.command) - sandbox_command = shlex.join( - [ - "codex", - "sandbox", - "-P", - profile_name, - "--include-managed-config", - "--", - "bash", - "-lc", - shell_command, - ] - ) - print(shlex.join(["ssh", args.host, sandbox_command])) - - # Test-only proof that this inner script was allowed to run unsandboxed. - Path({outside_path_literal}).write_text("unsandboxed", encoding="utf-8") - - -if __name__ == "__main__": - main() -"# - ), - )?; - let remote_bash_exec = shlex::try_join([remote_bash_path.to_string_lossy().as_ref()])?; - codex_utils_cargo_bin::write_executable( - &wrapper_path, - &format!( - r#"#!/usr/bin/env zsh -exec {remote_bash_exec} "$@" -"# - ), - )?; - - let remote_bash_pattern = serde_json::to_string(&remote_bash_path.to_string_lossy())?; - let rules = format!(r#"prefix_rule(pattern=[{remote_bash_pattern}], decision="allow")"#); - let server = start_mock_server().await; - let mut builder = - zsh_fork_test_builder(runtime, approval_policy).with_pre_build_hook(move |home| { - fs::write( - home.join("config.toml"), - WORKSPACE_PERMISSION_PROFILE_CONFIG, - ) - .expect("write config"); - let rules_dir = home.join("rules"); - fs::create_dir_all(&rules_dir).expect("create rules dir"); - fs::write(rules_dir.join("default.rules"), rules).expect("write rules"); - }); - let test = builder.build(&server).await?; - assert!(!outside_path.starts_with(test.config.cwd.as_path())); - assert_active_workspace_permission_profile(&test); - - let command = shlex::try_join([ - wrapper_path.to_string_lossy().as_ref(), - "--host", - HOST, - "--", - "printf", - "%s", - "hello world", - ])?; - let call_id = "zsh-fork-remote-sandbox-wrapper"; - let event = shell_event( - call_id, - &command, - /*timeout_ms*/ 30_000, - SandboxPermissions::UseDefault, - )?; - let _ = mount_sse_once( - &server, - sse(vec![ - ev_response_created("resp-zsh-fork-remote-wrapper-1"), - event, - ev_completed("resp-zsh-fork-remote-wrapper-1"), - ]), - ) - .await; - let results = mount_sse_once( - &server, - sse(vec![ - ev_assistant_message("msg-zsh-fork-remote-wrapper-1", "done"), - ev_completed("resp-zsh-fork-remote-wrapper-2"), - ]), - ) - .await; - - submit_turn_preserving_active_permission_profile( - &test, - "run the remote sandbox wrapper", - approval_policy, - ) - .await?; - wait_for_completion_without_approval(&test).await; - - let result = parse_result(&results.single_request().function_call_output(call_id)); - assert_eq!( - result.exit_code.unwrap_or(0), - 0, - "the inner remote_bash.py script should run successfully: {}", - result.stdout - ); - let ssh_argv = shlex::split(result.stdout.trim()).context("parse printed ssh command")?; - assert_eq!( - ssh_argv.len(), - 3, - "expected ssh HOST LONG_COMMAND, got: {}", - result.stdout - ); - assert_eq!( - ssh_argv[..2], - ["ssh", HOST], - "remote_bash.py should target only the allowlisted host" - ); - let sandbox_argv = shlex::split(&ssh_argv[2]).context("parse remote sandbox command")?; - assert_eq!( - sandbox_argv.len(), - 9, - "expected codex sandbox ... bash -lc CMD" - ); - assert_eq!( - sandbox_argv[..8], - [ - "codex", - "sandbox", - "-P", - BUILT_IN_PERMISSION_PROFILE_WORKSPACE, - "--include-managed-config", - "--", - "bash", - "-lc", - ], - "remote_bash.py should use the allowlisted inherited profile and managed configuration to reconstruct the Codex sandbox" - ); - let command_argv = shlex::split(&sandbox_argv[8]).context("parse remote bash command")?; - assert_eq!( - command_argv, - ["printf", "%s", "hello world"], - "remote_bash.py should preserve every argument after --" - ); - assert!( - outside_path.exists(), - "the inner allowlisted script should run outside the :workspace sandbox" - ); - - Ok(()) -} - #[tokio::test(flavor = "current_thread")] #[cfg(unix)] async fn invalid_requested_prefix_rule_falls_back_for_compound_command() -> Result<()> { diff --git a/codex-rs/core/tests/suite/cyber_exec_policy.rs b/codex-rs/core/tests/suite/cyber_exec_policy.rs index f3e37df9a9..4c8c7d71d0 100644 --- a/codex-rs/core/tests/suite/cyber_exec_policy.rs +++ b/codex-rs/core/tests/suite/cyber_exec_policy.rs @@ -20,7 +20,6 @@ use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::sse_completed; use core_test_support::responses::start_mock_server; -use core_test_support::skip_if_host_windows; use core_test_support::skip_if_no_network; use core_test_support::skip_if_sandbox; use core_test_support::skip_if_wine_exec; @@ -28,8 +27,6 @@ use core_test_support::submit_thread_settings; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; -use core_test_support::zsh_fork::zsh_fork_runtime; -use core_test_support::zsh_fork::zsh_fork_test_builder; use pretty_assertions::assert_eq; use serde_json::json; use std::fs; @@ -44,12 +41,6 @@ enum ModelSpecialty { General, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum ShellBackend { - Standard, - ZshFork, -} - fn configure_saved_prefix_and_guardian(config: &mut Config) { let policy_path = config.codex_home.join("rules/default.rules"); fs::create_dir_all(policy_path.parent().expect("rules directory")) @@ -108,29 +99,18 @@ async fn submit_model_turn(test: &TestCodex, model: &str, prompt: &str) -> Resul test.submit_text_turn(prompt).await } -#[test_case(ModelSpecialty::Cyber, ShellBackend::Standard; "cyber unified exec is reviewed")] -#[test_case(ModelSpecialty::Cyber, ShellBackend::ZshFork; "cyber zsh unified exec is reviewed")] -#[test_case(ModelSpecialty::General, ShellBackend::Standard; "general unified exec keeps saved approval")] +#[test_case(ModelSpecialty::Cyber; "cyber unified exec is reviewed")] +#[test_case(ModelSpecialty::General; "general unified exec keeps saved approval")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn saved_prefix_only_bypasses_guardian_for_general_models( model_specialty: ModelSpecialty, - shell_backend: ShellBackend, ) -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); skip_if_wine_exec!(Ok(()), "Guardian command reviews require host-native paths"); let server = start_mock_server().await; - let builder = match shell_backend { - ShellBackend::Standard => test_codex(), - ShellBackend::ZshFork => { - skip_if_host_windows!(Ok(())); - let Some(runtime) = zsh_fork_runtime("cyber model zsh-fork saved prefix")? else { - return Ok(()); - }; - zsh_fork_test_builder(runtime, AskForApproval::OnRequest) - } - }; + let builder = test_codex(); let mut builder = builder .with_model_info_override("gpt-5.4", move |model| { if model_specialty == ModelSpecialty::Cyber { @@ -139,10 +119,9 @@ async fn saved_prefix_only_bypasses_guardian_for_general_models( }) .with_config(configure_saved_prefix_and_guardian); let test = builder.build_with_auto_env(&server).await?; - let expected_guardian_review_count = match (model_specialty, shell_backend) { - (ModelSpecialty::General, _) => 0, - (ModelSpecialty::Cyber, ShellBackend::Standard) => 1, - (ModelSpecialty::Cyber, ShellBackend::ZshFork) => 2, + let expected_guardian_review_count = match model_specialty { + ModelSpecialty::General => 0, + ModelSpecialty::Cyber => 1, }; let mut response_bodies = vec![command_response( diff --git a/codex-rs/core/tests/suite/deprecation_notice.rs b/codex-rs/core/tests/suite/deprecation_notice.rs index 6858986f32..63501fee48 100644 --- a/codex-rs/core/tests/suite/deprecation_notice.rs +++ b/codex-rs/core/tests/suite/deprecation_notice.rs @@ -1,9 +1,11 @@ #![cfg(not(target_os = "windows"))] use anyhow::Ok; +use codex_config::test_support::CloudConfigBundleFixture; use codex_features::Feature; use codex_protocol::protocol::DeprecationNoticeEvent; use codex_protocol::protocol::EventMsg; +use core_test_support::responses; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; @@ -139,3 +141,86 @@ async fn emits_deprecation_notice_for_use_legacy_landlock() -> anyhow::Result<() Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn legacy_zsh_config_starts_session_with_migration_notices() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + let server = start_mock_server().await; + let mut builder = test_codex() + .with_pre_build_hook(|home| { + std::fs::write( + home.join("config.toml"), + r#"[features] +shell_zsh_fork = true +unified_exec_zsh_fork = true +"#, + ) + .expect("write legacy configuration"); + }) + .with_cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + "[features]\nshell_zsh_fork = true\nunified_exec_zsh_fork = true\n", + ), + ); + let test = builder.build_with_auto_env(&server).await?; + + for feature in ["shell_zsh_fork", "unified_exec_zsh_fork"] { + let expected = DeprecationNoticeEvent { + summary: format!("`[features].{feature}` is deprecated and ignored."), + details: Some(format!( + "The patched zsh backend has been removed. Shell commands now use the standard shell executor. Remove `{feature}` from [features] in config.toml or profile configuration files." + )), + }; + let notice = wait_for_event_match(&test.codex, |event| match event { + EventMsg::DeprecationNotice(notice) if notice.summary == expected.summary => { + Some(notice.clone()) + } + _ => None, + }) + .await; + assert_eq!( + (notice.summary, notice.details), + (expected.summary, expected.details) + ); + } + let shell = match core_test_support::test_target_os() { + core_test_support::TestTargetOs::Linux | core_test_support::TestTargetOs::MacOs => { + "/bin/sh" + } + core_test_support::TestTargetOs::Windows => "powershell", + }; + let arguments = serde_json::json!({ + "cmd": "echo migrated-shell", + "shell": shell, + "login": false, + "yield_time_ms": 10_000, + }); + let mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_function_call( + "shell-migration", + "exec_command", + &arguments.to_string(), + ), + responses::ev_completed("response-1"), + ]), + responses::sse(vec![responses::ev_completed("response-2")]), + ], + ) + .await; + test.submit_turn("run a command with the configured shell") + .await?; + let output = mock + .function_call_output_text("shell-migration") + .expect("shell tool output"); + assert!(output.contains("Process exited with code 0\n"), "{output}"); + assert_eq!( + output + .split_once("Output:\n") + .map(|(_, stdout)| stdout.trim()), + Some("migrated-shell") + ); + Ok(()) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index d64c3a3ed2..5d2cd461ad 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -205,7 +205,6 @@ mod search_tool; mod settings_commits; mod settings_constraints; mod shell_snapshot; -mod skill_approval; mod skills; mod skills_extension; mod spawn_agent_description; @@ -240,8 +239,6 @@ mod unified_exec_mxc_powershell; mod unified_exec_process_events; mod unified_exec_stdin_approval; mod unified_exec_stdin_review_size; -#[cfg(unix)] -mod unified_exec_zsh_fork_approvals; mod unstable_features_warning; mod user_notification; mod user_shell_cmd; diff --git a/codex-rs/core/tests/suite/model_provider_requirements_tests.rs b/codex-rs/core/tests/suite/model_provider_requirements_tests.rs index 6bb8c9b68a..9d28aece9c 100644 --- a/codex-rs/core/tests/suite/model_provider_requirements_tests.rs +++ b/codex-rs/core/tests/suite/model_provider_requirements_tests.rs @@ -183,11 +183,6 @@ X-Managed = "required" overridden.cwd.to_path_buf(), &overridden.config_layer_stack, overridden.codex_home.clone(), - overridden - .zsh_path - .clone() - .map(codex_utils_absolute_path::AbsolutePathBuf::try_from) - .transpose()?, ) .await?; assert_eq!(rebuilt.model_provider, test.config.model_provider); diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index c93940599d..5749351983 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -68,8 +68,6 @@ use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; use core_test_support::wait_for_mcp_server; -use core_test_support::zsh_fork::zsh_fork_runtime; -use core_test_support::zsh_fork::zsh_fork_test_builder; use pretty_assertions::assert_eq; use tempfile::TempDir; use test_case::test_case; @@ -466,12 +464,8 @@ async fn shared_analytics_client_preserves_session_products() -> Result<()> { Ok(()) } -#[test_case(false; "classic shell")] -#[test_case(true; "zsh-fork shell")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn persisted_remote_plugin_command_attribution_flows_through_turn_context( - zsh_fork: bool, -) -> Result<()> { +async fn persisted_remote_plugin_command_attribution_flows_through_turn_context() -> Result<()> { skip_if_target_windows!(Ok(()), "executes a POSIX shell script"); skip_if_no_network!(Ok(())); skip_if_remote!( @@ -495,14 +489,7 @@ async fn persisted_remote_plugin_command_attribution_flows_through_turn_context( plugin_root.join("analytics.yaml"), "version: 1\noperations: {scan: {path: ./scripts/run.sh, measurements: {files_scanned: {}}}}\n", )?; - let builder = if zsh_fork { - let Some(runtime) = zsh_fork_runtime("zsh-fork plugin measurement test")? else { - return Ok(()); - }; - zsh_fork_test_builder(runtime, AskForApproval::Never) - } else { - test_codex() - }; + let builder = test_codex(); let command = shlex::try_join(["/bin/sh", script_path.to_string_lossy().as_ref()])?; let call_id = "remote-plugin-command"; let arguments = serde_json::to_string(&serde_json::json!({ diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 3dbaa5f376..273936769f 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -1915,13 +1915,6 @@ async fn pending_attachment_installs_configuration_before_waiting_turn_resumes() .enable(Feature::StableEnvironmentTools) .expect("enable stable environment tools"); assert!(config.features.enable(Feature::DeferredExecutor).is_ok()); - if cfg!(unix) { - assert!(config.features.enable(Feature::ShellZshFork).is_ok()); - // Calls stop at environment/login validation, so these paths are never executed. - let executable = std::env::current_exe().expect("current executable"); - config.zsh_path = Some(executable.clone()); - config.main_execve_wrapper_exe = Some(executable); - } assert!( config .features diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs deleted file mode 100644 index bc89d4acef..0000000000 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ /dev/null @@ -1,286 +0,0 @@ -#![allow(clippy::unwrap_used)] -#![cfg(unix)] - -use anyhow::Result; -use codex_core::TurnInputRequest; -use codex_protocol::config_types::CollaborationMode; -use codex_protocol::config_types::ModeKind; -use codex_protocol::config_types::Settings; -use codex_protocol::models::PermissionProfile; -use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::ExecApprovalRequestEvent; -use codex_protocol::protocol::GranularApprovalConfig; -use codex_protocol::protocol::ThreadSettingsOverrides; -use codex_protocol::user_input::UserInput; -use core_test_support::responses::mount_function_call_agent_response; -use core_test_support::responses::start_mock_server; -use core_test_support::skip_if_no_network; -use core_test_support::test_codex::TestCodex; -use core_test_support::test_codex::local_requests; -use core_test_support::test_codex::turn_permission_fields; -use core_test_support::wait_for_event; -use core_test_support::wait_for_event_match; -use core_test_support::zsh_fork::build_zsh_fork_test; -use core_test_support::zsh_fork::restrictive_workspace_write_profile; -use core_test_support::zsh_fork::zsh_fork_runtime; -use std::fs; -use std::path::Path; -use std::path::PathBuf; - -fn write_skill_metadata(home: &Path, name: &str, contents: &str) -> Result<()> { - let metadata_dir = home.join("skills").join(name).join("agents"); - fs::create_dir_all(&metadata_dir)?; - fs::write(metadata_dir.join("openai.yaml"), contents)?; - Ok(()) -} - -fn exec_command_arguments(command: &str) -> Result { - Ok(serde_json::to_string(&serde_json::json!({ - "cmd": command, - "yield_time_ms": 500, - }))?) -} - -async fn submit_turn_with_policies( - test: &TestCodex, - prompt: &str, - approval_policy: AskForApproval, - permission_profile: PermissionProfile, -) -> Result<()> { - let (sandbox_policy, permission_profile) = - turn_permission_fields(permission_profile, test.cwd_path()); - test.codex - .start_or_steer_turn( - TurnInputRequest::user_input(vec![UserInput::Text { - text: prompt.to_string(), - text_elements: Vec::new(), - }]) - .with_thread_settings(ThreadSettingsOverrides { - environments: Some(local_requests(test.config.cwd.clone())), - approval_policy: Some(approval_policy), - sandbox_policy: Some(sandbox_policy), - permission_profile, - collaboration_mode: Some(CollaborationMode { - mode: ModeKind::Default, - settings: Settings { - model: test.session_configured.model.clone(), - reasoning_effort: None, - developer_instructions: None, - }, - }), - ..Default::default() - }), - ) - .await?; - Ok(()) -} - -#[cfg(unix)] -fn write_skill_with_shell_script_contents( - home: &Path, - name: &str, - script_name: &str, - script_contents: &str, -) -> Result { - let skill_dir = home.join("skills").join(name); - let scripts_dir = skill_dir.join("scripts"); - fs::create_dir_all(&scripts_dir)?; - fs::write( - skill_dir.join("SKILL.md"), - format!( - r#"--- -name: {name} -description: {name} skill ---- -"# - ), - )?; - - let script_path = scripts_dir.join(script_name); - codex_utils_cargo_bin::write_executable(&script_path, script_contents)?; - Ok(script_path) -} - -fn skill_script_command(test: &TestCodex, script_name: &str) -> Result { - let script_path = fs::canonicalize( - test.codex_home_path() - .join("skills/mbolin-test-skill/scripts") - .join(script_name), - )?; - Ok(shlex::try_join([script_path.to_string_lossy().as_ref()])?) -} - -async fn wait_for_exec_approval_request(test: &TestCodex) -> Option { - wait_for_event_match(test.codex.as_ref(), |event| match event { - EventMsg::ExecApprovalRequest(request) => Some(Some(request.clone())), - EventMsg::TurnComplete(_) => Some(None), - _ => None, - }) - .await -} - -async fn wait_for_turn_complete(test: &TestCodex) { - wait_for_event(test.codex.as_ref(), |event| { - matches!(event, EventMsg::TurnComplete(_)) - }) - .await; -} - -fn output_shows_sandbox_denial(output: &str) -> bool { - output.contains("Permission denied") - || output.contains("Operation not permitted") - || output.contains("Read-only file system") -} - -#[cfg(unix)] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_zsh_fork_skill_scripts_ignore_declared_permissions() -> Result<()> { - skip_if_no_network!(Ok(())); - - let Some(runtime) = zsh_fork_runtime("zsh-fork skill script ignores permissions test")? else { - return Ok(()); - }; - - let approval_policy = AskForApproval::Granular(GranularApprovalConfig { - sandbox_approval: true, - rules: true, - skill_approval: false, - request_permissions: true, - mcp_elicitations: true, - }); - let workspace_write_profile = restrictive_workspace_write_profile(); - let outside_dir = tempfile::tempdir_in(std::env::current_dir()?)?; - let allowed_dir = outside_dir.path().join("allowed-output"); - fs::create_dir_all(&allowed_dir)?; - let allowed_path = allowed_dir.join("allowed.txt"); - let allowed_path_quoted = shlex::try_join([allowed_path.to_string_lossy().as_ref()])?; - let script_contents = format!( - "#!/bin/sh\nprintf '%s' allowed > {allowed_path_quoted}\nif [ -f {allowed_path_quoted} ]; then cat {allowed_path_quoted}; fi\n" - ); - let permissions_yaml = format!( - "permissions:\n file_system:\n write:\n - \"{}\"\n", - allowed_dir.display() - ); - - let server = start_mock_server().await; - let allowed_path_for_hook = allowed_path.clone(); - let script_contents_for_hook = script_contents.clone(); - let test = build_zsh_fork_test( - &server, - runtime, - approval_policy, - workspace_write_profile.clone(), - move |home| { - let _ = fs::remove_file(&allowed_path_for_hook); - write_skill_with_shell_script_contents( - home, - "mbolin-test-skill", - "sandboxed.sh", - &script_contents_for_hook, - ) - .unwrap(); - write_skill_metadata(home, "mbolin-test-skill", &permissions_yaml).unwrap(); - }, - ) - .await?; - - let command = skill_script_command(&test, "sandboxed.sh")?; - let call_id = "zsh-fork-skill-script-ignores-permissions"; - let arguments = exec_command_arguments(&command)?; - let mocks = - mount_function_call_agent_response(&server, call_id, &arguments, "exec_command").await; - - submit_turn_with_policies( - &test, - "use $mbolin-test-skill", - approval_policy, - workspace_write_profile, - ) - .await?; - - let approval = wait_for_exec_approval_request(&test).await; - assert!( - approval.is_none(), - "expected skill script execution to skip the removed skill approval path" - ); - - wait_for_turn_complete(&test).await; - - let call_output = mocks - .completion - .single_request() - .function_call_output(call_id); - let output = call_output["output"].as_str().unwrap_or_default(); - assert!( - !output.contains("Execution denied: Execution forbidden by policy"), - "skill script should now be governed by the turn sandbox, not the removed skill approval gate: {output:?}" - ); - assert!( - output_shows_sandbox_denial(output) || !output.contains("allowed"), - "expected the turn sandbox to block the out-of-workspace write, got output: {output:?}" - ); - assert!( - !allowed_path.exists(), - "declared skill permissions should not widen script execution beyond the turn sandbox" - ); - - Ok(()) -} - -#[cfg(unix)] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_zsh_fork_still_enforces_workspace_write_sandbox() -> Result<()> { - skip_if_no_network!(Ok(())); - - let Some(runtime) = zsh_fork_runtime("zsh-fork workspace sandbox test")? else { - return Ok(()); - }; - - let server = start_mock_server().await; - let tool_call_id = "zsh-fork-workspace-write-deny"; - let outside_path = "/tmp/codex-zsh-fork-workspace-write-deny.txt"; - let workspace_write_profile = restrictive_workspace_write_profile(); - let _ = fs::remove_file(outside_path); - let test = build_zsh_fork_test( - &server, - runtime, - AskForApproval::Never, - workspace_write_profile.clone(), - move |_| { - let _ = fs::remove_file(outside_path); - }, - ) - .await?; - - let command = format!("touch {outside_path}"); - let arguments = exec_command_arguments(&command)?; - let mocks = - mount_function_call_agent_response(&server, tool_call_id, &arguments, "exec_command").await; - - submit_turn_with_policies( - &test, - "write outside workspace with zsh fork", - AskForApproval::Never, - workspace_write_profile, - ) - .await?; - - wait_for_turn_complete(&test).await; - - let call_output = mocks - .completion - .single_request() - .function_call_output(tool_call_id); - let output = call_output["output"].as_str().unwrap_or_default(); - assert!( - output_shows_sandbox_denial(output), - "expected sandbox denial, got output: {output:?}" - ); - assert!( - !Path::new(outside_path).exists(), - "command should not write outside workspace under WorkspaceWrite policy" - ); - - Ok(()) -} diff --git a/codex-rs/core/tests/suite/unified_exec_launch_failure_tests.rs b/codex-rs/core/tests/suite/unified_exec_launch_failure_tests.rs index 14815a3491..501c35f8db 100644 --- a/codex-rs/core/tests/suite/unified_exec_launch_failure_tests.rs +++ b/codex-rs/core/tests/suite/unified_exec_launch_failure_tests.rs @@ -80,7 +80,6 @@ async fn interrupted_one_shot_launch_failure_completes_and_persists() -> Result< ), ) .with_config(|config| { - config.features.disable(Feature::ShellZshFork).unwrap(); config.features.disable(Feature::ShellSnapshot).unwrap(); config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never); config diff --git a/codex-rs/core/tests/suite/unified_exec_stdin_approval.rs b/codex-rs/core/tests/suite/unified_exec_stdin_approval.rs index 6df66e35ff..705eccf35e 100644 --- a/codex-rs/core/tests/suite/unified_exec_stdin_approval.rs +++ b/codex-rs/core/tests/suite/unified_exec_stdin_approval.rs @@ -209,7 +209,6 @@ async fn stdin_reviews_escalated_terminal_with_denied_reads_before_delivering_in .enable(Feature::ExecPermissionApprovals) .unwrap(); config.features.enable(Feature::UnifiedExec).unwrap(); - config.features.disable(Feature::ShellZshFork).unwrap(); config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); let mut file_system = PermissionProfile::read_only().file_system_sandbox_policy(); file_system.entries.push(FileSystemSandboxEntry::new( diff --git a/codex-rs/core/tests/suite/unified_exec_zsh_fork_approvals.rs b/codex-rs/core/tests/suite/unified_exec_zsh_fork_approvals.rs deleted file mode 100644 index d41e2a26a7..0000000000 --- a/codex-rs/core/tests/suite/unified_exec_zsh_fork_approvals.rs +++ /dev/null @@ -1,982 +0,0 @@ -use anyhow::Context; -use anyhow::Result; -use codex_config::permissions_toml::FilesystemPermissionToml; -use codex_config::permissions_toml::PermissionProfileToml; -use codex_config::types::ApprovalsReviewer; -use codex_core::TurnInputRequest; -use codex_core::config::Config; -use codex_core::sandboxing::SandboxPermissions; -use codex_extension_api::ExtensionRegistryBuilder; -use codex_features::Feature; -use codex_protocol::config_types::CollaborationMode; -use codex_protocol::config_types::ModeKind; -use codex_protocol::config_types::Settings; -use codex_protocol::models::PermissionProfile; -use codex_protocol::permissions::FileSystemAccessMode; -use codex_protocol::permissions::FileSystemPath; -use codex_protocol::permissions::FileSystemSandboxEntry; -use codex_protocol::permissions::FileSystemSandboxPolicy; -use codex_protocol::permissions::FileSystemSpecialPath; -use codex_protocol::permissions::NetworkSandboxPolicy; -use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::ExecApprovalRequestEvent; -use codex_protocol::protocol::GuardianAssessmentAction; -use codex_protocol::protocol::GuardianAssessmentStatus; -use codex_protocol::protocol::GuardianCommandSource; -use codex_protocol::protocol::Op; -use codex_protocol::protocol::ReviewDecision; -use codex_protocol::protocol::ThreadSettingsOverrides; -use codex_protocol::user_input::UserInput; -use core_test_support::responses::ResponseMock; -use core_test_support::responses::ev_assistant_message; -use core_test_support::responses::ev_completed; -use core_test_support::responses::ev_function_call; -use core_test_support::responses::ev_response_created; -use core_test_support::responses::mount_sse_once; -use core_test_support::responses::mount_sse_sequence; -use core_test_support::responses::sse; -use core_test_support::responses::start_mock_server; -use core_test_support::skip_if_no_network; -use core_test_support::test_codex::TestCodex; -use core_test_support::test_codex::local_requests; -use core_test_support::test_codex::turn_permission_fields; -use core_test_support::wait_for_event; -use core_test_support::wait_for_event_with_timeout; -use core_test_support::zsh_fork::restrictive_workspace_write_profile; -use core_test_support::zsh_fork::zsh_fork_runtime; -use core_test_support::zsh_fork::zsh_fork_test_builder; -use pretty_assertions::assert_eq; -use regex_lite::Regex; -use serde_json::Value; -use serde_json::json; -use std::fs; -use std::path::Path; -use std::sync::Arc; -use std::time::Duration; -use toml_edit::Key as TomlKey; -use wiremock::MockServer; - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn unified_exec_zsh_fork_parent_approval_preserves_denied_reads() -> Result<()> { - skip_if_no_network!(Ok(())); - - let denied_dir = tempfile::tempdir_in(std::env::current_dir()?)?; - let denied_path = denied_dir.path().join("secret.env"); - let secret = "unified-exec-zsh-fork-denied-read-secret"; - fs::write(&denied_path, format!("{secret}\n"))?; - let permission_profile = denied_read_permission_profile(&denied_path)?; - assert!( - permission_profile - .file_system_sandbox_policy() - .has_denied_read_restrictions(), - "test must exercise a permission profile with denied reads" - ); - - let approval_policy = AskForApproval::OnRequest; - let command = format!("cat {denied_path:?}"); - let Some((server, test)) = build_unified_exec_zsh_fork_test_or_skip( - "unified-exec zsh-fork denied-read approval test", - approval_policy, - permission_profile, - move |_home| {}, - ) - .await? - else { - return Ok(()); - }; - - let call_id = "uexec-zsh-fork-parent-approval-denied-read"; - let results = mount_unified_exec_command( - &server, - "uexec-zsh-fork-denied-read", - call_id, - &command, - "attempt a denied read for the test", - ) - .await?; - submit_turn_with_session_permissions( - &test, - "run approved unified exec denied read through zsh fork", - approval_policy, - ApprovalsReviewer::User, - ) - .await?; - approve_expected_exec(&test, &command).await?; - wait_for_completion_without_approval(&test).await; - - let result = command_result(&results, call_id); - assert_ne!( - result.exit_code.unwrap_or(0), - 0, - "denied-read command should stay sandboxed after parent approval" - ); - assert!( - !result.stdout.contains(secret), - "denied-read command unexpectedly printed the secret: {}", - result.stdout - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn unified_exec_zsh_fork_parent_approval_escalates_intercepted_exec() -> Result<()> { - skip_if_no_network!(Ok(())); - - let approval_policy = AskForApproval::OnRequest; - let permission_profile = restrictive_workspace_write_profile(); - let outside_dir = tempfile::tempdir_in(std::env::current_dir()?)?; - let outside_path = outside_dir - .path() - .join("unified-exec-zsh-fork-parent-approval.txt"); - let command = format!("printf hi > {outside_path:?}"); - - let outside_path_for_hook = outside_path.clone(); - let Some((server, test)) = build_unified_exec_zsh_fork_test_or_skip( - "unified-exec zsh-fork parent approval test", - approval_policy, - permission_profile, - move |_home| { - let _ = fs::remove_file(&outside_path_for_hook); - }, - ) - .await? - else { - return Ok(()); - }; - - let call_id = "uexec-zsh-fork-parent-approval"; - let results = mount_unified_exec_command( - &server, - "uexec-zsh-fork-parent-approval", - call_id, - &command, - "write outside the workspace for the test", - ) - .await?; - submit_turn_with_session_permissions( - &test, - "run approved unified exec through zsh fork", - approval_policy, - ApprovalsReviewer::User, - ) - .await?; - approve_expected_exec(&test, &command).await?; - wait_for_completion_without_approval(&test).await; - - let result = command_result(&results, call_id); - assert_eq!( - result.exit_code.unwrap_or(0), - 0, - "approved unified exec zsh-fork command should complete: {}", - result.stdout - ); - let contents = fs::read_to_string(&outside_path) - .with_context(|| format!("read {}", outside_path.display()))?; - assert_eq!( - contents, "hi", - "approved parent sandbox override should allow zsh-fork shell redirection to write outside the workspace" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn unified_exec_zsh_fork_parent_approval_keeps_explicit_prompt_rule() -> Result<()> { - skip_if_no_network!(Ok(())); - - let approval_policy = AskForApproval::OnRequest; - let permission_profile = restrictive_workspace_write_profile(); - let outside_dir = tempfile::tempdir_in(std::env::current_dir()?)?; - let outside_path = outside_dir - .path() - .join("unified-exec-zsh-fork-explicit-prompt-rule.txt"); - let intercepted_command = format!("touch {outside_path:?}"); - let command = format!("{intercepted_command} && {intercepted_command}"); - let rules = r#"prefix_rule(pattern=["touch"], decision="prompt")"#.to_string(); - - let outside_path_for_hook = outside_path.clone(); - let Some((server, test)) = build_unified_exec_zsh_fork_test_or_skip( - "unified-exec zsh-fork prompt rule approval test", - approval_policy, - permission_profile, - move |home| { - let _ = fs::remove_file(&outside_path_for_hook); - let rules_dir = home.join("rules"); - fs::create_dir_all(&rules_dir).unwrap(); - fs::write(rules_dir.join("default.rules"), &rules).unwrap(); - }, - ) - .await? - else { - return Ok(()); - }; - - let call_id = "uexec-zsh-fork-parent-approval-explicit-prompt-rule"; - let results = mount_unified_exec_command( - &server, - "uexec-zsh-fork-prompt-rule", - call_id, - &command, - "write outside the workspace for the test", - ) - .await?; - submit_turn_with_session_permissions( - &test, - "run approved unified exec prompt rule through zsh fork", - approval_policy, - ApprovalsReviewer::User, - ) - .await?; - approve_expected_exec(&test, &command).await?; - - let mut intercepted_approval_ids = Vec::new(); - for _ in 0..2 { - let approval_event = wait_for_event_with_timeout( - &test.codex, - |event| { - matches!( - event, - EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) - ) - }, - Duration::from_secs(10), - ) - .await; - let EventMsg::ExecApprovalRequest(inner_approval) = approval_event else { - panic!("expected explicit prompt rule approval before completion"); - }; - assert_eq!( - ( - inner_approval.call_id.as_str(), - inner_approval.environment_id.as_deref(), - inner_approval.available_decisions.as_deref(), - ), - ( - call_id, - Some(codex_exec_server::LOCAL_ENVIRONMENT_ID), - Some([ReviewDecision::Approved, ReviewDecision::Abort].as_slice()), - ) - ); - let approval_id = inner_approval - .approval_id - .as_ref() - .context("intercepted execve should have a subprocess approval id")?; - assert_ne!(approval_id, call_id); - assert!( - !intercepted_approval_ids.contains(approval_id), - "identical intercepted commands must receive distinct approval ids" - ); - intercepted_approval_ids.push(approval_id.clone()); - assert!( - inner_approval - .command - .iter() - .any(|arg| arg.ends_with("/touch")) - && inner_approval - .command - .iter() - .any(|arg| arg == outside_path.to_string_lossy().as_ref()), - "expected explicit prompt rule approval for intercepted touch, got: {:?}", - inner_approval.command - ); - - approve_exec(&test, inner_approval.effective_approval_id()).await?; - } - wait_for_completion(&test).await; - - let result = command_result(&results, call_id); - assert_eq!( - result.exit_code.unwrap_or(0), - 0, - "approved unified exec zsh-fork prompt-rule command should complete: {}", - result.stdout - ); - assert!( - outside_path.exists(), - "approved intercepted touch should create the out-of-workspace file" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn unified_exec_zsh_fork_guardian_reviews_intercepted_execve() -> Result<()> { - skip_if_no_network!(Ok(())); - - let approval_policy = AskForApproval::OnRequest; - let permission_profile = restrictive_workspace_write_profile(); - let outside_dir = tempfile::tempdir_in(std::env::current_dir()?)?; - let outside_path = outside_dir - .path() - .join("unified-exec-zsh-fork-guardian-execve.txt"); - let command = format!("touch {outside_path:?}"); - let rules = r#"prefix_rule(pattern=["touch"], decision="prompt")"#.to_string(); - - let outside_path_for_hook = outside_path.clone(); - let Some((server, test)) = build_unified_exec_zsh_fork_test_or_skip( - "unified-exec zsh-fork guardian execve approval test", - approval_policy, - permission_profile, - move |home| { - let _ = fs::remove_file(&outside_path_for_hook); - let rules_dir = home.join("rules"); - fs::create_dir_all(&rules_dir).unwrap(); - fs::write(rules_dir.join("default.rules"), &rules).unwrap(); - }, - ) - .await? - else { - return Ok(()); - }; - - let call_id = "uexec-zsh-fork-guardian-execve"; - let parent_tool_call = exec_command_event( - call_id, - &command, - Some(30_000), - SandboxPermissions::RequireEscalated, - "exercise Guardian review of intercepted execve", - )?; - let responses = mount_sse_sequence( - &server, - vec![ - sse(vec![ - ev_response_created("resp-guardian-execve-parent"), - parent_tool_call, - ev_completed("resp-guardian-execve-parent"), - ]), - sse(vec![ - ev_response_created("resp-guardian-parent-review"), - ev_assistant_message("msg-guardian-parent-review", r#"{"outcome":"allow"}"#), - ev_completed("resp-guardian-parent-review"), - ]), - sse(vec![ - ev_response_created("resp-guardian-execve-review"), - ev_assistant_message("msg-guardian-execve-review", r#"{"outcome":"allow"}"#), - ev_completed("resp-guardian-execve-review"), - ]), - sse(vec![ - ev_response_created("resp-guardian-execve-done"), - ev_assistant_message("msg-guardian-execve-done", "done"), - ev_completed("resp-guardian-execve-done"), - ]), - ], - ) - .await; - - submit_turn_with_session_permissions( - &test, - "run unified exec with an intercepted command requiring Guardian review", - approval_policy, - ApprovalsReviewer::AutoReview, - ) - .await?; - - let mut intercepted_assessments = Vec::new(); - loop { - let event = tokio::time::timeout(Duration::from_secs(30), test.codex.next_event()) - .await - .context("timed out waiting for intercepted execve Guardian review")??; - match event.msg { - EventMsg::GuardianAssessment(assessment) - if assessment.status == GuardianAssessmentStatus::Approved - && matches!(assessment.action, GuardianAssessmentAction::Execve { .. }) => - { - intercepted_assessments.push(assessment); - } - EventMsg::ExecApprovalRequest(_) => { - panic!("Guardian-reviewed intercepted execution must not prompt the user") - } - EventMsg::TurnComplete(_) => break, - _ => {} - } - } - - let [assessment] = intercepted_assessments.as_slice() else { - panic!( - "expected one approved intercepted execve Guardian assessment, got {intercepted_assessments:?}" - ); - }; - let GuardianAssessmentAction::Execve { - source, - program, - argv, - cwd, - } = &assessment.action - else { - unreachable!("intercepted assessments contain only execve actions"); - }; - let expected_cwd = test.config.cwd.canonicalize()?; - assert_eq!( - ( - *source, - argv.as_slice(), - cwd, - assessment.target_item_id.as_deref(), - ), - ( - GuardianCommandSource::UnifiedExec, - [ - "touch".to_string(), - outside_path.to_string_lossy().into_owned() - ] - .as_slice(), - &expected_cwd, - Some(call_id), - ) - ); - assert!( - program.ends_with("/touch"), - "unexpected execve program: {program}" - ); - - let guardian_requests = responses - .requests() - .into_iter() - .filter(|request| { - request.body_json()["client_metadata"]["x-openai-subagent"].as_str() == Some("guardian") - }) - .collect::>(); - assert_eq!(guardian_requests.len(), 2); - assert!(guardian_requests[1].body_contains_text(&outside_path.to_string_lossy())); - assert!( - outside_path.exists(), - "Guardian-approved intercepted touch should create the out-of-workspace file" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn unified_exec_zsh_fork_guardian_reviews_persistent_terminal_in_current_turn() -> Result<()> -{ - skip_if_no_network!(Ok(())); - - let approval_policy = AskForApproval::OnRequest; - let outside_dir = tempfile::tempdir_in(std::env::current_dir()?)?; - let outside_path = outside_dir - .path() - .join("unified-exec-zsh-fork-current-turn.txt"); - let initial_denied_path = outside_dir.path().join("original-environment-private"); - let permission_profile = denied_read_permission_profile(&initial_denied_path)?; - let rules = r#"prefix_rule(pattern=["touch"], decision="prompt")"#.to_string(); - - let outside_path_for_hook = outside_path.clone(); - let Some((server, test)) = build_unified_exec_zsh_fork_test_or_skip( - "unified-exec zsh-fork current-turn guardian approval test", - approval_policy, - permission_profile, - move |home| { - let _ = fs::remove_file(&outside_path_for_hook); - let rules_dir = home.join("rules"); - fs::create_dir_all(&rules_dir).unwrap(); - fs::write(rules_dir.join("default.rules"), &rules).unwrap(); - }, - ) - .await? - else { - return Ok(()); - }; - - let open_call_id = "uexec-zsh-fork-cross-turn-open"; - let open_command = "while IFS= read -r command; do eval \"$command\"; done"; - let open_args = json!({ - "cmd": open_command, - "yield_time_ms": 250, - "tty": true, - "sandbox_permissions": SandboxPermissions::RequireEscalated, - "justification": "start an interactive terminal for the next turn", - }); - let write_call_id = "uexec-zsh-fork-cross-turn-write"; - let write_args = json!({ - "chars": format!("touch {outside_path:?}\nexit\n"), - "session_id": 1000, - "yield_time_ms": 5_000, - }); - let responses = mount_sse_sequence( - &server, - vec![ - sse(vec![ - ev_response_created("resp-cross-turn-open"), - ev_function_call( - open_call_id, - "exec_command", - &serde_json::to_string(&open_args)?, - ), - ev_completed("resp-cross-turn-open"), - ]), - sse(vec![ - ev_response_created("resp-cross-turn-first-done"), - ev_assistant_message("msg-cross-turn-first-done", "terminal is running"), - ev_completed("resp-cross-turn-first-done"), - ]), - sse(vec![ - ev_response_created("resp-cross-turn-write"), - ev_function_call( - write_call_id, - "write_stdin", - &serde_json::to_string(&write_args)?, - ), - ev_completed("resp-cross-turn-write"), - ]), - sse(vec![ - ev_response_created("resp-cross-turn-write-guardian"), - ev_assistant_message("msg-cross-turn-write-guardian", r#"{"outcome":"allow"}"#), - ev_completed("resp-cross-turn-write-guardian"), - ]), - sse(vec![ - ev_response_created("resp-cross-turn-execve-guardian"), - ev_assistant_message("msg-cross-turn-execve-guardian", r#"{"outcome":"allow"}"#), - ev_completed("resp-cross-turn-execve-guardian"), - ]), - sse(vec![ - ev_response_created("resp-cross-turn-second-done"), - ev_assistant_message("msg-cross-turn-second-done", "done"), - ev_completed("resp-cross-turn-second-done"), - ]), - ], - ) - .await; - - submit_turn_with_session_permissions( - &test, - "start a persistent terminal with user approvals", - approval_policy, - ApprovalsReviewer::User, - ) - .await?; - approve_expected_exec(&test, open_command).await?; - let first_completion = wait_for_event(&test.codex, |event| { - matches!(event, EventMsg::TurnComplete(_)) - }) - .await; - let EventMsg::TurnComplete(first_completion) = first_completion else { - unreachable!("completion wait only returns turn-complete events"); - }; - - let next_cwd = test.config.cwd.join("next-turn"); - fs::create_dir(&next_cwd)?; - let next_denied_path = next_cwd.join("next-environment-private"); - let (sandbox_policy, permission_profile) = turn_permission_fields( - denied_read_permission_profile(next_denied_path.as_path())?, - next_cwd.as_path(), - ); - test.codex - .start_or_steer_turn( - TurnInputRequest::user_input(vec![UserInput::Text { - text: "run a command in the persistent terminal with Guardian approvals".into(), - text_elements: Vec::new(), - }]) - .with_thread_settings(ThreadSettingsOverrides { - environments: Some(local_requests(next_cwd)), - approvals_reviewer: Some(ApprovalsReviewer::AutoReview), - sandbox_policy: Some(sandbox_policy), - permission_profile, - ..Default::default() - }), - ) - .await?; - - let mut current_turn_id = None; - let mut stdin_assessment = None; - let mut intercepted_assessment = None; - loop { - let event = tokio::time::timeout(Duration::from_secs(30), test.codex.next_event()) - .await - .context("timed out waiting for current-turn intercepted execve Guardian review")??; - match event.msg { - EventMsg::TurnStarted(started) => current_turn_id = Some(started.turn_id), - EventMsg::GuardianAssessment(assessment) - if assessment.status == GuardianAssessmentStatus::Approved - && matches!( - &assessment.action, - GuardianAssessmentAction::WriteStdin { approval_id, .. } - if approval_id == write_call_id - ) => - { - stdin_assessment = Some(assessment); - } - EventMsg::GuardianAssessment(assessment) - if assessment.status == GuardianAssessmentStatus::Approved - && matches!(assessment.action, GuardianAssessmentAction::Execve { .. }) => - { - intercepted_assessment = Some(assessment); - } - EventMsg::ExecApprovalRequest(_) => { - panic!("persistent-terminal approval used the previous turn's user reviewer") - } - EventMsg::TurnComplete(_) => break, - _ => {} - } - } - - let current_turn_id = current_turn_id.context("expected the second turn to start")?; - assert_ne!(current_turn_id, first_completion.turn_id); - let stdin_assessment = - stdin_assessment.context("expected an approved Guardian assessment for stdin")?; - assert_eq!( - ( - stdin_assessment.turn_id.as_str(), - stdin_assessment.target_item_id.as_deref(), - ), - (current_turn_id.as_str(), Some(open_call_id)), - ); - let assessment = intercepted_assessment - .context("expected an approved Guardian assessment for the persistent terminal")?; - assert_eq!(assessment.turn_id, current_turn_id); - assert_eq!(assessment.target_item_id.as_deref(), Some(open_call_id)); - assert!( - outside_path.exists(), - "Guardian-approved command from the second turn should create the file" - ); - - let guardian_requests = responses - .requests() - .into_iter() - .filter(|request| { - request.body_json()["client_metadata"]["x-openai-subagent"].as_str() == Some("guardian") - }) - .collect::>(); - assert_eq!(guardian_requests.len(), 2); - assert!(guardian_requests[0].body_contains_text(&outside_path.to_string_lossy())); - let environment_id = &test.executor_environment().selection().environment_id; - let environment = format!(r#""environment_id": "{environment_id}""#); - assert!(guardian_requests[0].body_contains_text(&environment)); - assert!(guardian_requests[0].body_contains_text("The `cwd` field is its launch directory")); - assert!(guardian_requests[1].body_contains_text(&outside_path.to_string_lossy())); - let guardian_text = guardian_requests[1].message_input_texts("user").join(""); - let permissions = guardian_text - .split_once("PARENT TURN PERMISSION CONTEXT START") - .and_then(|(_, text)| text.split_once("PARENT TURN PERMISSION CONTEXT END")) - .map(|(permissions, _)| permissions) - .context("intercepted command's Guardian permissions")?; - assert!(permissions.contains(initial_denied_path.to_string_lossy().as_ref())); - assert!(!permissions.contains(next_denied_path.to_string_lossy().as_ref())); - - Ok(()) -} - -struct CommandResult { - exit_code: Option, - stdout: String, -} - -async fn build_unified_exec_zsh_fork_test_or_skip( - test_name: &str, - approval_policy: AskForApproval, - permission_profile: PermissionProfile, - pre_build_hook: F, -) -> Result> -where - F: FnOnce(&Path) + Send + 'static, -{ - let Some(runtime) = zsh_fork_runtime(test_name)? else { - return Ok(None); - }; - - struct ExecveIdentityCheck; - - impl codex_extension_api::ApprovalReviewContributor for ExecveIdentityCheck { - fn decide<'a>( - &'a self, - input: &'a codex_extension_api::ApprovalDecisionInput<'_>, - ) -> codex_extension_api::ExtensionFuture<'a, Option> - { - if input.action.get("program").is_some() { - assert_eq!(input.tool_call_id, None); - } - Box::pin(async { None }) - } - } - - let mut extensions = ExtensionRegistryBuilder::::new(); - extensions.approval_review_contributor(Arc::new(ExecveIdentityCheck)); - let server = start_mock_server().await; - let test = zsh_fork_test_builder(runtime, approval_policy) - .with_extensions(Arc::new(extensions.build())) - .with_pre_build_hook(pre_build_hook) - .with_config(move |config| { - config - .permissions - .set_permission_profile(permission_profile) - .expect("set permission profile"); - assert!(config.features.enable(Feature::WriteStdinApproval).is_ok()); - }) - .build(&server) - .await?; - Ok(Some((server, test))) -} - -fn denied_read_permission_profile(denied_path: &Path) -> Result { - let denied_path_key = TomlKey::new(denied_path.to_string_lossy().into_owned()); - permission_profile_from_toml(&format!( - r#" -[filesystem] -"/" = "read" -":project_roots" = "write" -{denied_path_key} = "deny" - -[network] -enabled = false -"# - )) -} - -fn permission_profile_from_toml(profile: &str) -> Result { - let profile = toml::from_str::(profile) - .context("test permission profile should deserialize")?; - let filesystem = profile - .filesystem - .as_ref() - .context("test permission profile should include filesystem entries")?; - let entries = filesystem - .entries - .iter() - .map(|(path, permission)| { - let FilesystemPermissionToml::Access(access) = permission else { - anyhow::bail!("unexpected scoped filesystem permission in test profile: {path}"); - }; - let path = match path.as_str() { - "/" => FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - ":project_roots" => FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(/*subpath*/ None), - }, - _ if *access == FileSystemAccessMode::Deny => FileSystemPath::GlobPattern { - pattern: path.clone(), - }, - _ => anyhow::bail!("unexpected filesystem entry in test profile: {path}"), - }; - Ok(FileSystemSandboxEntry { - path, - access: *access, - missing_path_behavior: None, - }) - }) - .collect::>>()?; - let mut file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(entries); - file_system_sandbox_policy.glob_scan_max_depth = filesystem.glob_scan_max_depth; - let network_sandbox_policy = match profile.network.as_ref().and_then(|network| network.enabled) - { - Some(true) => NetworkSandboxPolicy::Enabled, - Some(false) | None => NetworkSandboxPolicy::Restricted, - }; - - Ok(PermissionProfile::from_runtime_permissions( - &file_system_sandbox_policy, - network_sandbox_policy, - )) -} - -async fn mount_unified_exec_command( - server: &MockServer, - response_prefix: &str, - call_id: &str, - command: &str, - justification: &str, -) -> Result { - let first_response_id = format!("resp-{response_prefix}-1"); - let second_response_id = format!("resp-{response_prefix}-2"); - let message_id = format!("msg-{response_prefix}-1"); - let event = exec_command_event( - call_id, - command, - Some(30_000), - SandboxPermissions::RequireEscalated, - justification, - )?; - let _ = mount_sse_once( - server, - sse(vec![ - ev_response_created(&first_response_id), - event, - ev_completed(&first_response_id), - ]), - ) - .await; - let results = mount_sse_once( - server, - sse(vec![ - ev_assistant_message(&message_id, "done"), - ev_completed(&second_response_id), - ]), - ) - .await; - Ok(results) -} - -async fn submit_turn_with_session_permissions( - test: &TestCodex, - prompt: &str, - approval_policy: AskForApproval, - approvals_reviewer: ApprovalsReviewer, -) -> Result<()> { - let session_model = test.session_configured.model.clone(); - let (sandbox_policy, permission_profile) = turn_permission_fields( - test.session_configured.permission_profile.clone(), - test.cwd.path(), - ); - test.codex - .start_or_steer_turn( - TurnInputRequest::user_input(vec![UserInput::Text { - text: prompt.into(), - text_elements: Vec::new(), - }]) - .with_thread_settings(ThreadSettingsOverrides { - environments: Some(local_requests(test.config.cwd.clone())), - approval_policy: Some(approval_policy), - approvals_reviewer: Some(approvals_reviewer), - sandbox_policy: Some(sandbox_policy), - permission_profile, - collaboration_mode: Some(CollaborationMode { - mode: ModeKind::Default, - settings: Settings { - model: session_model, - reasoning_effort: None, - developer_instructions: None, - }, - }), - ..Default::default() - }), - ) - .await?; - - Ok(()) -} - -async fn approve_expected_exec(test: &TestCodex, expected_command: &str) -> Result<()> { - let approval = expect_exec_approval(test, expected_command).await; - approve_exec(test, approval.effective_approval_id()).await -} - -async fn approve_exec(test: &TestCodex, approval_id: String) -> Result<()> { - test.codex - .submit(Op::ExecApproval { - id: approval_id, - turn_id: None, - decision: ReviewDecision::Approved, - }) - .await?; - Ok(()) -} - -fn command_result(results: &ResponseMock, call_id: &str) -> CommandResult { - parse_result(&results.single_request().function_call_output(call_id)) -} - -fn exec_command_event( - call_id: &str, - cmd: &str, - yield_time_ms: Option, - sandbox_permissions: SandboxPermissions, - justification: &str, -) -> Result { - let mut args = json!({ - "cmd": cmd.to_string(), - }); - if let Some(yield_time_ms) = yield_time_ms { - args["yield_time_ms"] = json!(yield_time_ms); - } - if sandbox_permissions.requests_sandbox_override() { - args["sandbox_permissions"] = json!(sandbox_permissions); - args["justification"] = json!(justification); - } - let args_str = serde_json::to_string(&args)?; - Ok(ev_function_call(call_id, "exec_command", &args_str)) -} - -fn parse_result(item: &Value) -> CommandResult { - let Some(output_str) = item.get("output").and_then(Value::as_str) else { - return CommandResult { - exit_code: None, - stdout: String::new(), - }; - }; - match serde_json::from_str::(output_str) { - Ok(parsed) => { - let exit_code = parsed["metadata"]["exit_code"].as_i64(); - let stdout = parsed["output"].as_str().unwrap_or_default().to_string(); - CommandResult { exit_code, stdout } - } - Err(_) => parsed_regex_result(r"(?s)^Exit code:\s*(-?\d+).*?Output:\n(.*)$", output_str) - .or_else(|| { - parsed_regex_result( - r"(?s)^.*?Process exited with code (\d+)\n.*?Output:\n(.*)$", - output_str, - ) - }) - .unwrap_or_else(|| CommandResult { - exit_code: None, - stdout: output_str.to_string(), - }), - } -} - -fn parsed_regex_result(pattern: &str, output_str: &str) -> Option { - let regex = Regex::new(pattern).ok()?; - let captures = regex.captures(output_str)?; - let exit_code = captures.get(1)?.as_str().parse::().ok()?; - let output = captures.get(2)?.as_str(); - Some(CommandResult { - exit_code: Some(exit_code), - stdout: output.to_string(), - }) -} - -async fn expect_exec_approval( - test: &TestCodex, - expected_command: &str, -) -> ExecApprovalRequestEvent { - let event = wait_for_event(&test.codex, |event| { - matches!( - event, - EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) - ) - }) - .await; - - match event { - EventMsg::ExecApprovalRequest(approval) => { - let last_arg = approval - .command - .last() - .map(std::string::String::as_str) - .unwrap_or_default(); - assert_eq!( - last_arg, expected_command, - "approval request should be for the parent unified-exec command" - ); - approval - } - EventMsg::TurnComplete(_) => panic!("expected approval request before completion"), - other => panic!("unexpected event: {other:?}"), - } -} - -async fn wait_for_completion_without_approval(test: &TestCodex) { - let event = wait_for_event(&test.codex, |event| { - matches!( - event, - EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) - ) - }) - .await; - - match event { - EventMsg::TurnComplete(_) => {} - EventMsg::ExecApprovalRequest(event) => { - panic!("unexpected approval request: {:?}", event.command) - } - other => panic!("unexpected event: {other:?}"), - } -} - -async fn wait_for_completion(test: &TestCodex) { - wait_for_event(&test.codex, |event| { - matches!(event, EventMsg::TurnComplete(_)) - }) - .await; -} diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index ee7f379a97..df8e3bc53d 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -600,8 +600,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result service_tier: None, codex_self_exe: arg0_paths.codex_self_exe.clone(), codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(), - main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe.clone(), - default_zsh_path: None, + main_execve_wrapper_exe: None, base_instructions: None, developer_instructions: None, personality: None, diff --git a/codex-rs/exec/tests/suite/apply_patch.rs b/codex-rs/exec/tests/suite/apply_patch.rs index 8376b2e765..66c485d02b 100644 --- a/codex-rs/exec/tests/suite/apply_patch.rs +++ b/codex-rs/exec/tests/suite/apply_patch.rs @@ -5,9 +5,11 @@ use assert_cmd::prelude::*; use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1; use core_test_support::responses::ev_apply_patch_custom_tool_call; use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_exec_command_call_with_args; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; +use pretty_assertions::assert_eq; use std::fs; use std::process::Command; use tempfile::tempdir; @@ -92,6 +94,101 @@ async fn test_apply_patch_tool() -> anyhow::Result<()> { Ok(()) } +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn nested_apply_patch_alias_works_with_restricted_reads() -> anyhow::Result<()> { + use core_test_support::skip_if_no_network; + use core_test_support::skip_if_sandbox; + use core_test_support::test_codex_exec::test_codex_exec; + + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + #[cfg(target_os = "linux")] + if super::sandbox::linux_sandbox_test_env().await.is_none() { + return Ok(()); + } + let test = test_codex_exec(); + let binary = codex_utils_cargo_bin::cargo_bin("codex-exec")?.canonicalize()?; + let binary_dir = serde_json::to_string(binary.parent().expect("binary directory"))?; + let codex_home = serde_json::to_string(test.home_path())?; + fs::write( + test.home_path().join("config.toml"), + format!( + r#"default_permissions = "aliases" +approval_policy = "never" +[features] +shell_snapshot = false +shell_snapshot_v2 = false +[permissions.aliases.filesystem] +":minimal" = "read" +":project_roots" = "write" +{binary_dir} = "read" +{codex_home} = "none" +"# + ), + )?; + fs::write( + test.home_path().join("outside-workspace.txt"), + "must remain unreadable to the script", + )?; + fs::write( + test.cwd_path().join("patch.sh"), + "if cat \"$HOME/outside-workspace.txt\" >/dev/null 2>&1; then exit 99; fi\napply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: patched.txt\n+through the nested alias\n*** End Patch\nPATCH\n", + )?; + let server = start_mock_server().await; + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_exec_command_call_with_args( + "nested-patch", + &serde_json::json!({ + "cmd": "/bin/sh patch.sh", + "shell": "/bin/sh", + "login": false, + "yield_time_ms": 10_000, + }), + ), + ev_completed("response-1"), + ]), + sse(vec![ev_completed("response-2")]), + ], + ) + .await; + let run = test.cmd_with_server(&server) + .env("HOME", test.home_path()) + .env_remove("CODEX_API_KEY") + .env_remove("OPENAI_API_KEY") + .arg("--skip-git-repo-check") + .arg("-c") + .arg("default_permissions=\"aliases\"") + .arg("-c") + .arg("approvals_reviewer=\"user\"") + .arg("-c") + .arg("model_provider=\"test\"") + .arg("-c") + .arg(format!( + "model_providers.test={{name=\"test\",base_url={:?},wire_api=\"responses\",requires_openai_auth=false,supports_websockets=false}}", + format!("{}/v1", server.uri()) + )) + .arg("run the patch script") + .assert() + .success(); + let output = mock + .function_call_output_text("nested-patch") + .expect("tool output"); + assert!( + output.contains("Process exited with code 0"), + "{output}\n{}", + String::from_utf8_lossy(&run.get_output().stderr) + ); + assert_eq!( + fs::read_to_string(test.cwd_path().join("patched.txt"))?, + "through the nested alias\n" + ); + Ok(()) +} + #[cfg(not(target_os = "windows"))] #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn test_apply_patch_freeform_tool() -> anyhow::Result<()> { diff --git a/codex-rs/exec/tests/suite/sandbox.rs b/codex-rs/exec/tests/suite/sandbox.rs index d2eb241e9e..5f3b545813 100644 --- a/codex-rs/exec/tests/suite/sandbox.rs +++ b/codex-rs/exec/tests/suite/sandbox.rs @@ -101,7 +101,7 @@ pub(super) async fn spawn_command_under_sandbox( /// under the production Landlock path and skip when enforcement is unavailable /// (for example on kernels or container profiles where Landlock is not /// enforced). -async fn linux_sandbox_test_env() -> Option> { +pub(super) async fn linux_sandbox_test_env() -> Option> { let command_cwd = AbsolutePathBuf::current_dir().ok()?; let sandbox_cwd = command_cwd.clone(); let permission_profile = PermissionProfile::read_only(); diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index f9147628f0..8d67b47787 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -148,13 +148,9 @@ pub enum Feature { UnifiedExec, /// Allow unified exec commands to allocate an interactive terminal. UnifiedExecTty, - /// Route shell tool execution through the zsh exec bridge. + /// Removed compatibility flag for the retired patched zsh backend. ShellZshFork, - /// Allow unified exec to compose with the zsh exec bridge. - /// - /// This flag is only a composition gate. Enabling it by itself must not turn - /// on either `unified_exec` or `shell_zsh_fork` because those features have - /// separate rollout and enterprise controls. + /// Removed compatibility flag for the retired patched zsh backend. UnifiedExecZshFork, /// Removed compatibility flag. Transcript scrollback reflow on terminal resize is always on. TerminalResizeReflow, @@ -639,6 +635,17 @@ impl Features { Feature::WebSearchCached, ); } + "shell_zsh_fork" => { + self.record_legacy_usage_force("shell_zsh_fork", Feature::ShellZshFork); + continue; + } + "unified_exec_zsh_fork" => { + self.record_legacy_usage_force( + "unified_exec_zsh_fork", + Feature::UnifiedExecZshFork, + ); + continue; + } "transcript_v2" => { self.record_legacy_usage_force("features.transcript_v2", Feature::TranscriptV2); continue; @@ -754,6 +761,10 @@ impl Features { fn legacy_usage_notice(alias: &str, feature: Feature) -> (String, Option) { let canonical = feature.key(); match feature { + Feature::ShellZshFork | Feature::UnifiedExecZshFork => ( + format!("`[features].{canonical}` is deprecated and ignored."), + Some(format!("The patched zsh backend has been removed. Shell commands now use the standard shell executor. Remove `{canonical}` from [features] in config.toml or profile configuration files.")), + ), Feature::GuardianThreadContext => ( "`[features.guardianv2].thread_context` is deprecated and ignored.".to_string(), Some("Thread-owned Guardian context is always enabled. Remove `thread_context` from [features.guardianv2] in config.toml, including profile overrides.".to_string()), @@ -1048,14 +1059,14 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::ShellZshFork, key: "shell_zsh_fork", - stage: Stage::UnderDevelopment, + stage: Stage::Removed, default_enabled: false, }, FeatureSpec { id: Feature::UnifiedExecZshFork, key: "unified_exec_zsh_fork", stage: Stage::Removed, - default_enabled: true, + default_enabled: false, }, FeatureSpec { id: Feature::ShellSnapshot, diff --git a/codex-rs/install-context/src/lib.rs b/codex-rs/install-context/src/lib.rs index 1a9be86a11..899211bcb2 100644 --- a/codex-rs/install-context/src/lib.rs +++ b/codex-rs/install-context/src/lib.rs @@ -18,7 +18,6 @@ const PATH_DIRNAME: &str = "codex-path"; const RELEASES_DIRNAME: &str = "releases"; const RESOURCES_DIRNAME: &str = "codex-resources"; const STANDALONE_PACKAGES_DIRNAME: &str = "standalone"; -const ZSH_DIRNAME: &str = "zsh"; static INSTALL_CONTEXT: OnceLock = OnceLock::new(); #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -228,18 +227,6 @@ impl InstallContext { None } - - pub fn bundled_zsh_path(&self) -> Option { - if cfg!(windows) { - None - } else { - self.bundled_resource(zsh_resource_path()) - } - } - - pub fn bundled_zsh_bin_dir(&self) -> Option { - self.bundled_zsh_path()?.parent() - } } impl CodexPackageLayout { @@ -371,10 +358,6 @@ fn default_rg_command() -> PathBuf { } } -fn zsh_resource_path() -> PathBuf { - PathBuf::from(ZSH_DIRNAME).join(BIN_DIRNAME).join("zsh") -} - #[cfg(test)] #[path = "bundle_tests.rs"] mod bundle_tests; @@ -572,11 +555,6 @@ mod tests { fs::write(bin_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME), "")?; fs::write(resources_dir.join(TEST_RESOURCE_NAME), "")?; fs::write(path_dir.join(default_rg_command()), "")?; - if !cfg!(windows) { - let zsh_path = resources_dir.join(zsh_resource_path()); - fs::create_dir_all(zsh_path.parent().expect("zsh path should have parent"))?; - fs::write(&zsh_path, "")?; - } let canonical_package_dir = AbsolutePathBuf::from_absolute_path(package_dir.path().canonicalize()?)?; let canonical_bin_dir = AbsolutePathBuf::from_absolute_path(bin_dir.canonicalize()?)?; @@ -631,19 +609,6 @@ mod tests { context.bundled_resource(TEST_RESOURCE_NAME), Some(canonical_resources_dir.join(TEST_RESOURCE_NAME)) ); - if cfg!(windows) { - assert_eq!(context.bundled_zsh_path(), None); - assert_eq!(context.bundled_zsh_bin_dir(), None); - } else { - assert_eq!( - context.bundled_zsh_path(), - Some(canonical_resources_dir.join(zsh_resource_path())) - ); - assert_eq!( - context.bundled_zsh_bin_dir(), - Some(canonical_resources_dir.join(ZSH_DIRNAME).join(BIN_DIRNAME)) - ); - } Ok(()) } diff --git a/codex-rs/shell-command/src/shell_snapshot_exports.rs b/codex-rs/shell-command/src/shell_snapshot_exports.rs index 491ba09375..96ac267eb2 100644 --- a/codex-rs/shell-command/src/shell_snapshot_exports.rs +++ b/codex-rs/shell-command/src/shell_snapshot_exports.rs @@ -21,7 +21,7 @@ pub(super) fn script(shell_type: ShellType) -> String { ShellType::Zsh => { r#"( unsetopt rcquotes - # The bundled Zsh does not include the zsh/parameter module. + # Some Zsh builds do not include the zsh/parameter module. for __codex_snapshot_export_name in ${(f)"$(typeset +x)"}; do case "$__codex_snapshot_export_name" in ""|[0-9]*|*[!A-Za-z0-9_]*|PWD|OLDPWD) continue ;; diff --git a/codex-rs/shell-escalation/BUILD.bazel b/codex-rs/shell-escalation/BUILD.bazel deleted file mode 100644 index 5092f33324..0000000000 --- a/codex-rs/shell-escalation/BUILD.bazel +++ /dev/null @@ -1,6 +0,0 @@ -load("//:defs.bzl", "codex_rust_crate") - -codex_rust_crate( - name = "shell-escalation", - crate_name = "codex_shell_escalation", -) diff --git a/codex-rs/shell-escalation/Cargo.toml b/codex-rs/shell-escalation/Cargo.toml deleted file mode 100644 index 92ae8b033f..0000000000 --- a/codex-rs/shell-escalation/Cargo.toml +++ /dev/null @@ -1,41 +0,0 @@ -[package] -edition.workspace = true -license.workspace = true -name = "codex-shell-escalation" -version.workspace = true - -[[bin]] -name = "codex-execve-wrapper" -path = "src/bin/main_execve_wrapper.rs" - -[lints] -workspace = true - -[dependencies] -anyhow = { workspace = true } -clap = { workspace = true, features = ["derive"] } -codex-protocol = { workspace = true } -codex-utils-absolute-path = { workspace = true } -libc = { workspace = true } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -socket2 = { workspace = true, features = ["all"] } -tokio = { workspace = true, features = [ - "io-std", - "net", - "macros", - "process", - "rt-multi-thread", - "signal", - "time", -] } -tokio-util = { workspace = true } -tracing = { workspace = true } -tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } - -[dev-dependencies] -pretty_assertions = { workspace = true } -tempfile = { workspace = true } - -[lib] -doctest = false diff --git a/codex-rs/shell-escalation/README.md b/codex-rs/shell-escalation/README.md deleted file mode 100644 index 69cd038220..0000000000 --- a/codex-rs/shell-escalation/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# codex-shell-escalation - -This crate contains the Unix shell-escalation protocol implementation and the -`codex-execve-wrapper` executable. - -`codex-execve-wrapper` receives the arguments to an intercepted `execve(2)` call and delegates the -decision to the shell-escalation protocol over a shared file descriptor (specified by the -`CODEX_ESCALATE_SOCKET` environment variable). The server on the other side replies with one of: - -- `Run`: `codex-execve-wrapper` should invoke `execve(2)` on itself to run the original command - within the sandboxed shell. -- `Escalate`: forward the file descriptors of the current process so the command can be run - faithfully outside the sandbox. When the process completes, the server forwards the exit code - back to `codex-execve-wrapper`. -- `Deny`: the server has declared the proposed command to be forbidden, so - `codex-execve-wrapper` prints an error to `stderr` and exits with `1`. - -## Patched zsh - -We carry a small patch to `Src/exec.c` (see `patches/zsh-exec-wrapper.patch`) that adds support for `EXEC_WRAPPER`. The patch applies to `77045ef899e53b9598bebc5a41db93a548a40ca6` from https://git.code.sf.net/p/zsh/code. To rebuild manually: - -```bash -git clone https://git.code.sf.net/p/zsh/code -git checkout 77045ef899e53b9598bebc5a41db93a548a40ca6 -git apply /path/to/patches/zsh-exec-wrapper.patch -./Util/preconfig -./configure -make -j"$(nproc)" -``` - -Release artifacts are built by `.github/workflows/rust-release-zsh.yml` when a -`codex-zsh-vX.Y.Z` tag is pushed. When the zsh commit or patch changes, publish -the next version tag and update the checked-in DotSlash manifests to use the new -release. diff --git a/codex-rs/shell-escalation/patches/zsh-exec-wrapper.patch b/codex-rs/shell-escalation/patches/zsh-exec-wrapper.patch deleted file mode 100644 index 99cdcadcb2..0000000000 --- a/codex-rs/shell-escalation/patches/zsh-exec-wrapper.patch +++ /dev/null @@ -1,34 +0,0 @@ -diff --git a/Src/exec.c b/Src/exec.c -index 27bca11..baea760 100644 ---- a/Src/exec.c -+++ b/Src/exec.c -@@ -507,7 +507,9 @@ zexecve(char *pth, char **argv, char **newenvp) - { - int eno; - static char buf[PATH_MAX * 2+1]; -- char **eep; -+ char **eep, **exec_argv; -+ char *orig_pth = pth; -+ char *exec_wrapper; - - unmetafy(pth, NULL); - for (eep = argv; *eep; eep++) -@@ -526,8 +528,17 @@ zexecve(char *pth, char **argv, char **newenvp) - - if (newenvp == NULL) - newenvp = environ; -+ exec_argv = argv; -+ if ((exec_wrapper = getenv("EXEC_WRAPPER")) && -+ *exec_wrapper && !inblank(*exec_wrapper)) { -+ exec_argv = argv - 2; -+ exec_argv[0] = exec_wrapper; -+ exec_argv[1] = orig_pth; -+ pth = exec_wrapper; -+ } - winch_unblock(); -- execve(pth, argv, newenvp); -+ execve(pth, exec_argv, newenvp); -+ pth = orig_pth; - - /* If the execve returns (which in general shouldn't happen), * - * then check for an errno equal to ENOEXEC. This errno is set * diff --git a/codex-rs/shell-escalation/src/bin/main_execve_wrapper.rs b/codex-rs/shell-escalation/src/bin/main_execve_wrapper.rs deleted file mode 100644 index ab459a311e..0000000000 --- a/codex-rs/shell-escalation/src/bin/main_execve_wrapper.rs +++ /dev/null @@ -1,8 +0,0 @@ -#[cfg(not(unix))] -fn main() { - eprintln!("codex-execve-wrapper is only implemented for UNIX"); - std::process::exit(1); -} - -#[cfg(unix)] -pub use codex_shell_escalation::main_execve_wrapper as main; diff --git a/codex-rs/shell-escalation/src/lib.rs b/codex-rs/shell-escalation/src/lib.rs deleted file mode 100644 index 53a55c4d83..0000000000 --- a/codex-rs/shell-escalation/src/lib.rs +++ /dev/null @@ -1,39 +0,0 @@ -#[cfg(unix)] -mod unix; - -#[cfg(unix)] -pub use unix::ESCALATE_SOCKET_ENV_VAR; -#[cfg(unix)] -pub use unix::EscalateAction; -#[cfg(unix)] -pub use unix::EscalateServer; -#[cfg(unix)] -pub use unix::EscalationDecision; -#[cfg(unix)] -pub use unix::EscalationExecution; -#[cfg(unix)] -pub use unix::EscalationPermissions; -#[cfg(unix)] -pub use unix::EscalationPolicy; -#[cfg(unix)] -pub use unix::EscalationPolicyFuture; -#[cfg(unix)] -pub use unix::EscalationSession; -#[cfg(unix)] -pub use unix::ExecParams; -#[cfg(unix)] -pub use unix::ExecResult; -#[cfg(unix)] -pub use unix::PreparedExec; -#[cfg(unix)] -pub use unix::ResolvedPermissionProfile; -#[cfg(unix)] -pub use unix::ShellCommandExecutor; -#[cfg(unix)] -pub use unix::ShellCommandExecutorFuture; -#[cfg(unix)] -pub use unix::Stopwatch; -#[cfg(unix)] -pub use unix::main_execve_wrapper; -#[cfg(unix)] -pub use unix::run_shell_escalation_execve_wrapper; diff --git a/codex-rs/shell-escalation/src/unix/escalate_client.rs b/codex-rs/shell-escalation/src/unix/escalate_client.rs deleted file mode 100644 index 0f98883865..0000000000 --- a/codex-rs/shell-escalation/src/unix/escalate_client.rs +++ /dev/null @@ -1,144 +0,0 @@ -use std::io; -use std::os::fd::AsFd; -use std::os::fd::AsRawFd; -use std::os::fd::OwnedFd; - -use anyhow::Context as _; -use codex_utils_absolute_path::AbsolutePathBuf; - -use crate::unix::escalate_protocol::ESCALATE_SOCKET_ENV_VAR; -use crate::unix::escalate_protocol::EXEC_WRAPPER_ENV_VAR; -use crate::unix::escalate_protocol::EscalateAction; -use crate::unix::escalate_protocol::EscalateRequest; -use crate::unix::escalate_protocol::EscalateResponse; -use crate::unix::escalate_protocol::SuperExecMessage; -use crate::unix::escalate_protocol::SuperExecResult; -use crate::unix::socket::AsyncDatagramSocket; -use crate::unix::socket::AsyncSocket; - -fn get_escalate_client() -> anyhow::Result { - // TODO: we should defensively require only calling this once, since AsyncSocket will take ownership of the fd. - let client_fd = std::env::var(ESCALATE_SOCKET_ENV_VAR)?.parse::()?; - if client_fd < 0 { - return Err(anyhow::anyhow!( - "{ESCALATE_SOCKET_ENV_VAR} is not a valid file descriptor: {client_fd}" - )); - } - Ok(unsafe { AsyncDatagramSocket::from_raw_fd(client_fd) }?) -} - -fn duplicate_fd_for_transfer(fd: impl AsFd, name: &str) -> anyhow::Result { - fd.as_fd() - .try_clone_to_owned() - .with_context(|| format!("failed to duplicate {name} for escalation transfer")) -} - -pub async fn run_shell_escalation_execve_wrapper( - file: String, - argv: Vec, -) -> anyhow::Result { - let handshake_client = get_escalate_client()?; - let (server, client) = AsyncSocket::pair()?; - const HANDSHAKE_MESSAGE: [u8; 1] = [0]; - handshake_client - .send_with_fds(&HANDSHAKE_MESSAGE, &[server.into_inner().into()]) - .await - .context("failed to send handshake datagram")?; - let env = std::env::vars() - .filter(|(k, _)| !matches!(k.as_str(), ESCALATE_SOCKET_ENV_VAR | EXEC_WRAPPER_ENV_VAR)) - .collect(); - client - .send(EscalateRequest { - file: file.clone().into(), - argv: argv.clone(), - workdir: AbsolutePathBuf::current_dir()?, - env, - }) - .await - .context("failed to send EscalateRequest")?; - let message = client - .receive::() - .await - .context("failed to receive EscalateResponse")?; - match message.action { - EscalateAction::Escalate => { - // Duplicate stdio before transferring ownership to the server. The - // wrapper must keep using its own stdin/stdout/stderr until the - // escalated child takes over. - let destination_fds = [ - io::stdin().as_raw_fd(), - io::stdout().as_raw_fd(), - io::stderr().as_raw_fd(), - ]; - let fds_to_send = [ - duplicate_fd_for_transfer(io::stdin(), "stdin")?, - duplicate_fd_for_transfer(io::stdout(), "stdout")?, - duplicate_fd_for_transfer(io::stderr(), "stderr")?, - ]; - - // TODO: also forward signals over the super-exec socket - - client - .send_with_fds( - SuperExecMessage { - fds: destination_fds.into_iter().collect(), - }, - &fds_to_send, - ) - .await - .context("failed to send SuperExecMessage")?; - let SuperExecResult { exit_code } = client.receive::().await?; - Ok(exit_code) - } - EscalateAction::Run => { - // We avoid std::process::Command here because we want to be as transparent as - // possible. std::os::unix::process::CommandExt has .exec() but it does some funky - // stuff with signal masks and dup2() on its standard FDs, which we don't want. - use std::ffi::CString; - let file = CString::new(file).context("NUL in file")?; - - let argv_cstrs: Vec = argv - .iter() - .map(|s| CString::new(s.as_str()).context("NUL in argv")) - .collect::, _>>()?; - - let mut argv: Vec<*const libc::c_char> = - argv_cstrs.iter().map(|s| s.as_ptr()).collect(); - argv.push(std::ptr::null()); - - let err = unsafe { - libc::execv(file.as_ptr(), argv.as_ptr()); - std::io::Error::last_os_error() - }; - - Err(err.into()) - } - EscalateAction::Deny { reason } => { - match reason { - Some(reason) => eprintln!("Execution denied: {reason}"), - None => eprintln!("Execution denied"), - } - Ok(1) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::os::fd::AsRawFd; - use std::os::unix::net::UnixStream; - - #[test] - fn duplicate_fd_for_transfer_does_not_close_original() { - let (left, _right) = UnixStream::pair().expect("socket pair"); - let original_fd = left.as_raw_fd(); - - let duplicate = duplicate_fd_for_transfer(&left, "test fd").expect("duplicate fd"); - assert_ne!(duplicate.as_raw_fd(), original_fd); - - drop(duplicate); - - assert_ne!(unsafe { libc::fcntl(original_fd, libc::F_GETFD) }, -1); - } -} diff --git a/codex-rs/shell-escalation/src/unix/escalate_protocol.rs b/codex-rs/shell-escalation/src/unix/escalate_protocol.rs deleted file mode 100644 index 1892f181b9..0000000000 --- a/codex-rs/shell-escalation/src/unix/escalate_protocol.rs +++ /dev/null @@ -1,88 +0,0 @@ -use std::collections::HashMap; -use std::os::fd::RawFd; -use std::path::PathBuf; - -use codex_protocol::approvals::EscalationPermissions; -use codex_utils_absolute_path::AbsolutePathBuf; -use serde::Deserialize; -use serde::Serialize; - -/// Exec wrappers read this to find the inherited FD for the escalation socket. -pub const ESCALATE_SOCKET_ENV_VAR: &str = "CODEX_ESCALATE_SOCKET"; - -/// Patched shells use this to wrap exec() calls. -pub const EXEC_WRAPPER_ENV_VAR: &str = "EXEC_WRAPPER"; - -/// The client sends this to the server to request an exec() call. -#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] -pub struct EscalateRequest { - /// The executable path from the intercepted exec call. - /// - /// This may be relative, in which case it should be resolved against - /// `workdir`. - pub file: PathBuf, - /// The argv, including the program name (argv[0]). - pub argv: Vec, - pub workdir: AbsolutePathBuf, - pub env: HashMap, -} - -/// The server sends this to the client to respond to an exec() request. -#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] -pub struct EscalateResponse { - pub action: EscalateAction, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum EscalationDecision { - Run, - Escalate(EscalationExecution), - Deny { reason: Option }, -} - -#[allow(clippy::large_enum_variant)] -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum EscalationExecution { - /// Rerun the intercepted command outside any sandbox wrapper. - Unsandboxed, - /// Rerun using the turn's current sandbox configuration. - TurnDefault, - /// Rerun using an explicit sandbox configuration attached to the request. - Permissions(EscalationPermissions), -} - -impl EscalationDecision { - pub fn run() -> Self { - Self::Run - } - - pub fn escalate(execution: EscalationExecution) -> Self { - Self::Escalate(execution) - } - - pub fn deny(reason: Option) -> Self { - Self::Deny { reason } - } -} - -#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] -pub enum EscalateAction { - /// The command should be run directly by the client. - Run, - /// The command should be escalated to the server for execution. - Escalate, - /// The command should not be executed. - Deny { reason: Option }, -} - -/// The client sends this to the server to forward its open FDs. -#[derive(Clone, Serialize, Deserialize, Debug)] -pub struct SuperExecMessage { - pub fds: Vec, -} - -/// The server responds when the exec()'d command has exited. -#[derive(Clone, Serialize, Deserialize, Debug)] -pub struct SuperExecResult { - pub exit_code: i32, -} diff --git a/codex-rs/shell-escalation/src/unix/escalate_server.rs b/codex-rs/shell-escalation/src/unix/escalate_server.rs deleted file mode 100644 index 139450dc3d..0000000000 --- a/codex-rs/shell-escalation/src/unix/escalate_server.rs +++ /dev/null @@ -1,1118 +0,0 @@ -use std::collections::HashMap; -use std::future::Future; -use std::os::fd::AsRawFd; -use std::path::PathBuf; -use std::pin::Pin; -use std::process::Stdio; -use std::sync::Arc; -use std::sync::Mutex; -use std::time::Duration; - -use anyhow::Context as _; -use codex_protocol::shell_environment::scrub_non_inheritable_env_vars; -use codex_utils_absolute_path::AbsolutePathBuf; -use socket2::Socket; -use tokio::process::Command; -use tokio::task::JoinHandle; -use tokio_util::sync::CancellationToken; - -use crate::unix::escalate_protocol::ESCALATE_SOCKET_ENV_VAR; -use crate::unix::escalate_protocol::EXEC_WRAPPER_ENV_VAR; -use crate::unix::escalate_protocol::EscalateAction; -use crate::unix::escalate_protocol::EscalateRequest; -use crate::unix::escalate_protocol::EscalateResponse; -use crate::unix::escalate_protocol::EscalationDecision; -use crate::unix::escalate_protocol::EscalationExecution; -use crate::unix::escalate_protocol::SuperExecMessage; -use crate::unix::escalate_protocol::SuperExecResult; -use crate::unix::escalation_policy::EscalationPolicy; -use crate::unix::socket::AsyncDatagramSocket; -use crate::unix::socket::AsyncSocket; - -/// Adapter for running the shell command after the escalation server has been set up. -/// -/// This lets `shell-escalation` own the Unix escalation protocol while the caller -/// keeps control over process spawning, output capture, and sandbox integration. -/// Implementations can capture any sandbox state they need. -pub trait ShellCommandExecutor: Send + Sync { - /// Runs the requested shell command and returns the captured result. - /// - /// `env_overlay` contains only the wrapper/socket variables exported by - /// `EscalationSession::env()`, not a complete child environment. - /// Implementations should merge it into whatever base environment they use - /// for the shell process. `after_spawn` should be invoked immediately after - /// the shell process has been spawned so the parent copy of the inherited - /// escalation socket can be closed. - fn run( - &self, - command: Vec, - cwd: PathBuf, - env_overlay: HashMap, - cancel_rx: CancellationToken, - after_spawn: Option>, - ) -> ShellCommandExecutorFuture<'_, ExecResult>; - - /// Prepares an escalated subcommand for execution on the server side. - fn prepare_escalated_exec<'a>( - &'a self, - program: &'a AbsolutePathBuf, - argv: &'a [String], - workdir: &'a AbsolutePathBuf, - env: HashMap, - execution: EscalationExecution, - ) -> ShellCommandExecutorFuture<'a, PreparedExec>; -} - -pub type ShellCommandExecutorFuture<'a, T> = - Pin> + Send + 'a>>; - -#[derive(Debug, serde::Deserialize, serde::Serialize)] -pub struct ExecParams { - /// The command string to pass to the shell via `-c` or `-lc`. - pub command: String, - /// The working directory to execute the command in. Must be an absolute path. - pub workdir: String, - /// The timeout for the command in milliseconds. - pub timeout_ms: Option, - /// Launch the shell with -lc instead of -c: defaults to true. - pub login: Option, -} - -#[derive(Debug, serde::Serialize, serde::Deserialize)] -pub struct ExecResult { - pub exit_code: i32, - pub stdout: String, - pub stderr: String, - /// Aggregated stdout+stderr output for compatibility with existing callers. - pub output: String, - pub duration: Duration, - pub timed_out: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PreparedExec { - pub command: Vec, - pub cwd: PathBuf, - pub env: HashMap, - pub arg0: Option, -} - -#[derive(Debug)] -pub struct EscalationSession { - env: HashMap, - task: JoinHandle>, - client_socket: Arc>>, - cancellation_token: CancellationToken, -} - -impl EscalationSession { - /// Returns just the environment overlay needed by the execve wrapper. - /// - /// Callers should merge this into their own child-process environment - /// rather than treating it as the full environment for the shell. - pub fn env(&self) -> &HashMap { - &self.env - } - - pub fn close_client_socket(&self) { - if let Ok(mut client_socket) = self.client_socket.lock() { - client_socket.take(); - } - } -} - -impl Drop for EscalationSession { - fn drop(&mut self) { - self.close_client_socket(); - self.cancellation_token.cancel(); - self.task.abort(); - } -} - -pub struct EscalateServer { - shell_path: PathBuf, - execve_wrapper: PathBuf, - policy: Arc, -} - -impl EscalateServer { - pub fn new(shell_path: PathBuf, execve_wrapper: PathBuf, policy: Policy) -> Self - where - Policy: EscalationPolicy + Send + Sync + 'static, - { - Self { - shell_path, - execve_wrapper, - policy: Arc::new(policy), - } - } - - pub async fn exec( - &self, - params: ExecParams, - cancel_rx: CancellationToken, - command_executor: Arc, - ) -> anyhow::Result { - let session = self.start_session(cancel_rx.clone(), Arc::clone(&command_executor))?; - let env_overlay = session.env().clone(); - let client_socket = Arc::clone(&session.client_socket); - let command = vec![ - self.shell_path.to_string_lossy().to_string(), - if params.login == Some(false) { - "-c".to_string() - } else { - "-lc".to_string() - }, - params.command, - ]; - let workdir = AbsolutePathBuf::try_from(params.workdir)?; - let result = command_executor - .run( - command, - workdir.to_path_buf(), - env_overlay, - cancel_rx, - Some(Box::new(move || { - if let Ok(mut client_socket) = client_socket.lock() { - client_socket.take(); - } - })), - ) - .await?; - Ok(result) - } - - /// Starts an escalation session and returns the environment overlay a shell - /// needs in order to route intercepted execs through this server. - /// - /// This does not spawn the shell itself. Callers own process creation and - /// only use the returned environment plus the session lifetime handle. - pub fn start_session( - &self, - parent_cancellation_token: CancellationToken, - command_executor: Arc, - ) -> anyhow::Result { - let cancellation_token = CancellationToken::new(); - let (escalate_server, escalate_client) = AsyncDatagramSocket::pair()?; - let client_socket = escalate_client.into_inner(); - let client_socket_fd = client_socket.as_raw_fd(); - // Only the client endpoint should cross exec into the wrapper process. - client_socket.set_cloexec(false)?; - let client_socket = Arc::new(Mutex::new(Some(client_socket))); - let task = tokio::spawn(escalate_task( - escalate_server, - Arc::clone(&self.policy), - Arc::clone(&command_executor), - parent_cancellation_token, - cancellation_token.clone(), - )); - let mut env = HashMap::new(); - env.insert( - ESCALATE_SOCKET_ENV_VAR.to_string(), - client_socket_fd.to_string(), - ); - env.insert( - EXEC_WRAPPER_ENV_VAR.to_string(), - self.execve_wrapper.to_string_lossy().to_string(), - ); - Ok(EscalationSession { - env, - task, - client_socket, - cancellation_token, - }) - } -} - -async fn escalate_task( - socket: AsyncDatagramSocket, - policy: Arc, - command_executor: Arc, - parent_cancellation_token: CancellationToken, - session_cancellation_token: CancellationToken, -) -> anyhow::Result<()> { - loop { - let (_, mut fds) = tokio::select! { - received = socket.receive_with_fds() => received?, - _ = parent_cancellation_token.cancelled() => return Ok(()), - _ = session_cancellation_token.cancelled() => return Ok(()), - }; - if fds.len() != 1 { - tracing::error!("expected 1 fd in datagram handshake, got {}", fds.len()); - continue; - } - let stream_socket = AsyncSocket::from_fd(fds.remove(0))?; - let policy = Arc::clone(&policy); - let command_executor = Arc::clone(&command_executor); - let parent_cancellation_token = parent_cancellation_token.clone(); - let session_cancellation_token = session_cancellation_token.clone(); - tokio::spawn(async move { - if let Err(err) = handle_escalate_session_with_policy( - stream_socket, - policy, - command_executor, - parent_cancellation_token, - session_cancellation_token, - ) - .await - { - tracing::error!("escalate session failed: {err:?}"); - } - }); - } -} - -async fn handle_escalate_session_with_policy( - socket: AsyncSocket, - policy: Arc, - command_executor: Arc, - parent_cancellation_token: CancellationToken, - session_cancellation_token: CancellationToken, -) -> anyhow::Result<()> { - let EscalateRequest { - file, - argv, - workdir, - env, - } = tokio::select! { - request = socket.receive::() => request?, - _ = parent_cancellation_token.cancelled() => return Ok(()), - _ = session_cancellation_token.cancelled() => return Ok(()), - }; - let program = AbsolutePathBuf::resolve_path_against_base(file, workdir.as_path()); - let decision = tokio::select! { - decision = policy.determine_action(&program, &argv, &workdir) => { - decision.context("failed to determine escalation action")? - } - _ = parent_cancellation_token.cancelled() => return Ok(()), - _ = session_cancellation_token.cancelled() => return Ok(()), - }; - - tracing::debug!("decided {decision:?} for {program:?} {argv:?} {workdir:?}"); - - match decision { - EscalationDecision::Run => { - socket - .send(EscalateResponse { - action: EscalateAction::Run, - }) - .await?; - } - EscalationDecision::Escalate(execution) => { - socket - .send(EscalateResponse { - action: EscalateAction::Escalate, - }) - .await?; - let (msg, fds) = tokio::select! { - message = socket.receive_with_fds::() => { - message.context("failed to receive SuperExecMessage")? - } - _ = parent_cancellation_token.cancelled() => return Ok(()), - _ = session_cancellation_token.cancelled() => return Ok(()), - }; - if fds.len() != msg.fds.len() { - return Err(anyhow::anyhow!( - "mismatched number of fds in SuperExecMessage: {} in the message, {} from the control message", - msg.fds.len(), - fds.len() - )); - } - - let PreparedExec { - command, - cwd, - env, - arg0, - } = tokio::select! { - prepared = command_executor.prepare_escalated_exec(&program, &argv, &workdir, env, execution) => prepared?, - _ = parent_cancellation_token.cancelled() => return Ok(()), - _ = session_cancellation_token.cancelled() => return Ok(()), - }; - let (program, args) = command - .split_first() - .ok_or_else(|| anyhow::anyhow!("prepared escalated command must not be empty"))?; - let mut command = Command::new(program); - command - .args(args) - .arg0(arg0.unwrap_or_else(|| program.clone())) - .env_clear() - .envs(&env) - .current_dir(&cwd) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .kill_on_drop(true); - scrub_non_inheritable_env_vars(command.as_std_mut()); - unsafe { - command.pre_exec(move || { - for (dst_fd, src_fd) in msg.fds.iter().zip(&fds) { - libc::dup2(src_fd.as_raw_fd(), *dst_fd); - } - Ok(()) - }); - } - let mut child = command.spawn()?; - let exit_status = tokio::select! { - status = child.wait() => status?, - _ = parent_cancellation_token.cancelled() => { - let _ = child.start_kill(); - child.wait().await? - } - _ = session_cancellation_token.cancelled() => { - let _ = child.start_kill(); - child.wait().await? - } - }; - socket - .send(SuperExecResult { - exit_code: exit_status.code().unwrap_or(127), - }) - .await?; - } - EscalationDecision::Deny { reason } => { - socket - .send(EscalateResponse { - action: EscalateAction::Deny { reason }, - }) - .await?; - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::unix::escalation_policy::EscalationPolicyFuture; - use codex_protocol::approvals::EscalationPermissions; - use codex_protocol::models::AdditionalPermissionProfile as PermissionProfile; - use codex_protocol::models::NetworkPermissions; - use codex_utils_absolute_path::AbsolutePathBuf; - use pretty_assertions::assert_eq; - use std::collections::HashMap; - use std::io::Write; - use std::os::fd::AsRawFd; - use std::os::fd::FromRawFd; - use std::path::PathBuf; - use std::sync::LazyLock; - use std::sync::atomic::AtomicBool; - use std::sync::atomic::Ordering; - use tempfile::TempDir; - use tokio::sync::Semaphore; - use tokio::time::Instant; - use tokio::time::sleep; - - static ESCALATE_SERVER_TEST_LOCK: LazyLock = LazyLock::new(|| Semaphore::new(1)); - - struct DeterministicEscalationPolicy { - decision: EscalationDecision, - } - - impl EscalationPolicy for DeterministicEscalationPolicy { - fn determine_action<'a>( - &'a self, - _file: &'a AbsolutePathBuf, - _argv: &'a [String], - _workdir: &'a AbsolutePathBuf, - ) -> EscalationPolicyFuture<'a> { - Box::pin(async move { Ok(self.decision.clone()) }) - } - } - - struct AssertingEscalationPolicy { - expected_file: AbsolutePathBuf, - expected_workdir: AbsolutePathBuf, - } - - impl EscalationPolicy for AssertingEscalationPolicy { - fn determine_action<'a>( - &'a self, - file: &'a AbsolutePathBuf, - _argv: &'a [String], - workdir: &'a AbsolutePathBuf, - ) -> EscalationPolicyFuture<'a> { - Box::pin(async move { - assert_eq!(file, &self.expected_file); - assert_eq!(workdir, &self.expected_workdir); - Ok(EscalationDecision::run()) - }) - } - } - - struct ForwardingShellCommandExecutor; - - impl ForwardingShellCommandExecutor { - async fn prepare_escalated_exec( - &self, - program: &AbsolutePathBuf, - argv: &[String], - workdir: &AbsolutePathBuf, - env: HashMap, - ) -> anyhow::Result { - Ok(PreparedExec { - command: std::iter::once(program.to_string_lossy().to_string()) - .chain(argv.iter().skip(1).cloned()) - .collect(), - cwd: workdir.to_path_buf(), - env, - arg0: argv.first().cloned(), - }) - } - } - - impl ShellCommandExecutor for ForwardingShellCommandExecutor { - fn run( - &self, - _command: Vec, - _cwd: PathBuf, - _env_overlay: HashMap, - _cancel_rx: CancellationToken, - _after_spawn: Option>, - ) -> ShellCommandExecutorFuture<'_, ExecResult> { - Box::pin(async { - unreachable!("run() is not used by handle_escalate_session_with_policy() tests") - }) - } - - fn prepare_escalated_exec<'a>( - &'a self, - program: &'a AbsolutePathBuf, - argv: &'a [String], - workdir: &'a AbsolutePathBuf, - env: HashMap, - _execution: EscalationExecution, - ) -> ShellCommandExecutorFuture<'a, PreparedExec> { - Box::pin(ForwardingShellCommandExecutor::prepare_escalated_exec( - self, program, argv, workdir, env, - )) - } - } - - struct PermissionAssertingShellCommandExecutor { - expected_permissions: EscalationPermissions, - } - - impl PermissionAssertingShellCommandExecutor { - async fn prepare_escalated_exec( - &self, - program: &AbsolutePathBuf, - argv: &[String], - workdir: &AbsolutePathBuf, - env: HashMap, - execution: EscalationExecution, - ) -> anyhow::Result { - assert_eq!( - execution, - EscalationExecution::Permissions(self.expected_permissions.clone()) - ); - Ok(PreparedExec { - command: std::iter::once(program.to_string_lossy().to_string()) - .chain(argv.iter().skip(1).cloned()) - .collect(), - cwd: workdir.to_path_buf(), - env, - arg0: argv.first().cloned(), - }) - } - } - - impl ShellCommandExecutor for PermissionAssertingShellCommandExecutor { - fn run( - &self, - _command: Vec, - _cwd: PathBuf, - _env_overlay: HashMap, - _cancel_rx: CancellationToken, - _after_spawn: Option>, - ) -> ShellCommandExecutorFuture<'_, ExecResult> { - Box::pin(async { - unreachable!("run() is not used by handle_escalate_session_with_policy() tests") - }) - } - - fn prepare_escalated_exec<'a>( - &'a self, - program: &'a AbsolutePathBuf, - argv: &'a [String], - workdir: &'a AbsolutePathBuf, - env: HashMap, - execution: EscalationExecution, - ) -> ShellCommandExecutorFuture<'a, PreparedExec> { - Box::pin( - PermissionAssertingShellCommandExecutor::prepare_escalated_exec( - self, program, argv, workdir, env, execution, - ), - ) - } - } - - async fn wait_for_pid_file(pid_file: &std::path::Path) -> anyhow::Result { - let deadline = Instant::now() + Duration::from_secs(5); - loop { - if let Ok(contents) = std::fs::read_to_string(pid_file) { - return Ok(contents.trim().parse()?); - } - if Instant::now() >= deadline { - return Err(anyhow::anyhow!( - "timed out waiting for pid file {}", - pid_file.display() - )); - } - sleep(Duration::from_millis(20)).await; - } - } - - fn process_exists(pid: i32) -> bool { - let rc = unsafe { libc::kill(pid, 0) }; - if rc == 0 { - return true; - } - std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) - } - - struct AfterSpawnAssertingShellCommandExecutor { - after_spawn_invoked: Arc, - } - - impl AfterSpawnAssertingShellCommandExecutor { - async fn run( - &self, - env_overlay: HashMap, - after_spawn: Option>, - ) -> anyhow::Result { - let socket_fd = env_overlay - .get(ESCALATE_SOCKET_ENV_VAR) - .expect("session should export shell escalation socket") - .parse::()?; - assert_ne!(unsafe { libc::fcntl(socket_fd, libc::F_GETFD) }, -1); - after_spawn.expect("one-shot exec should install an after-spawn hook")(); - self.after_spawn_invoked.store(true, Ordering::Relaxed); - Ok(ExecResult { - exit_code: 0, - stdout: String::new(), - stderr: String::new(), - output: String::new(), - duration: Duration::ZERO, - timed_out: false, - }) - } - } - - impl ShellCommandExecutor for AfterSpawnAssertingShellCommandExecutor { - fn run( - &self, - _command: Vec, - _cwd: PathBuf, - env_overlay: HashMap, - _cancel_rx: CancellationToken, - after_spawn: Option>, - ) -> ShellCommandExecutorFuture<'_, ExecResult> { - Box::pin(AfterSpawnAssertingShellCommandExecutor::run( - self, - env_overlay, - after_spawn, - )) - } - - fn prepare_escalated_exec<'a>( - &'a self, - _program: &'a AbsolutePathBuf, - _argv: &'a [String], - _workdir: &'a AbsolutePathBuf, - _env: HashMap, - _execution: EscalationExecution, - ) -> ShellCommandExecutorFuture<'a, PreparedExec> { - Box::pin(async { unreachable!("prepare_escalated_exec() is not used by exec() tests") }) - } - } - - async fn wait_for_process_exit(pid: i32) -> anyhow::Result<()> { - let deadline = Instant::now() + Duration::from_secs(5); - loop { - if !process_exists(pid) { - return Ok(()); - } - if Instant::now() >= deadline { - return Err(anyhow::anyhow!("timed out waiting for pid {pid} to exit")); - } - sleep(Duration::from_millis(20)).await; - } - } - - /// Verifies that `start_session()` returns only the wrapper/socket env - /// overlay and does not need to touch the configured shell or wrapper - /// executable paths. - /// - /// The `/bin/zsh` and `/tmp/codex-execve-wrapper` values here are - /// intentionally fake sentinels: this test asserts that the paths are - /// copied into the exported environment and that the socket fd stays valid - /// until `close_client_socket()` is called. - #[tokio::test] - async fn start_session_exposes_wrapper_env_overlay() -> anyhow::Result<()> { - let _guard = ESCALATE_SERVER_TEST_LOCK.acquire().await?; - let execve_wrapper = PathBuf::from("/tmp/codex-execve-wrapper"); - let execve_wrapper_str = execve_wrapper.to_string_lossy().to_string(); - let server = EscalateServer::new( - PathBuf::from("/bin/zsh"), - execve_wrapper, - DeterministicEscalationPolicy { - decision: EscalationDecision::run(), - }, - ); - - let session = server.start_session( - CancellationToken::new(), - Arc::new(ForwardingShellCommandExecutor), - )?; - let env = session.env(); - assert_eq!(env.get(EXEC_WRAPPER_ENV_VAR), Some(&execve_wrapper_str)); - let socket_fd = env - .get(ESCALATE_SOCKET_ENV_VAR) - .expect("session should export shell escalation socket"); - let socket_fd = socket_fd.parse::()?; - assert!(socket_fd >= 0); - assert_ne!(unsafe { libc::fcntl(socket_fd, libc::F_GETFD) }, -1); - assert!( - session - .client_socket - .lock() - .is_ok_and(|socket| socket.is_some()) - ); - session.close_client_socket(); - assert!( - session - .client_socket - .lock() - .is_ok_and(|socket| socket.is_none()) - ); - - Ok(()) - } - - #[tokio::test] - async fn exec_closes_parent_socket_after_shell_spawn() -> anyhow::Result<()> { - let _guard = ESCALATE_SERVER_TEST_LOCK.acquire().await?; - let after_spawn_invoked = Arc::new(AtomicBool::new(false)); - let server = EscalateServer::new( - PathBuf::from("/bin/bash"), - PathBuf::from("/tmp/codex-execve-wrapper"), - DeterministicEscalationPolicy { - decision: EscalationDecision::run(), - }, - ); - - let result = server - .exec( - ExecParams { - command: "true".to_string(), - workdir: AbsolutePathBuf::current_dir()? - .to_string_lossy() - .to_string(), - timeout_ms: None, - login: Some(false), - }, - CancellationToken::new(), - Arc::new(AfterSpawnAssertingShellCommandExecutor { - after_spawn_invoked: Arc::clone(&after_spawn_invoked), - }), - ) - .await?; - assert_eq!(0, result.exit_code); - assert!(after_spawn_invoked.load(Ordering::Relaxed)); - - Ok(()) - } - - #[tokio::test] - async fn handle_escalate_session_respects_run_in_sandbox_decision() -> anyhow::Result<()> { - let _guard = ESCALATE_SERVER_TEST_LOCK.acquire().await?; - let (server, client) = AsyncSocket::pair()?; - let server_task = tokio::spawn(handle_escalate_session_with_policy( - server, - Arc::new(DeterministicEscalationPolicy { - decision: EscalationDecision::run(), - }), - Arc::new(ForwardingShellCommandExecutor), - CancellationToken::new(), - CancellationToken::new(), - )); - - let mut env = HashMap::new(); - for i in 0..10 { - let value = "A".repeat(1024); - env.insert(format!("CODEX_TEST_VAR{i}"), value); - } - - client - .send(EscalateRequest { - file: PathBuf::from("/bin/echo"), - argv: vec!["echo".to_string()], - workdir: AbsolutePathBuf::try_from(PathBuf::from("/tmp"))?, - env, - }) - .await?; - - let response = client.receive::().await?; - assert_eq!( - EscalateResponse { - action: EscalateAction::Run, - }, - response - ); - server_task.await? - } - - #[tokio::test] - async fn handle_escalate_session_resolves_relative_file_against_request_workdir() - -> anyhow::Result<()> { - let _guard = ESCALATE_SERVER_TEST_LOCK.acquire().await?; - let (server, client) = AsyncSocket::pair()?; - let tmp = tempfile::TempDir::new()?; - let workdir = tmp.path().join("workspace"); - std::fs::create_dir(&workdir)?; - let workdir = AbsolutePathBuf::try_from(workdir)?; - let expected_file = workdir.join("bin/tool"); - let server_task = tokio::spawn(handle_escalate_session_with_policy( - server, - Arc::new(AssertingEscalationPolicy { - expected_file, - expected_workdir: workdir.clone(), - }), - Arc::new(ForwardingShellCommandExecutor), - CancellationToken::new(), - CancellationToken::new(), - )); - - client - .send(EscalateRequest { - file: PathBuf::from("./bin/tool"), - argv: vec!["./bin/tool".to_string()], - workdir, - env: HashMap::new(), - }) - .await?; - - let response = client.receive::().await?; - assert_eq!( - EscalateResponse { - action: EscalateAction::Run, - }, - response - ); - server_task.await? - } - - #[tokio::test] - async fn handle_escalate_session_executes_escalated_command() -> anyhow::Result<()> { - let _guard = ESCALATE_SERVER_TEST_LOCK.acquire().await?; - let (server, client) = AsyncSocket::pair()?; - let server_task = tokio::spawn(handle_escalate_session_with_policy( - server, - Arc::new(DeterministicEscalationPolicy { - decision: EscalationDecision::escalate(EscalationExecution::Unsandboxed), - }), - Arc::new(ForwardingShellCommandExecutor), - CancellationToken::new(), - CancellationToken::new(), - )); - - client - .send(EscalateRequest { - file: PathBuf::from("/bin/sh"), - argv: vec![ - "sh".to_string(), - "-c".to_string(), - r#"if [ "$KEY" = VALUE ]; then exit 42; else exit 1; fi"#.to_string(), - ], - workdir: AbsolutePathBuf::current_dir()?, - env: HashMap::from([("KEY".to_string(), "VALUE".to_string())]), - }) - .await?; - - let response = client.receive::().await?; - assert_eq!( - EscalateResponse { - action: EscalateAction::Escalate, - }, - response - ); - - client - .send_with_fds(SuperExecMessage { fds: Vec::new() }, &[]) - .await?; - - let result = client.receive::().await?; - assert_eq!(42, result.exit_code); - - server_task.await? - } - - /// Saves a target descriptor, closes it, and restores it when dropped. - /// - /// The overlap regression test needs the next received `SCM_RIGHTS` handle - /// to land on a specific descriptor number such as stdin. Temporarily - /// closing the descriptor makes that allocation possible while still - /// letting the test put the process back the way it found it. - struct RestoredFd { - target_fd: i32, - original_fd: std::os::fd::OwnedFd, - } - - impl RestoredFd { - /// Duplicates `target_fd`, then closes the original descriptor number. - /// - /// The duplicate is kept alive so `Drop` can restore the original - /// process state after the test finishes. - fn close_temporarily(target_fd: i32) -> anyhow::Result { - let original_fd = unsafe { libc::dup(target_fd) }; - if original_fd == -1 { - return Err(std::io::Error::last_os_error().into()); - } - if unsafe { libc::close(target_fd) } == -1 { - let err = std::io::Error::last_os_error(); - unsafe { - libc::close(original_fd); - } - return Err(err.into()); - } - Ok(Self { - target_fd, - original_fd: unsafe { std::os::fd::OwnedFd::from_raw_fd(original_fd) }, - }) - } - } - - /// Restores the original descriptor back onto its original fd number. - /// - /// This keeps the overlap test self-contained even though it mutates the - /// current process's stdio table. - impl Drop for RestoredFd { - fn drop(&mut self) { - unsafe { - libc::dup2(self.original_fd.as_raw_fd(), self.target_fd); - } - } - } - - #[tokio::test] - async fn handle_escalate_session_accepts_received_fds_that_overlap_destinations() - -> anyhow::Result<()> { - let _guard = ESCALATE_SERVER_TEST_LOCK.acquire().await?; - let mut pipe_fds = [0; 2]; - if unsafe { libc::pipe(pipe_fds.as_mut_ptr()) } == -1 { - return Err(std::io::Error::last_os_error().into()); - } - let read_end = unsafe { std::os::fd::OwnedFd::from_raw_fd(pipe_fds[0]) }; - let mut write_end = unsafe { std::fs::File::from_raw_fd(pipe_fds[1]) }; - - // Force the receive-side overlap case for stdin. - // - // SCM_RIGHTS installs received descriptors into the lowest available fd - // numbers in the receiving process. The pipe is opened first so its - // read end does not consume fd 0. After stdin is temporarily closed, - // receiving `read_end` should reuse descriptor 0. The message below - // also asks the server to map that received fd to destination fd 0, so - // the pre-exec dup2 loop exercises the src_fd == dst_fd case. - let stdin_restore = RestoredFd::close_temporarily(libc::STDIN_FILENO)?; - let (server, client) = AsyncSocket::pair()?; - let server_task = tokio::spawn(handle_escalate_session_with_policy( - server, - Arc::new(DeterministicEscalationPolicy { - decision: EscalationDecision::escalate(EscalationExecution::Unsandboxed), - }), - Arc::new(ForwardingShellCommandExecutor), - CancellationToken::new(), - CancellationToken::new(), - )); - - client - .send(EscalateRequest { - file: PathBuf::from("/bin/sh"), - argv: vec![ - "sh".to_string(), - "-c".to_string(), - "IFS= read -r line && [ \"$line\" = overlap-ok ]".to_string(), - ], - workdir: AbsolutePathBuf::current_dir()?, - env: HashMap::new(), - }) - .await?; - - let response = client.receive::().await?; - assert_eq!( - EscalateResponse { - action: EscalateAction::Escalate, - }, - response - ); - - client - .send_with_fds( - SuperExecMessage { - fds: vec![libc::STDIN_FILENO], - }, - &[read_end], - ) - .await?; - write_end.write_all(b"overlap-ok\n")?; - drop(write_end); - - let result = client.receive::().await?; - assert_eq!( - 0, result.exit_code, - "expected the escalated child to read the sent stdin payload even when the received fd reuses fd 0" - ); - drop(stdin_restore); - - server_task.await? - } - - #[tokio::test] - async fn handle_escalate_session_passes_permissions_to_executor() -> anyhow::Result<()> { - let _guard = ESCALATE_SERVER_TEST_LOCK.acquire().await?; - let (server, client) = AsyncSocket::pair()?; - let server_task = tokio::spawn(handle_escalate_session_with_policy( - server, - Arc::new(DeterministicEscalationPolicy { - decision: EscalationDecision::escalate(EscalationExecution::Permissions( - EscalationPermissions::AdditionalPermissionProfile(PermissionProfile { - network: Some(NetworkPermissions { - enabled: Some(true), - }), - ..Default::default() - }), - )), - }), - Arc::new(PermissionAssertingShellCommandExecutor { - expected_permissions: EscalationPermissions::AdditionalPermissionProfile( - PermissionProfile { - network: Some(NetworkPermissions { - enabled: Some(true), - }), - ..Default::default() - }, - ), - }), - CancellationToken::new(), - CancellationToken::new(), - )); - - client - .send(EscalateRequest { - file: PathBuf::from("/bin/sh"), - argv: vec!["sh".to_string(), "-c".to_string(), "exit 0".to_string()], - workdir: AbsolutePathBuf::current_dir()?, - env: HashMap::new(), - }) - .await?; - - let response = client.receive::().await?; - assert_eq!( - EscalateResponse { - action: EscalateAction::Escalate, - }, - response - ); - - client - .send_with_fds(SuperExecMessage { fds: Vec::new() }, &[]) - .await?; - - let result = client.receive::().await?; - assert_eq!(0, result.exit_code); - - server_task.await? - } - - #[tokio::test] - async fn dropping_session_aborts_intercept_workers_and_kills_spawned_child() - -> anyhow::Result<()> { - let _guard = ESCALATE_SERVER_TEST_LOCK.acquire().await?; - let tmp = TempDir::new()?; - let pid_file = tmp.path().join("escalated-child.pid"); - let pid_file_display = pid_file.display().to_string(); - assert!( - !pid_file_display.contains('\''), - "test temp path should not contain single quotes: {pid_file_display}" - ); - let server = EscalateServer::new( - PathBuf::from("/bin/bash"), - PathBuf::from("/tmp/codex-execve-wrapper"), - DeterministicEscalationPolicy { - decision: EscalationDecision::escalate(EscalationExecution::Unsandboxed), - }, - ); - - let session = server.start_session( - CancellationToken::new(), - Arc::new(ForwardingShellCommandExecutor), - )?; - let socket_fd = session - .env() - .get(ESCALATE_SOCKET_ENV_VAR) - .expect("session should export shell escalation socket") - .parse::()?; - let dup_socket_fd = unsafe { libc::dup(socket_fd) }; - assert!(dup_socket_fd >= 0, "expected dup() to succeed"); - let handshake_client = unsafe { AsyncDatagramSocket::from_raw_fd(dup_socket_fd) }?; - let (server_stream, client_stream) = AsyncSocket::pair()?; - // Keep one local reference to the server end alive until the worker has - // responded once. Without that guard, macOS can observe EOF on the - // client side before the transferred fd is fully servicing the stream. - let server_stream_guard = server_stream.into_inner(); - let dup_server_stream_fd = unsafe { libc::dup(server_stream_guard.as_raw_fd()) }; - assert!( - dup_server_stream_fd >= 0, - "expected dup() of server stream to succeed" - ); - let server_stream_fd = unsafe { std::os::fd::OwnedFd::from_raw_fd(dup_server_stream_fd) }; - handshake_client - .send_with_fds(&[0], &[server_stream_fd]) - .await - .context("failed to send handshake datagram")?; - - client_stream - .send(EscalateRequest { - file: PathBuf::from("/bin/sh"), - argv: vec![ - "sh".to_string(), - "-c".to_string(), - format!("echo $$ > '{pid_file_display}' && exec /bin/sleep 100"), - ], - workdir: AbsolutePathBuf::current_dir()?, - env: HashMap::new(), - }) - .await - .context("failed to send EscalateRequest")?; - - let response = client_stream - .receive::() - .await - .context("failed to receive EscalateResponse")?; - assert_eq!( - EscalateResponse { - action: EscalateAction::Escalate, - }, - response - ); - drop(server_stream_guard); - - client_stream - .send_with_fds(SuperExecMessage { fds: Vec::new() }, &[]) - .await - .context("failed to send SuperExecMessage")?; - - let pid = wait_for_pid_file(&pid_file).await?; - assert!( - process_exists(pid), - "expected spawned child pid {pid} to exist" - ); - - drop(session); - - wait_for_process_exit(pid).await?; - - Ok(()) - } -} diff --git a/codex-rs/shell-escalation/src/unix/escalation_policy.rs b/codex-rs/shell-escalation/src/unix/escalation_policy.rs deleted file mode 100644 index c77af22db7..0000000000 --- a/codex-rs/shell-escalation/src/unix/escalation_policy.rs +++ /dev/null @@ -1,19 +0,0 @@ -use std::future::Future; -use std::pin::Pin; - -use codex_utils_absolute_path::AbsolutePathBuf; - -use crate::unix::escalate_protocol::EscalationDecision; - -/// Decides what action to take in response to an execve request from a client. -pub trait EscalationPolicy: Send + Sync { - fn determine_action<'a>( - &'a self, - file: &'a AbsolutePathBuf, - argv: &'a [String], - workdir: &'a AbsolutePathBuf, - ) -> EscalationPolicyFuture<'a>; -} - -pub type EscalationPolicyFuture<'a> = - Pin> + Send + 'a>>; diff --git a/codex-rs/shell-escalation/src/unix/execve_wrapper.rs b/codex-rs/shell-escalation/src/unix/execve_wrapper.rs deleted file mode 100644 index 58f8d37062..0000000000 --- a/codex-rs/shell-escalation/src/unix/execve_wrapper.rs +++ /dev/null @@ -1,25 +0,0 @@ -//! Entrypoints for execve interception helper binaries. - -use clap::Parser; -use tracing_subscriber::EnvFilter; - -#[derive(Parser)] -pub struct ExecveWrapperCli { - file: String, - - #[arg(trailing_var_arg = true)] - argv: Vec, -} - -#[tokio::main] -pub async fn main_execve_wrapper() -> anyhow::Result<()> { - tracing_subscriber::fmt() - .with_env_filter(EnvFilter::from_default_env()) - .with_writer(std::io::stderr) - .with_ansi(false) - .init(); - - let ExecveWrapperCli { file, argv } = ExecveWrapperCli::parse(); - let exit_code = crate::run_shell_escalation_execve_wrapper(file, argv).await?; - std::process::exit(exit_code); -} diff --git a/codex-rs/shell-escalation/src/unix/mod.rs b/codex-rs/shell-escalation/src/unix/mod.rs deleted file mode 100644 index 8e30056bb4..0000000000 --- a/codex-rs/shell-escalation/src/unix/mod.rs +++ /dev/null @@ -1,81 +0,0 @@ -//! Unix shell-escalation protocol implementation. -//! -//! A patched shell invokes an exec wrapper on every `exec()` attempt. The wrapper sends an -//! `EscalateRequest` over the inherited `CODEX_ESCALATE_SOCKET`, and the server decides whether to -//! run the command directly (`Run`) or execute it on the server side (`Escalate`). -//! -//! Of key importance is the `EscalateRequest` includes a file descriptor for a socket -//! that the server can use to send the response to the execve wrapper. In this -//! way, all descendents of the Server process can use the file descriptor -//! specified by the `CODEX_ESCALATE_SOCKET` environment variable to _send_ escalation requests, -//! but responses are read from a separate socket that is created for each request, which -//! allows the server to handle multiple concurrent escalation requests. -//! -//! ### Escalation flow -//! -//! Command Server Shell Execve Wrapper -//! | -//! o----->o -//! | | -//! | o--(exec)-->o -//! | | | -//! |o<-(EscalateReq)--o -//! || | | -//! |o--(Escalate)---->o -//! || | | -//! |o<---------(fds)--o -//! || | | -//! o<------o | | -//! | || | | -//! x------>o | | -//! || | | -//! |x--(exit code)--->o -//! | | | -//! | o<--(exit)--x -//! | | -//! o<-----x -//! -//! ### Non-escalation flow -//! -//! Server Shell Execve Wrapper Command -//! | -//! o----->o -//! | | -//! | o--(exec)-->o -//! | | | -//! |o<-(EscalateReq)--o -//! || | | -//! |o-(Run)---------->o -//! | | | -//! | | x--(exec)-->o -//! | | | -//! | o<--------------(exit)--x -//! | | -//! o<-----x -//! -pub(crate) mod escalate_client; -pub(crate) mod escalate_protocol; -pub(crate) mod escalate_server; -pub(crate) mod escalation_policy; -pub(crate) mod execve_wrapper; -pub(crate) mod socket; -pub(crate) mod stopwatch; - -pub use self::escalate_client::run_shell_escalation_execve_wrapper; -pub use self::escalate_protocol::ESCALATE_SOCKET_ENV_VAR; -pub use self::escalate_protocol::EscalateAction; -pub use self::escalate_protocol::EscalationDecision; -pub use self::escalate_protocol::EscalationExecution; -pub use self::escalate_server::EscalateServer; -pub use self::escalate_server::EscalationSession; -pub use self::escalate_server::ExecParams; -pub use self::escalate_server::ExecResult; -pub use self::escalate_server::PreparedExec; -pub use self::escalate_server::ShellCommandExecutor; -pub use self::escalate_server::ShellCommandExecutorFuture; -pub use self::escalation_policy::EscalationPolicy; -pub use self::escalation_policy::EscalationPolicyFuture; -pub use self::execve_wrapper::main_execve_wrapper; -pub use self::stopwatch::Stopwatch; -pub use codex_protocol::approvals::EscalationPermissions; -pub use codex_protocol::approvals::ResolvedPermissionProfile; diff --git a/codex-rs/shell-escalation/src/unix/socket.rs b/codex-rs/shell-escalation/src/unix/socket.rs deleted file mode 100644 index a364608fc4..0000000000 --- a/codex-rs/shell-escalation/src/unix/socket.rs +++ /dev/null @@ -1,523 +0,0 @@ -use libc::c_uint; -use serde::Deserialize; -use serde::Serialize; -use socket2::Domain; -use socket2::MaybeUninitSlice; -use socket2::MsgHdr; -use socket2::MsgHdrMut; -use socket2::Socket; -use socket2::Type; -use std::io::IoSlice; -use std::mem::MaybeUninit; -use std::os::fd::AsRawFd; -use std::os::fd::FromRawFd; -use std::os::fd::OwnedFd; -use std::os::fd::RawFd; -use tokio::io::Interest; -use tokio::io::unix::AsyncFd; - -const MAX_FDS_PER_MESSAGE: usize = 16; -const LENGTH_PREFIX_SIZE: usize = size_of::(); -const MAX_DATAGRAM_SIZE: usize = 8192; - -/// Converts a slice of MaybeUninit to a slice of T. -/// -/// The caller guarantees that every element of `buf` is initialized. -fn assume_init(buf: &[MaybeUninit]) -> &[T] { - unsafe { std::slice::from_raw_parts(buf.as_ptr().cast(), buf.len()) } -} - -fn assume_init_slice(buf: &[MaybeUninit; N]) -> &[T; N] { - unsafe { &*(buf as *const [MaybeUninit; N] as *const [T; N]) } -} - -fn assume_init_vec(mut buf: Vec>) -> Vec { - unsafe { - let ptr = buf.as_mut_ptr() as *mut T; - let len = buf.len(); - let cap = buf.capacity(); - std::mem::forget(buf); - Vec::from_raw_parts(ptr, len, cap) - } -} - -fn control_space_for_fds(count: usize) -> usize { - unsafe { libc::CMSG_SPACE((count * size_of::()) as _) as usize } -} - -/// Extracts the FDs from a SCM_RIGHTS control message. -fn extract_fds(control: &[u8]) -> Vec { - let mut fds = Vec::new(); - let mut hdr: libc::msghdr = unsafe { std::mem::zeroed() }; - hdr.msg_control = control.as_ptr() as *mut libc::c_void; - hdr.msg_controllen = control.len() as _; - let hdr = hdr; // drop mut - - let mut cmsg = unsafe { libc::CMSG_FIRSTHDR(&hdr) as *const libc::cmsghdr }; - while !cmsg.is_null() { - let level = unsafe { (*cmsg).cmsg_level }; - let ty = unsafe { (*cmsg).cmsg_type }; - if level == libc::SOL_SOCKET && ty == libc::SCM_RIGHTS { - let data_ptr = unsafe { libc::CMSG_DATA(cmsg).cast::() }; - let fd_count: usize = { - // `cmsghdr::cmsg_len` is not typed consistently across targets, so normalize it - // before doing the size arithmetic. - #[allow(clippy::useless_conversion, clippy::expect_used)] - let cmsg_data_len = usize::try_from(unsafe { (*cmsg).cmsg_len }) - .expect("cmsghdr length fits") - - unsafe { libc::CMSG_LEN(0) as usize }; - cmsg_data_len / size_of::() - }; - for i in 0..fd_count { - let fd = unsafe { data_ptr.add(i).read() }; - fds.push(unsafe { OwnedFd::from_raw_fd(fd) }); - } - } - cmsg = unsafe { libc::CMSG_NXTHDR(&hdr, cmsg) }; - } - fds -} - -/// Read a frame from a SOCK_STREAM socket. -/// -/// A frame is a message length prefix followed by a payload. FDs may be included in the control -/// message when receiving the frame header. -async fn read_frame(async_socket: &AsyncFd) -> std::io::Result<(Vec, Vec)> { - let (message_len, fds) = read_frame_header(async_socket).await?; - let payload = read_frame_payload(async_socket, message_len).await?; - Ok((payload, fds)) -} - -/// Read the frame header (i.e. length) and any FDs from a SOCK_STREAM socket. -async fn read_frame_header( - async_socket: &AsyncFd, -) -> std::io::Result<(usize, Vec)> { - let mut header = [MaybeUninit::::uninit(); LENGTH_PREFIX_SIZE]; - let mut filled = 0; - let mut control = vec![MaybeUninit::::uninit(); control_space_for_fds(MAX_FDS_PER_MESSAGE)]; - let mut captured_control = false; - - while filled < LENGTH_PREFIX_SIZE { - let mut guard = async_socket.readable().await?; - // The first read should come with a control message containing any FDs. - let read = if !captured_control { - match guard.try_io(|inner| { - let mut bufs = [MaybeUninitSlice::new(&mut header[filled..])]; - let (read, control_len) = { - let mut msg = MsgHdrMut::new() - .with_buffers(&mut bufs) - .with_control(&mut control); - let read = inner.get_ref().recvmsg(&mut msg, 0)?; - (read, msg.control_len()) - }; - control.truncate(control_len); - captured_control = true; - Ok(read) - }) { - Ok(Ok(read)) => read, - Ok(Err(err)) => return Err(err), - Err(_would_block) => continue, - } - } else { - match guard.try_io(|inner| inner.get_ref().recv(&mut header[filled..])) { - Ok(Ok(read)) => read, - Ok(Err(err)) => return Err(err), - Err(_would_block) => continue, - } - }; - if read == 0 { - return Err(std::io::Error::new( - std::io::ErrorKind::UnexpectedEof, - "socket closed while receiving frame header", - )); - } - - filled += read; - assert!(filled <= LENGTH_PREFIX_SIZE); - if filled == LENGTH_PREFIX_SIZE { - let len_bytes = assume_init_slice(&header); - let payload_len = u32::from_le_bytes(*len_bytes) as usize; - let fds = extract_fds(assume_init(&control)); - return Ok((payload_len, fds)); - } - } - unreachable!("header loop always returns") -} - -/// Read `message_len` bytes from a SOCK_STREAM socket. -async fn read_frame_payload( - async_socket: &AsyncFd, - message_len: usize, -) -> std::io::Result> { - if message_len == 0 { - return Ok(Vec::new()); - } - let mut payload = vec![MaybeUninit::::uninit(); message_len]; - let mut filled = 0; - while filled < message_len { - let mut guard = async_socket.readable().await?; - let read = match guard.try_io(|inner| inner.get_ref().recv(&mut payload[filled..])) { - Ok(Ok(read)) => read, - Ok(Err(err)) => return Err(err), - Err(_would_block) => continue, - }; - if read == 0 { - return Err(std::io::Error::new( - std::io::ErrorKind::UnexpectedEof, - "socket closed while receiving frame payload", - )); - } - filled += read; - assert!(filled <= message_len); - if filled == message_len { - return Ok(assume_init_vec(payload)); - } - } - unreachable!("loop exits only after returning payload") -} - -fn send_datagram_bytes(socket: &Socket, data: &[u8], fds: &[OwnedFd]) -> std::io::Result<()> { - let control = make_control_message(fds)?; - let payload = [IoSlice::new(data)]; - let msg = if control.is_empty() { - MsgHdr::new().with_buffers(&payload) - } else { - MsgHdr::new().with_buffers(&payload).with_control(&control) - }; - let written = socket.sendmsg(&msg, 0)?; - if written != data.len() { - return Err(std::io::Error::new( - std::io::ErrorKind::WriteZero, - format!( - "short datagram write: wrote {written} bytes out of {}", - data.len() - ), - )); - } - Ok(()) -} - -fn encode_length(len: usize) -> std::io::Result<[u8; LENGTH_PREFIX_SIZE]> { - let len_u32 = u32::try_from(len).map_err(|_| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("message too large: {len}"), - ) - })?; - Ok(len_u32.to_le_bytes()) -} - -fn make_control_message(fds: &[OwnedFd]) -> std::io::Result> { - if fds.len() > MAX_FDS_PER_MESSAGE { - Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("too many fds: {}", fds.len()), - )) - } else if fds.is_empty() { - Ok(Vec::new()) - } else { - let mut control = vec![0u8; control_space_for_fds(fds.len())]; - unsafe { - let cmsg = control.as_mut_ptr().cast::(); - (*cmsg).cmsg_len = - libc::CMSG_LEN(size_of::() as c_uint * fds.len() as c_uint) as _; - (*cmsg).cmsg_level = libc::SOL_SOCKET; - (*cmsg).cmsg_type = libc::SCM_RIGHTS; - let data_ptr = libc::CMSG_DATA(cmsg).cast::(); - for (i, fd) in fds.iter().enumerate() { - data_ptr.add(i).write(fd.as_raw_fd()); - } - } - Ok(control) - } -} - -fn receive_datagram_bytes(socket: &Socket) -> std::io::Result<(Vec, Vec)> { - let mut buffer = vec![MaybeUninit::::uninit(); MAX_DATAGRAM_SIZE]; - let mut control = vec![MaybeUninit::::uninit(); control_space_for_fds(MAX_FDS_PER_MESSAGE)]; - let (read, control_len) = { - let mut bufs = [MaybeUninitSlice::new(&mut buffer)]; - let mut msg = MsgHdrMut::new() - .with_buffers(&mut bufs) - .with_control(&mut control); - let read = socket.recvmsg(&mut msg, 0)?; - (read, msg.control_len()) - }; - let data = assume_init(&buffer[..read]).to_vec(); - let fds = extract_fds(assume_init(&control[..control_len])); - Ok((data, fds)) -} - -pub(crate) struct AsyncSocket { - inner: AsyncFd, -} - -impl AsyncSocket { - fn new(socket: Socket) -> std::io::Result { - socket.set_nonblocking(true)?; - let async_socket = AsyncFd::new(socket)?; - Ok(AsyncSocket { - inner: async_socket, - }) - } - - pub fn from_fd(fd: OwnedFd) -> std::io::Result { - AsyncSocket::new(Socket::from(fd)) - } - - pub fn pair() -> std::io::Result<(AsyncSocket, AsyncSocket)> { - // `socket2::Socket::pair()` also applies "common flags" (including - // `SO_NOSIGPIPE` on Apple platforms), which can fail for AF_UNIX sockets. - // Use `pair_raw()` to avoid those side effects, then restore `CLOEXEC` - // explicitly on both endpoints. - let (server, client) = Socket::pair_raw(Domain::UNIX, Type::STREAM, None)?; - server.set_cloexec(true)?; - client.set_cloexec(true)?; - Ok((AsyncSocket::new(server)?, AsyncSocket::new(client)?)) - } - - pub async fn send_with_fds( - &self, - msg: T, - fds: &[OwnedFd], - ) -> std::io::Result<()> { - let payload = serde_json::to_vec(&msg)?; - let mut frame = Vec::with_capacity(LENGTH_PREFIX_SIZE + payload.len()); - frame.extend_from_slice(&encode_length(payload.len())?); - frame.extend_from_slice(&payload); - send_stream_frame(&self.inner, &frame, fds).await - } - - pub async fn receive_with_fds Deserialize<'de>>( - &self, - ) -> std::io::Result<(T, Vec)> { - let (payload, fds) = read_frame(&self.inner).await?; - let message: T = serde_json::from_slice(&payload)?; - Ok((message, fds)) - } - - pub async fn send(&self, msg: T) -> std::io::Result<()> - where - T: Serialize, - { - self.send_with_fds(&msg, &[]).await - } - - pub async fn receive Deserialize<'de>>(&self) -> std::io::Result { - let (msg, fds) = self.receive_with_fds().await?; - if !fds.is_empty() { - tracing::warn!("unexpected fds in receive: {}", fds.len()); - } - Ok(msg) - } - - pub fn into_inner(self) -> Socket { - self.inner.into_inner() - } -} - -async fn send_stream_frame( - socket: &AsyncFd, - frame: &[u8], - fds: &[OwnedFd], -) -> std::io::Result<()> { - let mut written = 0; - let mut include_fds = !fds.is_empty(); - while written < frame.len() { - let mut guard = socket.writable().await?; - let bytes_written = match guard - .try_io(|inner| send_stream_chunk(inner.get_ref(), &frame[written..], fds, include_fds)) - { - Ok(result) => result?, - Err(_would_block) => continue, - }; - if bytes_written == 0 { - return Err(std::io::Error::new( - std::io::ErrorKind::WriteZero, - "socket closed while sending frame payload", - )); - } - written += bytes_written; - include_fds = false; - } - Ok(()) -} - -fn send_stream_chunk( - socket: &Socket, - frame: &[u8], - fds: &[OwnedFd], - include_fds: bool, -) -> std::io::Result { - let control = if include_fds { - make_control_message(fds)? - } else { - Vec::new() - }; - let payload = [IoSlice::new(frame)]; - let msg = if control.is_empty() { - MsgHdr::new().with_buffers(&payload) - } else { - MsgHdr::new().with_buffers(&payload).with_control(&control) - }; - socket.sendmsg(&msg, 0) -} - -pub(crate) struct AsyncDatagramSocket { - inner: AsyncFd, -} - -impl AsyncDatagramSocket { - fn new(socket: Socket) -> std::io::Result { - socket.set_nonblocking(true)?; - Ok(Self { - inner: AsyncFd::new(socket)?, - }) - } - - pub unsafe fn from_raw_fd(fd: RawFd) -> std::io::Result { - Self::new(unsafe { Socket::from_raw_fd(fd) }) - } - - pub fn pair() -> std::io::Result<(Self, Self)> { - // `socket2::Socket::pair()` also applies "common flags" (including - // `SO_NOSIGPIPE` on Apple platforms), which can fail for AF_UNIX sockets. - // Use `pair_raw()` to avoid those side effects, then restore `CLOEXEC` - // explicitly on both endpoints. - let (server, client) = Socket::pair_raw(Domain::UNIX, Type::DGRAM, None)?; - server.set_cloexec(true)?; - client.set_cloexec(true)?; - Ok((Self::new(server)?, Self::new(client)?)) - } - - pub async fn send_with_fds(&self, data: &[u8], fds: &[OwnedFd]) -> std::io::Result<()> { - self.inner - .async_io(Interest::WRITABLE, |socket| { - send_datagram_bytes(socket, data, fds) - }) - .await - } - - pub async fn receive_with_fds(&self) -> std::io::Result<(Vec, Vec)> { - self.inner - .async_io(Interest::READABLE, receive_datagram_bytes) - .await - } - - pub fn into_inner(self) -> Socket { - self.inner.into_inner() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use serde::Deserialize; - use serde::Serialize; - use std::os::fd::AsFd; - use std::os::fd::AsRawFd; - use tempfile::NamedTempFile; - - #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] - struct TestPayload { - id: i32, - label: String, - } - - fn fd_list(count: usize) -> std::io::Result> { - let file = NamedTempFile::new()?; - let mut fds = Vec::new(); - for _ in 0..count { - fds.push(file.as_fd().try_clone_to_owned()?); - } - Ok(fds) - } - - #[tokio::test] - async fn async_socket_round_trips_payload_and_fds() -> std::io::Result<()> { - let (server, client) = AsyncSocket::pair()?; - let payload = TestPayload { - id: 7, - label: "round-trip".to_string(), - }; - let send_fds = fd_list(/*count*/ 1)?; - - let receive_task = - tokio::spawn(async move { server.receive_with_fds::().await }); - client.send_with_fds(payload.clone(), &send_fds).await?; - drop(send_fds); - - let (received_payload, received_fds) = receive_task.await.unwrap()?; - assert_eq!(payload, received_payload); - assert_eq!(1, received_fds.len()); - let fd_status = unsafe { libc::fcntl(received_fds[0].as_raw_fd(), libc::F_GETFD) }; - assert!( - fd_status >= 0, - "expected received file descriptor to be valid, but got {fd_status}", - ); - Ok(()) - } - - #[tokio::test] - async fn async_socket_handles_large_payload() -> std::io::Result<()> { - let (server, client) = AsyncSocket::pair()?; - let payload = vec![b'A'; 10_000]; - let receive_task = tokio::spawn(async move { server.receive::>().await }); - client.send(payload.clone()).await?; - let received_payload = receive_task.await.unwrap()?; - assert_eq!(payload, received_payload); - Ok(()) - } - - #[tokio::test] - async fn async_datagram_sockets_round_trip_messages() -> std::io::Result<()> { - let (server, client) = AsyncDatagramSocket::pair()?; - let data = b"datagram payload".to_vec(); - let send_fds = fd_list(/*count*/ 1)?; - let receive_task = tokio::spawn(async move { server.receive_with_fds().await }); - - client.send_with_fds(&data, &send_fds).await?; - drop(send_fds); - - let (received_bytes, received_fds) = receive_task.await.unwrap()?; - assert_eq!(data, received_bytes); - assert_eq!(1, received_fds.len()); - Ok(()) - } - - #[test] - fn send_datagram_bytes_rejects_excessive_fd_counts() -> std::io::Result<()> { - let (socket, _peer) = Socket::pair_raw(Domain::UNIX, Type::DGRAM, None)?; - let fds = fd_list(MAX_FDS_PER_MESSAGE + 1)?; - let err = send_datagram_bytes(&socket, b"hi", &fds).unwrap_err(); - assert_eq!(std::io::ErrorKind::InvalidInput, err.kind()); - Ok(()) - } - - #[test] - fn send_stream_chunk_rejects_excessive_fd_counts() -> std::io::Result<()> { - let (socket, _peer) = Socket::pair_raw(Domain::UNIX, Type::STREAM, None)?; - let fds = fd_list(MAX_FDS_PER_MESSAGE + 1)?; - let err = send_stream_chunk(&socket, b"hello", &fds, /*include_fds*/ true).unwrap_err(); - assert_eq!(std::io::ErrorKind::InvalidInput, err.kind()); - Ok(()) - } - - #[test] - fn encode_length_errors_for_oversized_messages() { - let err = encode_length(usize::MAX).unwrap_err(); - assert_eq!(std::io::ErrorKind::InvalidInput, err.kind()); - } - - #[tokio::test] - async fn receive_fails_when_peer_closes_before_header() { - let (server, client) = AsyncSocket::pair().expect("failed to create socket pair"); - drop(client); - let err = server - .receive::() - .await - .expect_err("expected read failure"); - assert_eq!(std::io::ErrorKind::UnexpectedEof, err.kind()); - } -} diff --git a/codex-rs/shell-escalation/src/unix/stopwatch.rs b/codex-rs/shell-escalation/src/unix/stopwatch.rs deleted file mode 100644 index 19821d8be2..0000000000 --- a/codex-rs/shell-escalation/src/unix/stopwatch.rs +++ /dev/null @@ -1,237 +0,0 @@ -use std::future::Future; -use std::sync::Arc; -use std::time::Duration; -use std::time::Instant; - -use tokio::sync::Mutex; -use tokio::sync::Notify; -use tokio_util::sync::CancellationToken; - -#[derive(Clone, Debug)] -pub struct Stopwatch { - limit: Option, - inner: Arc>, - notify: Arc, -} - -#[derive(Debug)] -struct StopwatchState { - elapsed: Duration, - running_since: Option, - active_pauses: u32, -} - -impl Stopwatch { - pub fn new(limit: Duration) -> Self { - Self { - inner: Arc::new(Mutex::new(StopwatchState { - elapsed: Duration::ZERO, - running_since: Some(Instant::now()), - active_pauses: 0, - })), - notify: Arc::new(Notify::new()), - limit: Some(limit), - } - } - - pub fn unlimited() -> Self { - Self { - inner: Arc::new(Mutex::new(StopwatchState { - elapsed: Duration::ZERO, - running_since: Some(Instant::now()), - active_pauses: 0, - })), - notify: Arc::new(Notify::new()), - limit: None, - } - } - - pub fn cancellation_token(&self) -> CancellationToken { - let token = CancellationToken::new(); - let Some(limit) = self.limit else { - return token; - }; - let cancel = token.clone(); - let inner = Arc::clone(&self.inner); - let notify = Arc::clone(&self.notify); - tokio::spawn(async move { - loop { - let (remaining, running) = { - let guard = inner.lock().await; - let elapsed = guard.elapsed - + guard - .running_since - .map(|since| since.elapsed()) - .unwrap_or_default(); - if elapsed >= limit { - break; - } - (limit - elapsed, guard.running_since.is_some()) - }; - - if !running { - notify.notified().await; - continue; - } - - let sleep = tokio::time::sleep(remaining); - tokio::pin!(sleep); - tokio::select! { - _ = &mut sleep => { - break; - } - _ = notify.notified() => { - continue; - } - } - } - cancel.cancel(); - }); - token - } - - /// Runs `fut`, pausing the stopwatch while the future is pending. The clock - /// resumes automatically when the future completes. Nested/overlapping - /// calls are reference-counted so the stopwatch only resumes when every - /// pause is lifted. - pub async fn pause_for(&self, fut: F) -> T - where - F: Future, - { - self.pause().await; - let result = fut.await; - self.resume().await; - result - } - - async fn pause(&self) { - let mut guard = self.inner.lock().await; - guard.active_pauses += 1; - if guard.active_pauses == 1 - && let Some(since) = guard.running_since.take() - { - guard.elapsed += since.elapsed(); - self.notify.notify_waiters(); - } - } - - async fn resume(&self) { - let mut guard = self.inner.lock().await; - if guard.active_pauses == 0 { - return; - } - guard.active_pauses -= 1; - if guard.active_pauses == 0 && guard.running_since.is_none() { - guard.running_since = Some(Instant::now()); - self.notify.notify_waiters(); - } - } -} - -#[cfg(test)] -mod tests { - use super::Stopwatch; - use tokio::time::Duration; - use tokio::time::Instant; - use tokio::time::sleep; - use tokio::time::timeout; - - #[tokio::test] - async fn cancellation_receiver_fires_after_limit() { - let stopwatch = Stopwatch::new(Duration::from_millis(50)); - let token = stopwatch.cancellation_token(); - let start = Instant::now(); - token.cancelled().await; - assert!(start.elapsed() >= Duration::from_millis(50)); - } - - #[tokio::test] - async fn pause_prevents_timeout_until_resumed() { - let stopwatch = Stopwatch::new(Duration::from_millis(50)); - let token = stopwatch.cancellation_token(); - - let pause_handle = tokio::spawn({ - let stopwatch = stopwatch.clone(); - async move { - stopwatch - .pause_for(async { - sleep(Duration::from_millis(100)).await; - }) - .await; - } - }); - - assert!( - timeout(Duration::from_millis(30), token.cancelled()) - .await - .is_err() - ); - - pause_handle.await.expect("pause task should finish"); - - token.cancelled().await; - } - - #[tokio::test] - async fn overlapping_pauses_only_resume_once() { - let stopwatch = Stopwatch::new(Duration::from_millis(50)); - let token = stopwatch.cancellation_token(); - - // First pause. - let pause1 = { - let stopwatch = stopwatch.clone(); - tokio::spawn(async move { - stopwatch - .pause_for(async { - sleep(Duration::from_millis(80)).await; - }) - .await; - }) - }; - - // Overlapping pause that ends sooner. - let pause2 = { - let stopwatch = stopwatch.clone(); - tokio::spawn(async move { - stopwatch - .pause_for(async { - sleep(Duration::from_millis(30)).await; - }) - .await; - }) - }; - - // While both pauses are active, the cancellation should not fire. - assert!( - timeout(Duration::from_millis(40), token.cancelled()) - .await - .is_err() - ); - - pause2.await.expect("short pause should complete"); - - // Still paused because the long pause is active. - assert!( - timeout(Duration::from_millis(30), token.cancelled()) - .await - .is_err() - ); - - pause1.await.expect("long pause should complete"); - - // Now the stopwatch should resume and hit the limit shortly after. - token.cancelled().await; - } - - #[tokio::test] - async fn unlimited_stopwatch_never_cancels() { - let stopwatch = Stopwatch::unlimited(); - let token = stopwatch.cancellation_token(); - - assert!( - timeout(Duration::from_millis(30), token.cancelled()) - .await - .is_err() - ); - } -} diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 640f3a1855..b91d65cd9e 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -341,7 +341,7 @@ async fn new_config( file_opener: UriBasedFileOpener::VsCode, codex_self_exe: arg0_paths.codex_self_exe, codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe, - main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe, + main_execve_wrapper_exe: None, zsh_path: None, model_reasoning_effort: None, plan_mode_reasoning_effort: None, diff --git a/codex-rs/tools/Cargo.toml b/codex-rs/tools/Cargo.toml index f47815537f..fc75ad22ce 100644 --- a/codex-rs/tools/Cargo.toml +++ b/codex-rs/tools/Cargo.toml @@ -15,7 +15,6 @@ codex-features = { workspace = true } codex-file-system = { workspace = true } codex-extension-items = { workspace = true } codex-protocol = { workspace = true } -codex-utils-absolute-path = { workspace = true } codex-utils-output-truncation = { workspace = true } codex-utils-path-uri = { workspace = true } codex-utils-string = { workspace = true } @@ -29,7 +28,6 @@ rmcp = { workspace = true, default-features = false, features = [ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, features = ["raw_value"] } thiserror = { workspace = true } -tracing = { workspace = true } urlencoding = { workspace = true } [dev-dependencies] diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 29c02afabb..a89697a2e9 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -80,9 +80,6 @@ pub use tool_call::ToolEnvironment; pub use tool_call::TurnItemEmissionFuture; pub use tool_call::TurnItemEmitter; pub use tool_config::ToolEnvironmentMode; -pub use tool_config::ToolUserShellType; -pub use tool_config::UnifiedExecShellMode; -pub use tool_config::ZshForkConfig; pub use tool_config::request_user_input_available_modes; pub use tool_definition::ToolDefinition; pub use tool_discovery::DiscoverablePluginInfo; diff --git a/codex-rs/tools/src/tool_config.rs b/codex-rs/tools/src/tool_config.rs index ea2a9688a7..3609405509 100644 --- a/codex-rs/tools/src/tool_config.rs +++ b/codex-rs/tools/src/tool_config.rs @@ -2,17 +2,6 @@ use codex_features::Feature; use codex_features::Features; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::TUI_VISIBLE_COLLABORATION_MODES; -use codex_utils_absolute_path::AbsolutePathBuf; -use std::path::PathBuf; - -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -pub enum ToolUserShellType { - Zsh, - Bash, - PowerShell, - Sh, - Cmd, -} pub fn request_user_input_available_modes(features: &Features) -> Vec { TUI_VISIBLE_COLLABORATION_MODES @@ -25,58 +14,6 @@ pub fn request_user_input_available_modes(features: &Features) -> Vec .collect() } -#[derive(Debug, Clone, Eq, PartialEq)] -pub enum UnifiedExecShellMode { - Direct, - ZshFork(ZshForkConfig), -} - -#[derive(Debug, Clone, Eq, PartialEq)] -pub struct ZshForkConfig { - pub shell_zsh_path: AbsolutePathBuf, - pub main_execve_wrapper_exe: AbsolutePathBuf, -} - -impl UnifiedExecShellMode { - pub fn for_session( - features: &Features, - user_shell_type: ToolUserShellType, - shell_zsh_path: Option<&PathBuf>, - main_execve_wrapper_exe: Option<&PathBuf>, - ) -> Self { - if cfg!(unix) - && features.enabled(Feature::ShellTool) - && features.enabled(Feature::UnifiedExec) - && features.enabled(Feature::ShellZshFork) - && features.enabled(Feature::UnifiedExecZshFork) - && matches!(user_shell_type, ToolUserShellType::Zsh) - && let (Some(shell_zsh_path), Some(main_execve_wrapper_exe)) = - (shell_zsh_path, main_execve_wrapper_exe) - && let (Ok(shell_zsh_path), Ok(main_execve_wrapper_exe)) = ( - AbsolutePathBuf::try_from(shell_zsh_path.as_path()).inspect_err(|err| { - tracing::warn!( - "Failed to convert shell_zsh_path `{shell_zsh_path:?}`: {err:?}" - ) - }), - AbsolutePathBuf::try_from(main_execve_wrapper_exe.as_path()).inspect_err( - |err| { - tracing::warn!( - "Failed to convert main_execve_wrapper_exe `{main_execve_wrapper_exe:?}`: {err:?}" - ) - }, - ), - ) - { - Self::ZshFork(ZshForkConfig { - shell_zsh_path, - main_execve_wrapper_exe, - }) - } else { - Self::Direct - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ToolEnvironmentMode { None, diff --git a/codex-rs/tools/src/tool_config_tests.rs b/codex-rs/tools/src/tool_config_tests.rs index 69e6c13117..61ec0415b6 100644 --- a/codex-rs/tools/src/tool_config_tests.rs +++ b/codex-rs/tools/src/tool_config_tests.rs @@ -20,70 +20,3 @@ fn request_user_input_modes_follow_default_mode_feature() { vec![ModeKind::Default, ModeKind::Plan] ); } - -#[test] -fn unified_exec_shell_mode_respects_feature_and_policy_gates() { - let exe = std::env::current_exe().expect("current exe path"); - let shell = exe.clone(); - let mut features = Features::with_defaults(); - features.enable(Feature::ShellTool); - features.enable(Feature::ShellZshFork); - let mode = UnifiedExecShellMode::for_session( - &features, - ToolUserShellType::Zsh, - Some(&shell), - Some(&exe), - ); - if cfg!(unix) { - assert!(matches!(mode, UnifiedExecShellMode::ZshFork(_))); - } else { - assert_eq!(mode, UnifiedExecShellMode::Direct); - } - - features.disable(Feature::ShellZshFork); - assert_eq!( - UnifiedExecShellMode::for_session( - &features, - ToolUserShellType::Zsh, - Some(&shell), - Some(&exe), - ), - UnifiedExecShellMode::Direct - ); - - features.enable(Feature::ShellZshFork); - features.disable(Feature::UnifiedExecZshFork); - assert_eq!( - UnifiedExecShellMode::for_session( - &features, - ToolUserShellType::Zsh, - Some(&shell), - Some(&exe), - ), - UnifiedExecShellMode::Direct - ); - - features.enable(Feature::UnifiedExecZshFork); - features.disable(Feature::UnifiedExec); - assert_eq!( - UnifiedExecShellMode::for_session( - &features, - ToolUserShellType::Zsh, - Some(&shell), - Some(&exe), - ), - UnifiedExecShellMode::Direct - ); - - features.enable(Feature::UnifiedExec); - features.disable(Feature::ShellTool); - assert_eq!( - UnifiedExecShellMode::for_session( - &features, - ToolUserShellType::Zsh, - Some(&shell), - Some(&exe), - ), - UnifiedExecShellMode::Direct - ); -} diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__legacy_zsh_migration_notice_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__legacy_zsh_migration_notice_snapshot.snap new file mode 100644 index 0000000000..9a790a64a5 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__legacy_zsh_migration_notice_snapshot.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/history_cell/tests.rs +expression: "render_lines(&cell.transcript_lines(80)).join(\"\\n\")" +--- +⚠ `[features].shell_zsh_fork` is deprecated and ignored. +The patched zsh backend has been removed. Shell commands now use the +standard shell executor. Remove `shell_zsh_fork` from [features] in +config.toml or profile configuration files. diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index ea59afbbe7..f3b62e10dd 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -2965,6 +2965,18 @@ fn deprecation_notice_renders_summary_with_details() { ); } +#[test] +fn legacy_zsh_migration_notice_snapshot() { + let mut features = codex_features::Features::default(); + features.record_legacy_usage_force("shell_zsh_fork", codex_features::Feature::ShellZshFork); + let usage = features + .legacy_feature_usages() + .next() + .expect("migration notice"); + let cell = new_deprecation_notice(usage.summary.clone(), usage.details.clone()); + insta::assert_snapshot!(render_lines(&cell.transcript_lines(/*width*/ 80)).join("\n")); +} + #[test] fn agent_markdown_cell_renders_source_at_different_widths() { let source = diff --git a/codex-rs/tui/src/session_archive_commands.rs b/codex-rs/tui/src/session_archive_commands.rs index ea37b0fe20..47e4608d7d 100644 --- a/codex-rs/tui/src/session_archive_commands.rs +++ b/codex-rs/tui/src/session_archive_commands.rs @@ -368,7 +368,6 @@ pub(super) async fn start_app_server_for_session_command( model_provider, codex_self_exe: arg0_paths.codex_self_exe.clone(), codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(), - main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe.clone(), show_raw_agent_reasoning: cli.oss.then_some(true), bypass_hook_trust: cli.bypass_hook_trust.then_some(true), ..Default::default() diff --git a/codex-rs/tui/src/startup_orchestration.rs b/codex-rs/tui/src/startup_orchestration.rs index 87c1c42f54..51bbad1b67 100644 --- a/codex-rs/tui/src/startup_orchestration.rs +++ b/codex-rs/tui/src/startup_orchestration.rs @@ -441,7 +441,6 @@ pub(super) async fn run_main_inner( model_provider: model_provider_override.clone(), codex_self_exe: arg0_paths.codex_self_exe.clone(), codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(), - main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe.clone(), show_raw_agent_reasoning: cli.oss.then_some(true), bypass_hook_trust: cli.bypass_hook_trust.then_some(true), additional_writable_roots: additional_dirs,