diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 37189d57ed..12b51d55ed 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -754,6 +754,9 @@ "shell_snapshot": { "type": "boolean" }, + "shell_snapshot_v2": { + "type": "boolean" + }, "shell_tool": { "type": "boolean" }, @@ -5712,6 +5715,9 @@ "shell_snapshot": { "type": "boolean" }, + "shell_snapshot_v2": { + "type": "boolean" + }, "shell_tool": { "type": "boolean" }, diff --git a/codex-rs/core/src/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index 0e7718bade..2210f2823c 100644 --- a/codex-rs/core/src/environment_selection.rs +++ b/codex-rs/core/src/environment_selection.rs @@ -137,6 +137,7 @@ struct ResolvedEnvironment { environment: Arc, shell: Option, shell_snapshot: ShellSnapshotTask, + shell_snapshot_v2_supported: bool, installed_config: Option, } @@ -239,6 +240,7 @@ impl ThreadEnvironments { environment: environment.environment, shell: environment.shell, shell_snapshot: environment.shell_snapshot, + shell_snapshot_v2_supported: environment.shell_snapshot_v2_supported, installed_config: None, })) .boxed() @@ -602,24 +604,27 @@ impl ThreadEnvironments { }; // Resolve the attachment only after both prerequisites are ready. let ((), installed_config) = tokio::try_join!(connection_ready, configuration_ready)?; - let shell = if environment.is_remote() { + let (shell, shell_snapshot_v2_supported) = if environment.is_remote() { match environment.info().await { - Ok(info) => match Shell::from_environment_shell_info(info.shell) { - Ok(shell) => Some(shell), - Err(err) => { - tracing::warn!( - "failed to resolve shell for environment `{environment_id}`: {err}" - ); - None - } - }, + Ok(info) => ( + match Shell::from_environment_shell_info(info.shell) { + Ok(shell) => Some(shell), + Err(err) => { + tracing::warn!( + "failed to resolve shell for environment `{environment_id}`: {err}" + ); + None + } + }, + info.capabilities.shell_snapshot_v2, + ), Err(err) => { tracing::warn!("failed to get info for environment `{environment_id}`: {err}"); - None + (None, false) } } } else { - Some(local_shell) + (Some(local_shell), cfg!(unix)) }; let task = shell_snapshot .build(Arc::clone(&environment), selection.cwd, shell.clone()) @@ -632,6 +637,7 @@ impl ThreadEnvironments { environment, shell, shell_snapshot: task, + shell_snapshot_v2_supported, installed_config, }) } @@ -707,6 +713,8 @@ impl TurnEnvironmentState { environment.shell, ); turn_environment.shell_snapshot = environment.shell_snapshot; + turn_environment.shell_snapshot_v2_supported = + environment.shell_snapshot_v2_supported; Some(Self::Ready(turn_environment)) } Some(Err(err)) => { diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 8a8994bff3..8f8a256b31 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -408,6 +408,7 @@ pub(crate) async fn execute_exec_request( cwd, env, exec_server_env_config: _, + exec_server_shell_snapshot: _, network, expiration, capture_policy, diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index f25320ac34..af3647d096 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -52,6 +52,7 @@ pub struct ExecRequest { pub cwd: PathUri, pub env: HashMap, pub(crate) exec_server_env_config: Option, + pub(crate) exec_server_shell_snapshot: Option, pub network: Option, pub network_environment_id: Option, pub expiration: ExecExpiration, @@ -94,6 +95,7 @@ impl ExecRequest { cwd, env, exec_server_env_config: None, + exec_server_shell_snapshot: None, network, network_environment_id, expiration, @@ -182,6 +184,7 @@ impl ExecRequest { cwd, env, exec_server_env_config: None, + exec_server_shell_snapshot: None, network, network_environment_id, expiration, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index a492b0cc2c..b5441ca3de 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1164,7 +1164,21 @@ impl Session { } else { shell::default_user_shell() }; - let shell_snapshot = if config.features.enabled(Feature::ShellSnapshot) { + let use_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 + ); + let shell_snapshot = if config.features.enabled(Feature::ShellSnapshot) + && !use_executor_shell_snapshots + { ShellSnapshot::new( config.codex_home.clone(), thread_id, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index feb016d676..dcdaa4771c 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -42,6 +42,7 @@ pub(crate) struct TurnEnvironment { pub(crate) environment: Arc, pub(crate) shell: Option, pub(crate) shell_snapshot: ShellSnapshotTask, + pub(crate) shell_snapshot_v2_supported: bool, } impl TurnEnvironment { @@ -58,6 +59,7 @@ impl TurnEnvironment { environment, shell, shell_snapshot: futures::future::ready(None).boxed().shared(), + shell_snapshot_v2_supported: false, } } diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index f340080978..7b55a58fc0 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -207,6 +207,7 @@ pub(crate) async fn execute_user_shell_command( cwd: cwd.clone().into(), env: exec_env_map, exec_server_env_config: None, + exec_server_shell_snapshot: None, // `/shell` is the explicit full-access escape hatch, so it must not // inherit a managed proxy from the surrounding session or turn. network: None, diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index eae1f0ba22..faed4864ef 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -71,6 +71,7 @@ pub struct UnifiedExecRequest { pub turn_environment: TurnEnvironment, pub env: HashMap, pub exec_server_env_config: Option, + pub shell_snapshot: Option, pub explicit_env_overrides: HashMap, pub network: Option, pub tty: bool, @@ -201,7 +202,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt } fn uses_executor_managed_process_sandbox(&self, req: &UnifiedExecRequest) -> bool { - req.turn_environment.environment.is_remote() + req.turn_environment.environment.is_remote() || req.shell_snapshot.is_some() } fn sandbox_cwd<'b>(&self, req: &'b UnifiedExecRequest) -> Option<&'b PathUri> { @@ -368,7 +369,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt }; #[cfg(not(unix))] let runtime_path_prepends = RuntimePathPrepends::default(); - let command = if environment_is_remote { + let mut command = if environment_is_remote { base_command.to_vec() } else { maybe_wrap_shell_lc_with_snapshot( @@ -380,6 +381,15 @@ impl<'a> ToolRuntime for UnifiedExecRunt &runtime_path_prepends, ) }; + if req.shell_snapshot.is_some() { + let exports = + runtime_path_prepends.shell_exports_after_snapshot(&explicit_env_overrides); + if !exports.is_empty() + && let Some(script) = command.get_mut(2) + { + *script = format!("{exports}\n{script}"); + } + } let command = disable_powershell_profile_for_elevated_windows_sandbox( &command, Some(&req.shell_type), @@ -491,6 +501,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt network_proxy_launch, /*environment_id*/ Some(&req.turn_environment.selection.environment_id), req.exec_server_env_config.clone(), + req.shell_snapshot.clone(), windows_sandbox_proxy_settings_mode, req.tty, Box::new(NoopSpawnLifecycle), @@ -616,6 +627,7 @@ mod tests { turn_environment: test_turn_environment(sandbox_cwd.clone().into()), env: HashMap::new(), exec_server_env_config: None, + shell_snapshot: None, explicit_env_overrides: HashMap::new(), network: None, tty: false, @@ -718,6 +730,7 @@ mod tests { turn_environment: test_turn_environment(cwd.into()), env: HashMap::new(), exec_server_env_config: None, + shell_snapshot: None, explicit_env_overrides: HashMap::new(), network: None, tty: false, 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 index 6bf5f3a533..e5c3cc6504 100644 --- a/codex-rs/core/src/tools/runtimes/zsh_fork/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/zsh_fork/unix_escalation.rs @@ -632,6 +632,7 @@ impl CoreShellCommandExecutor { 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), diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 77b23625c6..a4c096e51b 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -51,6 +51,7 @@ mod head_tail_buffer; mod process; mod process_manager; mod process_state; +mod shell_snapshot; pub(crate) fn set_deterministic_process_ids_for_tests(enabled: bool) { process_manager::set_deterministic_process_ids_for_tests(enabled); diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index db28caad1f..3c5d628576 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -60,6 +60,7 @@ use crate::unified_exec::head_tail_buffer::HeadTailBuffer; 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; use codex_core_plugins::PLUGIN_METRICS_OUTPUT_ENV_VAR; use codex_core_plugins::PluginCommandAttribution; @@ -186,6 +187,11 @@ fn exec_server_env_for_request( if let Some(exec_server_env_config) = &request.exec_server_env_config { let mut env = env_overlay_for_exec_server(&request.env, &exec_server_env_config.local_policy_env); + if request.exec_server_shell_snapshot.is_some() + && !exec_server_env_config.policy.r#set.contains_key("PATH") + { + env.remove("PATH"); + } if request.exec_server_managed_network.is_some() { for (key, value) in &request.env { if is_managed_proxy_env_var(key, value) { @@ -210,18 +216,20 @@ fn exec_server_params_for_request( sandbox.windows_sandbox_proxy_settings_mode = Some(windows_sandbox_proxy_settings_mode); sandbox }); - // Sandbox retries reuse the unified-exec ID but start a distinct executor process. - let exec_server_process_id = if request.exec_server_sandbox.is_some() { - format!("{process_id}-{}", Uuid::new_v4()) - } else { - process_id.to_string() - }; + // Sandbox retries and memory-backed local launches can reuse a unified-exec + // ID while the executor still retains the previous process. + let exec_server_process_id = + if request.exec_server_sandbox.is_some() || request.exec_server_shell_snapshot.is_some() { + format!("{process_id}-{}", Uuid::new_v4()) + } else { + process_id.to_string() + }; codex_exec_server::ExecParams { process_id: exec_server_process_id.into(), argv: request.command.clone(), cwd: request.cwd.clone(), env_policy, - shell_snapshot: None, + shell_snapshot: request.exec_server_shell_snapshot.clone(), env, tty, pipe_stdin: false, @@ -1060,12 +1068,13 @@ impl UnifiedExecProcessManager { network_proxy_launch: Option, environment_id: Option<&str>, exec_server_env_config: Option, + 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() { + let mut request = if environment.is_remote() || shell_snapshot.is_some() { attempt.env_for_exec_server(command, options) } else { attempt.env_for(command, options, network, environment_id) @@ -1077,6 +1086,7 @@ impl UnifiedExecProcessManager { .and_then(|_| network.and_then(NetworkProxy::remote_policy_decider)); request.exec_server_network_proxy = network_proxy_launch; request.exec_server_env_config = exec_server_env_config; + request.exec_server_shell_snapshot = shell_snapshot; self.open_session_with_prepared_exec_env( process_id, &request, @@ -1111,7 +1121,7 @@ impl UnifiedExecProcessManager { ) -> Result { let inherited_fds = spawn_lifecycle.inherited_fds(); - if environment.is_remote() { + 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(), @@ -1237,6 +1247,7 @@ impl UnifiedExecProcessManager { policy: exec_env_policy_from_shell_policy(shell_environment_policy), local_policy_env, }; + 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 session_shell = context.session.user_shell(); @@ -1278,6 +1289,7 @@ impl UnifiedExecProcessManager { turn_environment: request.turn_environment.clone(), env, exec_server_env_config: Some(exec_server_env_config), + shell_snapshot, explicit_env_overrides, network: request.network.clone(), tty: request.tty, 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 5c096ce7e9..3cd899d1be 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -184,6 +184,7 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { ), ]), }), + exec_server_shell_snapshot: None, network: None, network_environment_id: None, expiration: crate::exec::ExecExpiration::DefaultTimeout, @@ -230,6 +231,18 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { ("CODEX_NETWORK_PROXY_ACTIVE".to_string(), "1".to_string(),), ]) ); + request.exec_server_shell_snapshot = Some(codex_exec_server::ShellSnapshotRequest { + scope_id: "attachment-1".to_string(), + shell: codex_exec_server::ShellInfo { + name: "bash".to_string(), + path: "/bin/bash".to_string(), + }, + }); + let mut snapshot_env = params.env; + snapshot_env.remove("PATH"); + assert_eq!(params_for_request(&request).env, snapshot_env); + request.exec_server_shell_snapshot = None; + request.exec_server_sandbox = Some( codex_exec_server::FileSystemSandboxContext::from_permission_profile(permission_profile), ); diff --git a/codex-rs/core/src/unified_exec/shell_snapshot.rs b/codex-rs/core/src/unified_exec/shell_snapshot.rs new file mode 100644 index 0000000000..ddd93ec8b5 --- /dev/null +++ b/codex-rs/core/src/unified_exec/shell_snapshot.rs @@ -0,0 +1,40 @@ +use codex_exec_server::ShellInfo; +use codex_exec_server::ShellSnapshotRequest; +use codex_features::Feature; +use codex_tools::UnifiedExecShellMode; +use codex_utils_path_uri::PathUri; + +use super::ExecCommandRequest; +use super::UnifiedExecContext; +use crate::shell::ShellType; + +pub(super) fn shell_snapshot_request( + request: &ExecCommandRequest, + cwd: &PathUri, + context: &UnifiedExecContext, +) -> Option { + 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) + || !matches!( + request.shell_type, + ShellType::Bash | ShellType::Zsh | ShellType::Sh + ) + || request.command.get(1).is_none_or(|flag| flag != "-lc") + { + return None; + } + + Some(ShellSnapshotRequest { + scope_id: format!( + "{}:{}", + context.session.thread_id(), + request.turn_environment.selection.environment_id + ), + shell: ShellInfo { + name: request.shell_type.name().to_string(), + path: request.command.first()?.clone(), + }, + }) +} diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index d24e15377b..411e0df613 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -1,6 +1,6 @@ use anyhow::Result; use codex_core::TurnInputRequest; -#[cfg(target_os = "macos")] +#[cfg(unix)] use codex_core::shell::get_shell_by_model_provided_path; use codex_features::Feature; use codex_protocol::config_types::CollaborationMode; @@ -20,6 +20,8 @@ use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; +#[cfg(unix)] +use core_test_support::skip_if_remote; use core_test_support::test_codex::TestCodexHarness; use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; @@ -290,6 +292,112 @@ fn assert_posix_snapshot_sections(snapshot: &str) { ); } +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shell_snapshot_v2_filters_profile_secrets_without_creating_files() -> Result<()> { + skip_if_remote!(Ok(()), "profile fixture uses a host-local HOME directory"); + let profile_home = tempfile::tempdir()?; + fs::write( + profile_home.path().join(".bashrc"), + "printf x >> \"$HOME/captures\"\nexport PATH=\"$HOME/profile-bin:$PATH\"\nexport PROFILE_ALLOWED=profile\nexport PROFILE_SECRET=secret\nprofile_helper() { printf helper; }\n", + ) + .await?; + fs::write( + profile_home.path().join(".bash_profile"), + "export PROFILE_SECRET=secret\n", + ) + .await?; + let configured_home = profile_home.path().to_string_lossy().into_owned(); + let shell = get_shell_by_model_provided_path(&PathBuf::from("/bin/sh")); + let builder = test_codex() + .with_user_shell(shell) + .with_config(move |config| { + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should enable unified exec"); + config + .features + .enable(Feature::ShellSnapshotV2) + .expect("test config should enable in-memory snapshots"); + config + .permissions + .shell_environment_policy + .ignore_default_excludes = false; + config.permissions.shell_environment_policy.r#set = HashMap::from([ + ("HOME".to_string(), configured_home), + ("PROFILE_ALLOWED".to_string(), "policy".to_string()), + ]); + }); + let harness = TestCodexHarness::with_auto_env_builder(builder).await?; + + for attempt in 0..2 { + let end = run_tool_turn_on_harness( + &harness, + "run an in-memory shell snapshot", + &format!("shell-snapshot-v2-{attempt}"), + "exec_command", + json!({ + "cmd": "profile_helper; case \":$PATH:\" in *\":$HOME/profile-bin:\"*) printf '|path';; *) printf '|missing';; esac; printf '|%s|%s' \"$PROFILE_ALLOWED\" \"${PROFILE_SECRET-missing}\"", + "shell": "/bin/bash", + "yield_time_ms": 1_000, + }), + ) + .await?; + + assert_eq!(end.exit_code, 0); + assert_eq!( + normalize_newlines(&end.stdout).trim(), + "helper|path|policy|missing" + ); + } + + assert_eq!( + fs::read_to_string(profile_home.path().join("captures")).await?, + "x" + ); + assert!(!harness.test().home.path().join("shell_snapshots").exists()); + Ok(()) +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shell_snapshot_v2_preserves_legacy_snapshots_for_user_shell() -> Result<()> { + skip_if_remote!(Ok(()), "legacy shell snapshots require a local environment"); + let builder = test_codex().with_config(|config| { + config + .features + .disable(Feature::ShellTool) + .expect("test config should disable model shell tools"); + config + .features + .enable(Feature::ShellSnapshot) + .expect("test config should enable legacy snapshots"); + config + .features + .enable(Feature::ShellSnapshotV2) + .expect("test config should enable in-memory snapshots"); + }); + let harness = TestCodexHarness::with_auto_env_builder(builder).await?; + let snapshot_path = wait_for_snapshot(harness.test().home.path()).await?; + assert_posix_snapshot_sections(&fs::read_to_string(snapshot_path).await?); + let codex = &harness.test().codex; + codex + .submit(Op::RunUserShellCommand { + command: "printf legacy".to_string(), + }) + .await?; + let end = wait_for_event_match(codex, |event| match event { + EventMsg::ExecCommandEnd(event) => Some(event.clone()), + _ => None, + }) + .await; + + assert_eq!(end.exit_code, 0); + assert_eq!(normalize_newlines(&end.stdout).trim(), "legacy"); + Ok(()) +} + #[cfg_attr(not(target_os = "linux"), ignore)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn linux_unified_exec_uses_shell_snapshot() -> Result<()> { diff --git a/codex-rs/exec-server/src/shell_snapshot.rs b/codex-rs/exec-server/src/shell_snapshot.rs index 495b3e8213..5967e94be5 100644 --- a/codex-rs/exec-server/src/shell_snapshot.rs +++ b/codex-rs/exec-server/src/shell_snapshot.rs @@ -172,9 +172,17 @@ impl ShellSnapshotCache { .collect::(); let state_variables = state_variables.join(" "); let shell_start = prepared.command.len() - params.argv.len(); - prepared.command[shell_start + 1] = "-c".to_string(); + // Automatic startup files run before the restoration script and could + // reintroduce environment variables that the snapshot already filtered. + let (shell_flag, startup) = match shell_type { + ShellType::Bash => ("-pc", "set +o privileged\n"), + ShellType::Zsh => ("-fc", "setopt RCS\n"), + ShellType::Sh => ("-c", ""), + ShellType::PowerShell | ShellType::Cmd => unreachable!(), + }; + prepared.command[shell_start + 1] = shell_flag.to_string(); prepared.command[shell_start + 2] = format!( - "if ! eval \"unset {state_variables}\n{state_expansion}\" >/dev/null; then printf 'failed to restore shell snapshot\\n' >&2; fi\n{}", + "{startup}if ! eval \"unset {state_variables}\n{state_expansion}\" >/dev/null; then printf 'failed to restore shell snapshot\\n' >&2; fi\n{}", params.argv[2] ); diff --git a/codex-rs/exec-server/tests/exec_process.rs b/codex-rs/exec-server/tests/exec_process.rs index 32a9f527e8..5b107dd245 100644 --- a/codex-rs/exec-server/tests/exec_process.rs +++ b/codex-rs/exec-server/tests/exec_process.rs @@ -103,15 +103,25 @@ async fn create_process_context(use_remote: bool) -> Result { } #[cfg(unix)] -#[test_case(false, false, false, "bash"; "local_pipe")] -#[test_case(false, true, false, "bash"; "local_tty")] -#[test_case(true, false, false, "bash"; "remote_pipe")] -#[test_case(true, true, false, "bash"; "remote_tty")] -#[test_case(true, false, true, "bash"; "remote_sandbox")] -#[test_case(false, false, false, "sh"; "local_sh_pipe")] +#[test_case(false, false, false, false, "bash"; "local_pipe")] +#[test_case(false, true, false, false, "bash"; "local_tty")] +#[test_case(true, false, false, false, "bash"; "remote_pipe")] +#[test_case(true, true, false, false, "bash"; "remote_tty")] +#[test_case(true, false, true, false, "bash"; "remote_sandbox")] +#[test_case(false, false, false, false, "sh"; "local_sh_pipe")] +#[test_case(false, false, false, true, "bash"; "local_bash_env")] +#[test_case(true, false, false, true, "bash"; "remote_bash_env")] #[cfg_attr( target_os = "macos", - test_case(false, false, false, "zsh"; "local_zsh_pipe") + test_case(false, false, false, false, "zsh"; "local_zsh_pipe") +)] +#[cfg_attr( + target_os = "macos", + test_case(false, false, false, true, "zsh"; "local_zshenv") +)] +#[cfg_attr( + target_os = "macos", + test_case(true, false, false, true, "zsh"; "remote_zshenv") )] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] // Serialize tests that launch a real exec-server process through the full CLI. @@ -120,6 +130,7 @@ async fn shell_snapshot_v2_filters_profile_exports_and_stays_in_memory( use_remote: bool, tty: bool, use_sandbox: bool, + automatic_startup: bool, shell_name: &str, ) -> Result<()> { if use_sandbox @@ -133,8 +144,10 @@ async fn shell_snapshot_v2_filters_profile_exports_and_stays_in_memory( let home = TempDir::new()?; let cwd = PathUri::from_host_native_path(home.path())?; let (shell_path, profile_name) = match shell_name { + "bash" if automatic_startup => ("/bin/bash", ".bash-env"), "bash" => ("/bin/bash", ".bashrc"), "sh" => ("/bin/sh", ".snapshot-env"), + "zsh" if automatic_startup => ("/bin/zsh", ".zshenv"), "zsh" => ("/bin/zsh", ".zshrc"), name => anyhow::bail!("unsupported test shell {name}"), }; @@ -160,6 +173,12 @@ async fn shell_snapshot_v2_filters_profile_exports_and_stays_in_memory( "printf x >> \"$HOME/captures\"\nexport PATH=\"$HOME/profile-bin:/usr/bin:/bin\"\nexport PROFILE_ALLOWED=profile\nexport PROFILE_SECRET=secret\nexport PROFILE_DENIED=denied\nprofile_helper() {{ printf helper; }}\n{shadowed_builtins}{padding}" ), )?; + if shell_name == "zsh" && automatic_startup { + std::fs::write( + home.path().join(".zshrc"), + "export PATH=\"$HOME/profile-bin:/usr/bin:/bin\"\n", + )?; + } let mut configured_environment = HashMap::from([( "HOME".to_string(), home.path().to_string_lossy().into_owned(), @@ -170,12 +189,19 @@ async fn shell_snapshot_v2_filters_profile_exports_and_stays_in_memory( profile_path.to_string_lossy().into_owned(), ); } + if shell_name == "bash" && automatic_startup { + configured_environment.insert( + "BASH_ENV".to_string(), + profile_path.to_string_lossy().into_owned(), + ); + } let policy = ExecEnvPolicy { inherit: ShellEnvironmentPolicyInherit::All, ignore_default_excludes: false, exclude: vec!["PROFILE_DENIED".to_string()], r#set: configured_environment, include_only: vec![ + "BASH_ENV".to_string(), "ENV".to_string(), "HOME".to_string(), "PATH".to_string(), diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 7d0a68ad19..f4747379a2 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -145,6 +145,8 @@ pub enum Feature { UseLegacyLandlock, /// Experimental shell snapshotting. ShellSnapshot, + /// Keep policy-filtered shell snapshots entirely in executor memory. + ShellSnapshotV2, /// Allow turns to start while selected executors are still starting. DeferredExecutor, /// Use the current working directory for turn diff display paths. @@ -871,6 +873,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::ShellSnapshotV2, + key: "shell_snapshot_v2", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::DeferredExecutor, key: "deferred_executor",