From 4d119f856ebe30b4304d139ee501ebe0458924a7 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 5 Dec 2025 14:56:31 +0000 Subject: [PATCH 01/19] test tests --- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/shell_snapshot.rs | 242 ++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 codex-rs/core/src/shell_snapshot.rs diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 721c6bb43c..f84c7dbf33 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -73,6 +73,7 @@ mod rollout; pub(crate) mod safety; pub mod seatbelt; pub mod shell; +pub mod shell_snapshot; pub mod skills; pub mod spawn; pub mod terminal; diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs new file mode 100644 index 0000000000..904b8a1926 --- /dev/null +++ b/codex-rs/core/src/shell_snapshot.rs @@ -0,0 +1,242 @@ +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use anyhow::bail; +use tokio::fs; +use tokio::process::Command; +use tokio::time::timeout; + +use crate::shell::Shell; +use crate::shell::ShellType; +use crate::shell::get_shell; + +pub async fn write_shell_snapshot(shell_type: ShellType, output_path: &Path) -> Result { + let shell = get_shell(shell_type.clone(), None) + .with_context(|| format!("No available shell for {shell_type:?}"))?; + + let snapshot = capture_snapshot(&shell).await?; + + if let Some(parent) = output_path.parent() { + let parent_display = parent.display(); + fs::create_dir_all(parent) + .await + .with_context(|| format!("Failed to create snapshot parent {parent_display}"))?; + } + + let snapshot_path = output_path.display(); + fs::write(output_path, snapshot) + .await + .with_context(|| format!("Failed to write snapshot to {snapshot_path}"))?; + + Ok(output_path.to_path_buf()) +} + +async fn capture_snapshot(shell: &Shell) -> Result { + let shell_type = shell.shell_type.clone(); + match shell_type { + ShellType::Zsh => run_shell_script(shell, zsh_snapshot_script()).await, + ShellType::Bash => run_shell_script(shell, bash_snapshot_script()).await, + ShellType::Sh => run_shell_script(shell, sh_snapshot_script()).await, + ShellType::PowerShell => run_shell_script(shell, powershell_snapshot_script()).await, + ShellType::Cmd => bail!("Shell snapshotting is not yet supported for {shell_type:?}"), + } +} + +async fn run_shell_script(shell: &Shell, script: &str) -> Result { + let args = shell.derive_exec_args(script, true); + let shell_name = shell.name(); + let output = timeout( + Duration::from_secs(10), + Command::new(&args[0]).args(&args[1..]).output(), + ) + .await + .map_err(|_| anyhow!("Snapshot command timed out for {shell_name}"))? + .with_context(|| format!("Failed to execute {shell_name}"))?; + + if !output.status.success() { + let status = output.status; + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("Snapshot command exited with status {status}: {stderr}"); + } + + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +fn zsh_snapshot_script() -> &'static str { + r#"print '# Snapshot file' +print '# Unset all aliases to avoid conflicts with functions' +print 'unalias -a 2>/dev/null || true' +print '# Functions' +functions +print '' +setopt_count=$(setopt | wc -l | tr -d ' ') +print "setopts $setopt_count" +setopt | sed 's/^/setopt /' +print '' +alias_count=$(alias -L | wc -l | tr -d ' ') +print "aliases $alias_count" +alias -L +print '' +export_count=$(export -p | wc -l | tr -d ' ') +print "exports $export_count" +export -p +"# +} + +fn bash_snapshot_script() -> &'static str { + r#"echo '# Snapshot file' +echo '# Unset all aliases to avoid conflicts with functions' +unalias -a 2>/dev/null || true +echo '# Functions' +declare -f +echo '' +bash_opts=$(set -o | awk '$2=="on"{print $1}') +bash_opt_count=$(printf '%s\n' "$bash_opts" | sed '/^$/d' | wc -l | tr -d ' ') +echo "setopts $bash_opt_count" +if [ -n "$bash_opts" ]; then + printf 'set -o %s\n' $bash_opts +fi +echo '' +alias_count=$(alias -p | wc -l | tr -d ' ') +echo "aliases $alias_count" +alias -p +echo '' +export_count=$(export -p | wc -l | tr -d ' ') +echo "exports $export_count" +export -p +"# +} + +fn sh_snapshot_script() -> &'static str { + r#"echo '# Snapshot file' +echo '# Unset all aliases to avoid conflicts with functions' +unalias -a 2>/dev/null || true +echo '# Functions' +if command -v typeset >/dev/null 2>&1; then + typeset -f +elif command -v declare >/dev/null 2>&1; then + declare -f +fi +echo '' +if set -o >/dev/null 2>&1; then + sh_opts=$(set -o | awk '$2=="on"{print $1}') + sh_opt_count=$(printf '%s\n' "$sh_opts" | sed '/^$/d' | wc -l | tr -d ' ') + echo "setopts $sh_opt_count" + if [ -n "$sh_opts" ]; then + printf 'set -o %s\n' $sh_opts + fi +else + echo 'setopts 0' +fi +echo '' +if alias >/dev/null 2>&1; then + alias_count=$(alias | wc -l | tr -d ' ') + echo "aliases $alias_count" + alias + echo '' +else + echo 'aliases 0' +fi +if export -p >/dev/null 2>&1; then + export_count=$(export -p | wc -l | tr -d ' ') + echo "exports $export_count" + export -p +else + export_count=$(env | wc -l | tr -d ' ') + echo "exports $export_count" + env | sort | while IFS='=' read -r key value; do + escaped=$(printf "%s" "$value" | sed "s/'/'\"'\"'/g") + printf "export %s='%s'\n" "$key" "$escaped" + done +fi +"# +} + +fn powershell_snapshot_script() -> &'static str { + r#"$ErrorActionPreference = 'Stop' +Write-Output '# Snapshot file' +Write-Output '# Unset all aliases to avoid conflicts with functions' +Write-Output 'Remove-Item Alias:* -ErrorAction SilentlyContinue' +Write-Output '# Functions' +Get-ChildItem Function: | ForEach-Object { + "function {0} {{`n{1}`n}}" -f $_.Name, $_.Definition +} +Write-Output '' +$aliases = Get-Alias +Write-Output ("aliases " + $aliases.Count) +$aliases | ForEach-Object { + "Set-Alias -Name {0} -Value {1}" -f $_.Name, $_.Definition +} +Write-Output '' +$envVars = Get-ChildItem Env: +Write-Output ("exports " + $envVars.Count) +$envVars | ForEach-Object { + $escaped = $_.Value -replace "'", "''" + "`$env:{0}='{1}'" -f $_.Name, $escaped +} +"# +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + async fn get_snapshot(shell_type: ShellType) -> Result { + let dir = tempdir()?; + let path = dir.path().join("snapshot.sh"); + write_shell_snapshot(shell_type, &path).await?; + let content = fs::read_to_string(&path).await?; + Ok(content) + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn macos_zsh_snapshot_includes_sections() -> Result<()> { + let snapshot = get_snapshot(ShellType::Zsh).await?; + assert!(snapshot.contains("# Snapshot file")); + assert!(snapshot.contains("aliases ")); + assert!(snapshot.contains("exports ")); + assert!(snapshot.contains("export CARGO")); + assert!(snapshot.contains("setopts ")); + Ok(()) + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn linux_bash_snapshot_includes_sections() -> Result<()> { + let snapshot = get_snapshot(ShellType::Bash).await?; + assert!(snapshot.contains("# Snapshot file")); + assert!(snapshot.contains("aliases ")); + assert!(snapshot.contains("exports ")); + assert!(snapshot.contains("export CARGO")); + assert!(snapshot.contains("setopts ")); + Ok(()) + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn linux_sh_snapshot_includes_sections() -> Result<()> { + let snapshot = get_snapshot(ShellType::Sh).await?; + assert!(snapshot.contains("# Snapshot file")); + assert!(snapshot.contains("aliases ")); + assert!(snapshot.contains("exports ")); + assert!(snapshot.contains("export CARGO")); + assert!(snapshot.contains("setopts ")); + Ok(()) + } + + #[cfg(target_os = "windows")] + #[tokio::test] + async fn windows_powershell_snapshot_includes_sections() -> Result<()> { + let snapshot = get_snapshot(ShellType::PowerShell).await?; + assert!(snapshot.contains("# Snapshot file")); + assert!(snapshot.contains("aliases ")); + assert!(snapshot.contains("exports ")); + Ok(()) + } +} From 25e0e4983d3b63db51d26893ab168c333dd8d939 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 5 Dec 2025 15:56:14 +0000 Subject: [PATCH 02/19] Integrate it --- codex-rs/core/src/codex.rs | 36 +++++- codex-rs/core/src/shell_snapshot.rs | 115 ++++++++++++++---- codex-rs/core/src/state/session.rs | 8 +- .../core/src/tools/handlers/unified_exec.rs | 6 +- codex-rs/core/src/tools/runtimes/shell.rs | 6 +- 5 files changed, 141 insertions(+), 30 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d35f95e423..62ffdc1012 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -109,6 +109,8 @@ use crate::rollout::RolloutRecorder; use crate::rollout::RolloutRecorderParams; use crate::rollout::map_session_init_error; use crate::shell; +use crate::shell_snapshot::ShellSnapshot; +use crate::shell_snapshot::wrap_command_with_snapshot; use crate::state::ActiveTurn; use crate::state::SessionServices; use crate::state::SessionState; @@ -571,7 +573,8 @@ impl Session { ); // Create the mutable state for the Session. - let state = SessionState::new(session_configuration.clone()); + let shell_snapshot = ShellSnapshot::try_new(&config.codex_home, &default_shell).await; + let state = SessionState::new(session_configuration.clone(), shell_snapshot); let services = SessionServices { mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())), @@ -1442,6 +1445,29 @@ impl Session { &self.services.user_shell } + pub(crate) async fn command_with_shell_snapshot( + &self, + command: &[String], + use_login_shell: bool, + ) -> Vec { + if use_login_shell { + return command.to_vec(); + } + + let snapshot_path = { + let state = self.state.lock().await; + state + .shell_snapshot + .as_ref() + .map(|snapshot| snapshot.path.clone()) + }; + + snapshot_path.map_or_else( + || command.to_vec(), + |path| wrap_command_with_snapshot(self.user_shell(), &path, command, use_login_shell), + ) + } + fn show_raw_agent_reasoning(&self) -> bool { self.services.show_raw_agent_reasoning } @@ -2585,7 +2611,7 @@ mod tests { session_source: SessionSource::Exec, }; - let mut state = SessionState::new(session_configuration); + let mut state = SessionState::new(session_configuration, None); let initial = RateLimitSnapshot { primary: Some(RateLimitWindow { used_percent: 10.0, @@ -2656,7 +2682,7 @@ mod tests { session_source: SessionSource::Exec, }; - let mut state = SessionState::new(session_configuration); + let mut state = SessionState::new(session_configuration, None); let initial = RateLimitSnapshot { primary: Some(RateLimitWindow { used_percent: 15.0, @@ -2863,7 +2889,7 @@ mod tests { session_source: SessionSource::Exec, }; - let state = SessionState::new(session_configuration.clone()); + let state = SessionState::new(session_configuration.clone(), None); let services = SessionServices { mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())), @@ -2942,7 +2968,7 @@ mod tests { session_source: SessionSource::Exec, }; - let state = SessionState::new(session_configuration.clone()); + let state = SessionState::new(session_configuration.clone(), None); let services = SessionServices { mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())), diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index 904b8a1926..6f2132fc08 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -9,11 +9,82 @@ use anyhow::bail; use tokio::fs; use tokio::process::Command; use tokio::time::timeout; +use tracing::debug; +use tracing::warn; +use uuid::Uuid; use crate::shell::Shell; use crate::shell::ShellType; use crate::shell::get_shell; +pub struct ShellSnapshot { + pub path: PathBuf, +} + +impl ShellSnapshot { + pub async fn try_new(codex_home: &Path, shell: &Shell) -> Option { + let extension = match shell.shell_type { + ShellType::PowerShell => "ps1", + _ => "sh", + }; + let path = + codex_home + .join("shell_snapshots") + .join(format!("{}.{}", Uuid::new_v4(), extension)); + match write_shell_snapshot(shell.shell_type.clone(), &path).await { + Ok(path) => Some(Self { path }), + Err(err) => { + warn!( + "Failed to create shell snapshot for {}: {err:?}", + shell.name() + ); + None + } + } + } +} + +impl Drop for ShellSnapshot { + fn drop(&mut self) { + if let Err(err) = std::fs::remove_file(&self.path) { + debug!( + "Failed to delete shell snapshot at {:?}: {err:?}", + self.path + ); + } + } +} + +pub fn wrap_command_with_snapshot( + shell: &Shell, + snapshot_path: &Path, + command: &[String], + use_login_shell: bool, +) -> Vec { + if command.is_empty() { + return command.to_vec(); + } + + match shell.shell_type { + ShellType::Zsh | ShellType::Bash | ShellType::Sh => { + let mut args = + shell.derive_exec_args(". \"$1\" && shift && exec \"$@\"", use_login_shell); + args.push("codex-shell-snapshot".to_string()); + args.push(snapshot_path.to_string_lossy().to_string()); + args.extend_from_slice(command); + args + } + ShellType::PowerShell => { + let mut args = + shell.derive_exec_args("param($snapshot) . $snapshot; & @args", use_login_shell); + args.push(snapshot_path.to_string_lossy().to_string()); + args.extend_from_slice(command); + args + } + ShellType::Cmd => command.to_vec(), + } +} + pub async fn write_shell_snapshot(shell_type: ShellType, output_path: &Path) -> Result { let shell = get_shell(shell_type.clone(), None) .with_context(|| format!("No available shell for {shell_type:?}"))?; @@ -67,28 +138,28 @@ async fn run_shell_script(shell: &Shell, script: &str) -> Result { } fn zsh_snapshot_script() -> &'static str { - r#"print '# Snapshot file' + r##"print '# Snapshot file' print '# Unset all aliases to avoid conflicts with functions' print 'unalias -a 2>/dev/null || true' print '# Functions' functions print '' setopt_count=$(setopt | wc -l | tr -d ' ') -print "setopts $setopt_count" +print "# setopts $setopt_count" setopt | sed 's/^/setopt /' print '' alias_count=$(alias -L | wc -l | tr -d ' ') -print "aliases $alias_count" +print "# aliases $alias_count" alias -L print '' export_count=$(export -p | wc -l | tr -d ' ') -print "exports $export_count" +print "# exports $export_count" export -p -"# +"## } fn bash_snapshot_script() -> &'static str { - r#"echo '# Snapshot file' + r##"echo '# Snapshot file' echo '# Unset all aliases to avoid conflicts with functions' unalias -a 2>/dev/null || true echo '# Functions' @@ -96,23 +167,23 @@ declare -f echo '' bash_opts=$(set -o | awk '$2=="on"{print $1}') bash_opt_count=$(printf '%s\n' "$bash_opts" | sed '/^$/d' | wc -l | tr -d ' ') -echo "setopts $bash_opt_count" +echo "# setopts $bash_opt_count" if [ -n "$bash_opts" ]; then printf 'set -o %s\n' $bash_opts fi echo '' alias_count=$(alias -p | wc -l | tr -d ' ') -echo "aliases $alias_count" +echo "# aliases $alias_count" alias -p echo '' export_count=$(export -p | wc -l | tr -d ' ') -echo "exports $export_count" +echo "# exports $export_count" export -p -"# +"## } fn sh_snapshot_script() -> &'static str { - r#"echo '# Snapshot file' + r##"echo '# Snapshot file' echo '# Unset all aliases to avoid conflicts with functions' unalias -a 2>/dev/null || true echo '# Functions' @@ -125,39 +196,39 @@ echo '' if set -o >/dev/null 2>&1; then sh_opts=$(set -o | awk '$2=="on"{print $1}') sh_opt_count=$(printf '%s\n' "$sh_opts" | sed '/^$/d' | wc -l | tr -d ' ') - echo "setopts $sh_opt_count" + echo "# setopts $sh_opt_count" if [ -n "$sh_opts" ]; then printf 'set -o %s\n' $sh_opts fi else - echo 'setopts 0' + echo '# setopts 0' fi echo '' if alias >/dev/null 2>&1; then alias_count=$(alias | wc -l | tr -d ' ') - echo "aliases $alias_count" + echo "# aliases $alias_count" alias echo '' else - echo 'aliases 0' + echo '# aliases 0' fi if export -p >/dev/null 2>&1; then export_count=$(export -p | wc -l | tr -d ' ') - echo "exports $export_count" + echo "# exports $export_count" export -p else export_count=$(env | wc -l | tr -d ' ') - echo "exports $export_count" + echo "# exports $export_count" env | sort | while IFS='=' read -r key value; do escaped=$(printf "%s" "$value" | sed "s/'/'\"'\"'/g") printf "export %s='%s'\n" "$key" "$escaped" done fi -"# +"## } fn powershell_snapshot_script() -> &'static str { - r#"$ErrorActionPreference = 'Stop' + r##"$ErrorActionPreference = 'Stop' Write-Output '# Snapshot file' Write-Output '# Unset all aliases to avoid conflicts with functions' Write-Output 'Remove-Item Alias:* -ErrorAction SilentlyContinue' @@ -167,18 +238,18 @@ Get-ChildItem Function: | ForEach-Object { } Write-Output '' $aliases = Get-Alias -Write-Output ("aliases " + $aliases.Count) +Write-Output ("# aliases " + $aliases.Count) $aliases | ForEach-Object { "Set-Alias -Name {0} -Value {1}" -f $_.Name, $_.Definition } Write-Output '' $envVars = Get-ChildItem Env: -Write-Output ("exports " + $envVars.Count) +Write-Output ("# exports " + $envVars.Count) $envVars | ForEach-Object { $escaped = $_.Value -replace "'", "''" "`$env:{0}='{1}'" -f $_.Name, $escaped } -"# +"## } #[cfg(test)] diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index c61d188373..0941c9470e 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -7,6 +7,7 @@ use crate::context_manager::ContextManager; use crate::protocol::RateLimitSnapshot; use crate::protocol::TokenUsage; use crate::protocol::TokenUsageInfo; +use crate::shell_snapshot::ShellSnapshot; use crate::truncate::TruncationPolicy; /// Persistent, session-scoped state previously stored directly on `Session`. @@ -14,16 +15,21 @@ pub(crate) struct SessionState { pub(crate) session_configuration: SessionConfiguration, pub(crate) history: ContextManager, pub(crate) latest_rate_limits: Option, + pub(crate) shell_snapshot: Option, } impl SessionState { /// Create a new session state mirroring previous `State::default()` semantics. - pub(crate) fn new(session_configuration: SessionConfiguration) -> Self { + pub(crate) fn new( + session_configuration: SessionConfiguration, + shell_snapshot: Option, + ) -> Self { let history = ContextManager::new(); Self { session_configuration, history, latest_rate_limits: None, + shell_snapshot, } } diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index f2500a413b..7c5fae729a 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -130,8 +130,9 @@ impl ToolHandler for UnifiedExecHandler { })?; let process_id = manager.allocate_process_id().await; - let command = get_command(&args); + let base_command = get_command(&args); let ExecCommandArgs { + login, workdir, yield_time_ms, max_output_tokens, @@ -139,6 +140,9 @@ impl ToolHandler for UnifiedExecHandler { justification, .. } = args; + let command = session + .command_with_shell_snapshot(&base_command, login) + .await; if with_escalated_permissions.unwrap_or(false) && !matches!( diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 2af095ee92..1c936430fc 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -151,8 +151,12 @@ impl ToolRuntime for ShellRuntime { attempt: &SandboxAttempt<'_>, ctx: &ToolCtx<'_>, ) -> Result { + let command = ctx + .session + .command_with_shell_snapshot(&req.command, false) + .await; let spec = build_command_spec( - &req.command, + &command, &req.cwd, &req.env, req.timeout_ms.into(), From 888391a22a4458e29f1b1963f8ad800dacb8ddba Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 5 Dec 2025 16:02:31 +0000 Subject: [PATCH 03/19] More tests --- codex-rs/core/src/shell_snapshot.rs | 69 +++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index 6f2132fc08..bd236f632c 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -255,6 +255,7 @@ $envVars | ForEach-Object { #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; use tempfile::tempdir; async fn get_snapshot(shell_type: ShellType) -> Result { @@ -265,6 +266,74 @@ mod tests { Ok(content) } + #[cfg(unix)] + #[test] + fn wrap_command_with_snapshot_wraps_bash_shell() { + let shell = Shell { + shell_type: ShellType::Bash, + shell_path: PathBuf::from("/bin/bash"), + }; + let snapshot_path = PathBuf::from("/tmp/snapshot.sh"); + let original_command = vec![ + "bash".to_string(), + "-lc".to_string(), + "echo hello".to_string(), + ]; + + let wrapped = wrap_command_with_snapshot( + &shell, + &snapshot_path, + &original_command, + false, + ); + + let mut expected = + shell.derive_exec_args(". \"$1\" && shift && exec \"$@\"", false); + expected.push("codex-shell-snapshot".to_string()); + expected.push(snapshot_path.to_string_lossy().to_string()); + expected.extend_from_slice(&original_command); + + assert_eq!(wrapped, expected); + } + + #[test] + fn wrap_command_with_snapshot_preserves_cmd_shell() { + let shell = Shell { + shell_type: ShellType::Cmd, + shell_path: PathBuf::from("cmd"), + }; + let snapshot_path = PathBuf::from("C:\\snapshot.cmd"); + let original_command = + vec!["cmd".to_string(), "/c".to_string(), "echo hello".to_string()]; + + let wrapped = + wrap_command_with_snapshot(&shell, &snapshot_path, &original_command, false); + + assert_eq!(wrapped, original_command); + } + + #[cfg(unix)] + #[tokio::test] + async fn try_new_creates_and_deletes_snapshot_file() -> Result<()> { + let dir = tempdir()?; + let shell = Shell { + shell_type: ShellType::Bash, + shell_path: PathBuf::from("/bin/bash"), + }; + + let snapshot = ShellSnapshot::try_new(dir.path(), &shell) + .await + .expect("snapshot should be created"); + let path = snapshot.path.clone(); + assert!(path.exists()); + + drop(snapshot); + + assert!(!path.exists()); + + Ok(()) + } + #[cfg(target_os = "macos")] #[tokio::test] async fn macos_zsh_snapshot_includes_sections() -> Result<()> { From 08dbdd01e2737f730b729207bb674bb3e5c8d964 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 5 Dec 2025 16:19:51 +0000 Subject: [PATCH 04/19] Default to false --- codex-rs/core/src/tools/handlers/unified_exec.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 7c5fae729a..0394cbc4a3 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -67,7 +67,7 @@ fn default_write_stdin_yield_time_ms() -> u64 { } fn default_login() -> bool { - true + false } #[async_trait] From 356452fd73e09b72900a6b2abd4ac7a6fef74971 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 5 Dec 2025 16:21:19 +0000 Subject: [PATCH 05/19] fmt --- codex-rs/core/src/shell_snapshot.rs | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index bd236f632c..88864aa9ce 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -280,15 +280,9 @@ mod tests { "echo hello".to_string(), ]; - let wrapped = wrap_command_with_snapshot( - &shell, - &snapshot_path, - &original_command, - false, - ); - - let mut expected = - shell.derive_exec_args(". \"$1\" && shift && exec \"$@\"", false); + let wrapped = wrap_command_with_snapshot(&shell, &snapshot_path, &original_command, false); + + let mut expected = shell.derive_exec_args(". \"$1\" && shift && exec \"$@\"", false); expected.push("codex-shell-snapshot".to_string()); expected.push(snapshot_path.to_string_lossy().to_string()); expected.extend_from_slice(&original_command); @@ -303,11 +297,13 @@ mod tests { shell_path: PathBuf::from("cmd"), }; let snapshot_path = PathBuf::from("C:\\snapshot.cmd"); - let original_command = - vec!["cmd".to_string(), "/c".to_string(), "echo hello".to_string()]; + let original_command = vec![ + "cmd".to_string(), + "/c".to_string(), + "echo hello".to_string(), + ]; - let wrapped = - wrap_command_with_snapshot(&shell, &snapshot_path, &original_command, false); + let wrapped = wrap_command_with_snapshot(&shell, &snapshot_path, &original_command, false); assert_eq!(wrapped, original_command); } From 88851e8b13d340fc8a7d2dce5f75962a19a64da0 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 5 Dec 2025 16:57:06 +0000 Subject: [PATCH 06/19] More work --- codex-rs/core/src/codex.rs | 33 ++++++------- codex-rs/core/src/features.rs | 8 ++++ codex-rs/core/src/shell_snapshot.rs | 27 ++++++++--- .../core/src/tools/handlers/apply_patch.rs | 2 +- codex-rs/core/src/tools/handlers/shell.rs | 2 +- .../core/src/tools/handlers/unified_exec.rs | 47 +++++++++---------- codex-rs/core/src/tools/registry.rs | 4 +- codex-rs/core/src/tools/runtimes/shell.rs | 5 +- 8 files changed, 70 insertions(+), 58 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 62ffdc1012..4c975cedad 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1445,27 +1445,22 @@ impl Session { &self.services.user_shell } - pub(crate) async fn command_with_shell_snapshot( - &self, - command: &[String], - use_login_shell: bool, - ) -> Vec { - if use_login_shell { - return command.to_vec(); + /// Add the shell snapshot the command if the snapshot is available. + /// + /// Returns the new command and `true` if a shell snapshot has been + /// applied, `false` otherwise. + pub(crate) async fn shell_snapshot(&self) -> Option { + if !self.enabled(Feature::ShellSnapshot) { + return None; } - let snapshot_path = { - let state = self.state.lock().await; - state - .shell_snapshot - .as_ref() - .map(|snapshot| snapshot.path.clone()) - }; - - snapshot_path.map_or_else( - || command.to_vec(), - |path| wrap_command_with_snapshot(self.user_shell(), &path, command, use_login_shell), - ) + let state = self.state.lock().await; + state.shell_snapshot.clone() + // if let Some(path) = snapshot_path { + // (wrap_command_with_snapshot(self.user_shell(), &path, command), true) + // } else { + // (command.to_vec(), false) + // } } fn show_raw_agent_reasoning(&self) -> bool { diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 1d775360c4..0865e23c3f 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -58,6 +58,8 @@ pub enum Feature { ParallelToolCalls, /// Experimental skills injection (CLI flag-driven). Skills, + /// Experimental shell snapshotting. + ShellSnapshot, } impl Feature { @@ -345,4 +347,10 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Experimental, default_enabled: false, }, + FeatureSpec { + id: Feature::ShellSnapshot, + key: "shell_snapshot", + stage: Stage::Experimental, + default_enabled: false, + }, ]; diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index 88864aa9ce..44e3abec47 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -55,11 +55,22 @@ impl Drop for ShellSnapshot { } } +/// Wraps an existing shell command so that it is executed after applying a +/// previously captured shell snapshot. +/// +/// The snapshot script at `snapshot_path` replays functions, aliases, and +/// environment variables from an earlier shell session. This helper builds a +/// new command line that: +/// 1. Starts the user's shell in non-login mode, +/// 2. Sources or runs the snapshot script, and then +/// 3. Executes the original `command` with its arguments. +/// +/// The wrapper shell always runs in non-login mode; callers control login +/// behavior for the final command itself when they construct `command`. pub fn wrap_command_with_snapshot( shell: &Shell, snapshot_path: &Path, command: &[String], - use_login_shell: bool, ) -> Vec { if command.is_empty() { return command.to_vec(); @@ -67,16 +78,18 @@ pub fn wrap_command_with_snapshot( match shell.shell_type { ShellType::Zsh | ShellType::Bash | ShellType::Sh => { - let mut args = - shell.derive_exec_args(". \"$1\" && shift && exec \"$@\"", use_login_shell); + // `. "$1" && shift && exec "$@"`: + // 1. source the snapshot script passed as the first argument, + // 2. drop that argument so "$@" becomes the original command and args, + // 3. exec the original command, replacing the wrapper shell. + let mut args = shell.derive_exec_args(". \"$1\" && shift && exec \"$@\"", false); args.push("codex-shell-snapshot".to_string()); args.push(snapshot_path.to_string_lossy().to_string()); args.extend_from_slice(command); args } ShellType::PowerShell => { - let mut args = - shell.derive_exec_args("param($snapshot) . $snapshot; & @args", use_login_shell); + let mut args = shell.derive_exec_args("param($snapshot) . $snapshot; & @args", false); args.push(snapshot_path.to_string_lossy().to_string()); args.extend_from_slice(command); args @@ -280,7 +293,7 @@ mod tests { "echo hello".to_string(), ]; - let wrapped = wrap_command_with_snapshot(&shell, &snapshot_path, &original_command, false); + let wrapped = wrap_command_with_snapshot(&shell, &snapshot_path, &original_command); let mut expected = shell.derive_exec_args(". \"$1\" && shift && exec \"$@\"", false); expected.push("codex-shell-snapshot".to_string()); @@ -303,7 +316,7 @@ mod tests { "echo hello".to_string(), ]; - let wrapped = wrap_command_with_snapshot(&shell, &snapshot_path, &original_command, false); + let wrapped = wrap_command_with_snapshot(&shell, &snapshot_path, &original_command); assert_eq!(wrapped, original_command); } diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 4a28619c76..5b8a04b388 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -46,7 +46,7 @@ impl ToolHandler for ApplyPatchHandler { ) } - fn is_mutating(&self, _invocation: &ToolInvocation) -> bool { + async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool { true } diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index cd05d126bf..357ef7f8ac 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -77,7 +77,7 @@ impl ToolHandler for ShellHandler { ) } - fn is_mutating(&self, invocation: &ToolInvocation) -> bool { + async fn is_mutating(&self, invocation: &ToolInvocation) -> bool { match &invocation.payload { ToolPayload::Function { arguments } => { serde_json::from_str::(arguments) diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 0394cbc4a3..5f3a660c97 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -8,6 +8,8 @@ use crate::protocol::ExecCommandSource; use crate::protocol::ExecOutputStream; use crate::shell::default_user_shell; use crate::shell::get_shell_by_model_provided_path; +use crate::shell_snapshot::ShellSnapshot; +use crate::shell_snapshot::wrap_command_with_snapshot; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; @@ -34,8 +36,8 @@ struct ExecCommandArgs { workdir: Option, #[serde(default)] shell: Option, - #[serde(default = "default_login")] - login: bool, + #[serde(default)] + login: Option, #[serde(default = "default_exec_yield_time_ms")] yield_time_ms: u64, #[serde(default)] @@ -66,10 +68,6 @@ fn default_write_stdin_yield_time_ms() -> u64 { 250 } -fn default_login() -> bool { - false -} - #[async_trait] impl ToolHandler for UnifiedExecHandler { fn kind(&self) -> ToolKind { @@ -83,7 +81,7 @@ impl ToolHandler for UnifiedExecHandler { ) } - fn is_mutating(&self, invocation: &ToolInvocation) -> bool { + async fn is_mutating(&self, invocation: &ToolInvocation) -> bool { let (ToolPayload::Function { arguments } | ToolPayload::UnifiedExec { arguments }) = &invocation.payload else { @@ -93,7 +91,7 @@ impl ToolHandler for UnifiedExecHandler { let Ok(params) = serde_json::from_str::(arguments) else { return true; }; - let command = get_command(¶ms); + let command = get_command(¶ms, invocation.session.shell_snapshot().await); !is_known_safe_command(&command) } @@ -123,16 +121,16 @@ impl ToolHandler for UnifiedExecHandler { let response = match tool_name.as_str() { "exec_command" => { - let args: ExecCommandArgs = serde_json::from_str(&arguments).map_err(|err| { - FunctionCallError::RespondToModel(format!( - "failed to parse exec_command arguments: {err:?}" - )) - })?; + let mut args: ExecCommandArgs = + serde_json::from_str(&arguments).map_err(|err| { + FunctionCallError::RespondToModel(format!( + "failed to parse exec_command arguments: {err:?}" + )) + })?; let process_id = manager.allocate_process_id().await; - let base_command = get_command(&args); + let command = get_command(&args, session.shell_snapshot().await); let ExecCommandArgs { - login, workdir, yield_time_ms, max_output_tokens, @@ -140,9 +138,6 @@ impl ToolHandler for UnifiedExecHandler { justification, .. } = args; - let command = session - .command_with_shell_snapshot(&base_command, login) - .await; if with_escalated_permissions.unwrap_or(false) && !matches!( @@ -259,14 +254,18 @@ impl ToolHandler for UnifiedExecHandler { } } -fn get_command(args: &ExecCommandArgs) -> Vec { +fn get_command(args: &ExecCommandArgs, shell_snapshot: Option) -> Vec { let shell = if let Some(shell_str) = &args.shell { get_shell_by_model_provided_path(&PathBuf::from(shell_str)) } else { default_user_shell() }; - shell.derive_exec_args(&args.cmd, args.login) + let command = shell.derive_exec_args(&args.cmd, args.login.unwrap_or(shell_snapshot.is_none())); + if let Some(snapshot) = shell_snapshot { + return wrap_command_with_snapshot(&shell, &snapshot.path, &command); + } + command } fn format_response(response: &UnifiedExecResponse) -> String { @@ -311,7 +310,7 @@ mod tests { assert!(args.shell.is_none()); - let command = get_command(&args); + let command = get_command(&args, None); assert_eq!(command.len(), 3); assert_eq!(command[2], "echo hello"); @@ -326,7 +325,7 @@ mod tests { assert_eq!(args.shell.as_deref(), Some("/bin/bash")); - let command = get_command(&args); + let command = get_command(&args, None); assert_eq!(command[2], "echo hello"); } @@ -340,7 +339,7 @@ mod tests { assert_eq!(args.shell.as_deref(), Some("powershell")); - let command = get_command(&args); + let command = get_command(&args, None); assert_eq!(command[2], "echo hello"); } @@ -354,7 +353,7 @@ mod tests { assert_eq!(args.shell.as_deref(), Some("cmd")); - let command = get_command(&args); + let command = get_command(&args, None); assert_eq!(command[2], "echo hello"); } diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index f35ff06315..9b33e84b76 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -30,7 +30,7 @@ pub trait ToolHandler: Send + Sync { ) } - fn is_mutating(&self, _invocation: &ToolInvocation) -> bool { + async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool { false } @@ -110,7 +110,7 @@ impl ToolRegistry { let output_cell = &output_cell; let invocation = invocation; async move { - if handler.is_mutating(&invocation) { + if handler.is_mutating(&invocation).await { tracing::trace!("waiting for tool gate"); invocation.turn.tool_call_gate.wait_ready().await; tracing::trace!("tool gate released"); diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 1c936430fc..b684a4f2a2 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -151,10 +151,7 @@ impl ToolRuntime for ShellRuntime { attempt: &SandboxAttempt<'_>, ctx: &ToolCtx<'_>, ) -> Result { - let command = ctx - .session - .command_with_shell_snapshot(&req.command, false) - .await; + let (command, _) = ctx.session.command_with_shell_snapshot(&req.command).await; let spec = build_command_spec( &command, &req.cwd, From dc95944b6ba8008af0fa980c9d6589fdcd67e911 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 5 Dec 2025 16:59:18 +0000 Subject: [PATCH 07/19] Fixes --- codex-rs/core/src/shell_snapshot.rs | 1 + codex-rs/core/src/tools/runtimes/shell.rs | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index 44e3abec47..8634c22b4e 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -17,6 +17,7 @@ use crate::shell::Shell; use crate::shell::ShellType; use crate::shell::get_shell; +#[derive(Clone, Debug)] pub struct ShellSnapshot { pub path: PathBuf, } diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index b684a4f2a2..2af095ee92 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -151,9 +151,8 @@ impl ToolRuntime for ShellRuntime { attempt: &SandboxAttempt<'_>, ctx: &ToolCtx<'_>, ) -> Result { - let (command, _) = ctx.session.command_with_shell_snapshot(&req.command).await; let spec = build_command_spec( - &command, + &req.command, &req.cwd, &req.env, req.timeout_ms.into(), From 04f69e52eacadcbc81b1e51be5d57bc6261a32a3 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 5 Dec 2025 17:00:58 +0000 Subject: [PATCH 08/19] clippy --- codex-rs/core/src/codex.rs | 1 - codex-rs/core/src/tools/handlers/unified_exec.rs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 4c975cedad..1d675d3658 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -110,7 +110,6 @@ use crate::rollout::RolloutRecorderParams; use crate::rollout::map_session_init_error; use crate::shell; use crate::shell_snapshot::ShellSnapshot; -use crate::shell_snapshot::wrap_command_with_snapshot; use crate::state::ActiveTurn; use crate::state::SessionServices; use crate::state::SessionState; diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 5f3a660c97..d435cc0e68 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -121,7 +121,7 @@ impl ToolHandler for UnifiedExecHandler { let response = match tool_name.as_str() { "exec_command" => { - let mut args: ExecCommandArgs = + let args: ExecCommandArgs = serde_json::from_str(&arguments).map_err(|err| { FunctionCallError::RespondToModel(format!( "failed to parse exec_command arguments: {err:?}" From 1267159ac53c4b37a74ca51607db488ce702f72d Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 5 Dec 2025 17:03:10 +0000 Subject: [PATCH 09/19] Fmt --- codex-rs/core/src/tools/handlers/unified_exec.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index d435cc0e68..5060b60a87 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -121,12 +121,11 @@ impl ToolHandler for UnifiedExecHandler { let response = match tool_name.as_str() { "exec_command" => { - let args: ExecCommandArgs = - serde_json::from_str(&arguments).map_err(|err| { - FunctionCallError::RespondToModel(format!( - "failed to parse exec_command arguments: {err:?}" - )) - })?; + let args: ExecCommandArgs = serde_json::from_str(&arguments).map_err(|err| { + FunctionCallError::RespondToModel(format!( + "failed to parse exec_command arguments: {err:?}" + )) + })?; let process_id = manager.allocate_process_id().await; let command = get_command(&args, session.shell_snapshot().await); From 9fb7c94f4f2e47e00687dee49685b16aad19d862 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 5 Dec 2025 17:58:39 +0000 Subject: [PATCH 10/19] Fix one test --- codex-rs/core/src/shell_snapshot.rs | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index 8634c22b4e..16cb32f235 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -272,6 +272,17 @@ mod tests { use pretty_assertions::assert_eq; use tempfile::tempdir; + fn assert_posix_snapshot_sections(snapshot: &str) { + assert!(snapshot.contains("# Snapshot file")); + assert!(snapshot.contains("aliases ")); + assert!(snapshot.contains("exports ")); + assert!( + snapshot.contains("PATH"), + "snapshot should capture a PATH export" + ); + assert!(snapshot.contains("setopts ")); + } + async fn get_snapshot(shell_type: ShellType) -> Result { let dir = tempdir()?; let path = dir.path().join("snapshot.sh"); @@ -348,11 +359,7 @@ mod tests { #[tokio::test] async fn macos_zsh_snapshot_includes_sections() -> Result<()> { let snapshot = get_snapshot(ShellType::Zsh).await?; - assert!(snapshot.contains("# Snapshot file")); - assert!(snapshot.contains("aliases ")); - assert!(snapshot.contains("exports ")); - assert!(snapshot.contains("export CARGO")); - assert!(snapshot.contains("setopts ")); + assert_posix_snapshot_sections(&snapshot); Ok(()) } @@ -360,11 +367,7 @@ mod tests { #[tokio::test] async fn linux_bash_snapshot_includes_sections() -> Result<()> { let snapshot = get_snapshot(ShellType::Bash).await?; - assert!(snapshot.contains("# Snapshot file")); - assert!(snapshot.contains("aliases ")); - assert!(snapshot.contains("exports ")); - assert!(snapshot.contains("export CARGO")); - assert!(snapshot.contains("setopts ")); + assert_posix_snapshot_sections(&snapshot); Ok(()) } @@ -372,11 +375,7 @@ mod tests { #[tokio::test] async fn linux_sh_snapshot_includes_sections() -> Result<()> { let snapshot = get_snapshot(ShellType::Sh).await?; - assert!(snapshot.contains("# Snapshot file")); - assert!(snapshot.contains("aliases ")); - assert!(snapshot.contains("exports ")); - assert!(snapshot.contains("export CARGO")); - assert!(snapshot.contains("setopts ")); + assert_posix_snapshot_sections(&snapshot); Ok(()) } From 05587ede409b6a828dae245466eff16e21503b2f Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 5 Dec 2025 18:09:30 +0000 Subject: [PATCH 11/19] Comments --- codex-rs/core/src/codex.rs | 7 +------ codex-rs/core/src/state/session.rs | 5 +++-- codex-rs/core/src/tools/handlers/unified_exec.rs | 4 ++-- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1d675d3658..4ded49d2b5 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1448,18 +1448,13 @@ impl Session { /// /// Returns the new command and `true` if a shell snapshot has been /// applied, `false` otherwise. - pub(crate) async fn shell_snapshot(&self) -> Option { + pub(crate) async fn shell_snapshot(&self) -> Option> { if !self.enabled(Feature::ShellSnapshot) { return None; } let state = self.state.lock().await; state.shell_snapshot.clone() - // if let Some(path) = snapshot_path { - // (wrap_command_with_snapshot(self.user_shell(), &path, command), true) - // } else { - // (command.to_vec(), false) - // } } fn show_raw_agent_reasoning(&self) -> bool { diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 0941c9470e..04e1eb3248 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -1,5 +1,6 @@ //! Session-wide mutable state. +use std::sync::Arc; use codex_protocol::models::ResponseItem; use crate::codex::SessionConfiguration; @@ -15,7 +16,7 @@ pub(crate) struct SessionState { pub(crate) session_configuration: SessionConfiguration, pub(crate) history: ContextManager, pub(crate) latest_rate_limits: Option, - pub(crate) shell_snapshot: Option, + pub(crate) shell_snapshot: Option>, } impl SessionState { @@ -29,7 +30,7 @@ impl SessionState { session_configuration, history, latest_rate_limits: None, - shell_snapshot, + shell_snapshot: shell_snapshot.map(Arc::new), } } diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 5060b60a87..1ea78fe8c6 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -1,5 +1,5 @@ use std::path::PathBuf; - +use std::sync::Arc; use crate::function_tool::FunctionCallError; use crate::is_safe_command::is_known_safe_command; use crate::protocol::EventMsg; @@ -253,7 +253,7 @@ impl ToolHandler for UnifiedExecHandler { } } -fn get_command(args: &ExecCommandArgs, shell_snapshot: Option) -> Vec { +fn get_command(args: &ExecCommandArgs, shell_snapshot: Option>) -> Vec { let shell = if let Some(shell_str) = &args.shell { get_shell_by_model_provided_path(&PathBuf::from(shell_str)) } else { From 7b9cbd9de8907870b21d5ae33a87a782f46c4cf5 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 5 Dec 2025 18:50:03 +0000 Subject: [PATCH 12/19] Process a bunch of comments --- codex-rs/core/src/codex.rs | 51 +++++++--------- codex-rs/core/src/environment_context.rs | 15 +++-- codex-rs/core/src/shell.rs | 41 +++++++++++++ codex-rs/core/src/shell_snapshot.rs | 61 ++++--------------- codex-rs/core/src/state/service.rs | 2 +- codex-rs/core/src/state/session.rs | 9 +-- codex-rs/core/src/tools/handlers/shell.rs | 3 + .../core/src/tools/handlers/unified_exec.rs | 42 ++++++------- 8 files changed, 109 insertions(+), 115 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 4ded49d2b5..4a7418fe49 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -511,7 +511,6 @@ impl Session { // - load history metadata let rollout_fut = RolloutRecorder::new(&config, rollout_params); - let default_shell = shell::default_user_shell(); let history_meta_fut = crate::message_history::history_metadata(&config); let auth_statuses_fut = compute_auth_statuses( config.mcp_servers.iter(), @@ -571,9 +570,15 @@ impl Session { config.active_profile.clone(), ); + let mut default_shell = shell::default_user_shell(); // Create the mutable state for the Session. - let shell_snapshot = ShellSnapshot::try_new(&config.codex_home, &default_shell).await; - let state = SessionState::new(session_configuration.clone(), shell_snapshot); + if config.features.enabled(Feature::ShellSnapshot) { + default_shell.shell_snapshot = + ShellSnapshot::try_new(&config.codex_home, &default_shell) + .await + .map(Arc::new); + } + let state = SessionState::new(session_configuration.clone()); let services = SessionServices { mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())), @@ -581,7 +586,7 @@ impl Session { unified_exec_manager: UnifiedExecSessionManager::default(), notifier: UserNotifier::new(config.notify.clone()), rollout: Mutex::new(Some(rollout_recorder)), - user_shell: default_shell, + user_shell: Arc::new(default_shell), show_raw_agent_reasoning: config.show_raw_agent_reasoning, auth_manager: Arc::clone(&auth_manager), otel_event_manager, @@ -792,14 +797,16 @@ impl Session { ) -> Option { let prev = previous?; - let prev_context = EnvironmentContext::from(prev.as_ref()); - let next_context = EnvironmentContext::from(next); + let shell = self.user_shell(); + let prev_context = EnvironmentContext::from_turn_context(prev.as_ref(), shell.as_ref()); + let next_context = EnvironmentContext::from_turn_context(next, shell.as_ref()); if prev_context.equals_except_shell(&next_context) { return None; } Some(ResponseItem::from(EnvironmentContext::diff( prev.as_ref(), next, + shell.as_ref(), ))) } @@ -1149,6 +1156,7 @@ impl Session { pub(crate) fn build_initial_context(&self, turn_context: &TurnContext) -> Vec { let mut items = Vec::::with_capacity(3); + let shell = self.user_shell(); if let Some(developer_instructions) = turn_context.developer_instructions.as_deref() { items.push(DeveloperInstructions::new(developer_instructions.to_string()).into()); } @@ -1165,7 +1173,7 @@ impl Session { Some(turn_context.cwd.clone()), Some(turn_context.approval_policy), Some(turn_context.sandbox_policy.clone()), - self.user_shell().clone(), + shell.as_ref().clone(), ))); items } @@ -1440,21 +1448,8 @@ impl Session { &self.services.notifier } - pub(crate) fn user_shell(&self) -> &shell::Shell { - &self.services.user_shell - } - - /// Add the shell snapshot the command if the snapshot is available. - /// - /// Returns the new command and `true` if a shell snapshot has been - /// applied, `false` otherwise. - pub(crate) async fn shell_snapshot(&self) -> Option> { - if !self.enabled(Feature::ShellSnapshot) { - return None; - } - - let state = self.state.lock().await; - state.shell_snapshot.clone() + pub(crate) fn user_shell(&self) -> Arc { + Arc::clone(&self.services.user_shell) } fn show_raw_agent_reasoning(&self) -> bool { @@ -2600,7 +2595,7 @@ mod tests { session_source: SessionSource::Exec, }; - let mut state = SessionState::new(session_configuration, None); + let mut state = SessionState::new(session_configuration); let initial = RateLimitSnapshot { primary: Some(RateLimitWindow { used_percent: 10.0, @@ -2671,7 +2666,7 @@ mod tests { session_source: SessionSource::Exec, }; - let mut state = SessionState::new(session_configuration, None); + let mut state = SessionState::new(session_configuration); let initial = RateLimitSnapshot { primary: Some(RateLimitWindow { used_percent: 15.0, @@ -2878,7 +2873,7 @@ mod tests { session_source: SessionSource::Exec, }; - let state = SessionState::new(session_configuration.clone(), None); + let state = SessionState::new(session_configuration.clone()); let services = SessionServices { mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())), @@ -2886,7 +2881,7 @@ mod tests { unified_exec_manager: UnifiedExecSessionManager::default(), notifier: UserNotifier::new(None), rollout: Mutex::new(None), - user_shell: default_user_shell(), + user_shell: Arc::new(default_user_shell()), show_raw_agent_reasoning: config.show_raw_agent_reasoning, auth_manager: auth_manager.clone(), otel_event_manager: otel_event_manager.clone(), @@ -2957,7 +2952,7 @@ mod tests { session_source: SessionSource::Exec, }; - let state = SessionState::new(session_configuration.clone(), None); + let state = SessionState::new(session_configuration.clone()); let services = SessionServices { mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())), @@ -2965,7 +2960,7 @@ mod tests { unified_exec_manager: UnifiedExecSessionManager::default(), notifier: UserNotifier::new(None), rollout: Mutex::new(None), - user_shell: default_user_shell(), + user_shell: Arc::new(default_user_shell()), show_raw_agent_reasoning: config.show_raw_agent_reasoning, auth_manager: Arc::clone(&auth_manager), otel_event_manager: otel_event_manager.clone(), diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index 56e7f6cadb..54756bda2d 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -6,7 +6,6 @@ use crate::codex::TurnContext; use crate::protocol::AskForApproval; use crate::protocol::SandboxPolicy; use crate::shell::Shell; -use crate::shell::default_user_shell; use codex_protocol::config_types::SandboxMode; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; @@ -95,7 +94,7 @@ impl EnvironmentContext { && self.writable_roots == *writable_roots } - pub fn diff(before: &TurnContext, after: &TurnContext) -> Self { + pub fn diff(before: &TurnContext, after: &TurnContext, shell: &Shell) -> Self { let cwd = if before.cwd != after.cwd { Some(after.cwd.clone()) } else { @@ -111,18 +110,15 @@ impl EnvironmentContext { } else { None }; - EnvironmentContext::new(cwd, approval_policy, sandbox_policy, default_user_shell()) + EnvironmentContext::new(cwd, approval_policy, sandbox_policy, shell.clone()) } -} -impl From<&TurnContext> for EnvironmentContext { - fn from(turn_context: &TurnContext) -> Self { + pub fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self { Self::new( Some(turn_context.cwd.clone()), Some(turn_context.approval_policy), Some(turn_context.sandbox_policy.clone()), - // Shell is not configurable from turn to turn - default_user_shell(), + shell.clone(), ) } } @@ -201,6 +197,7 @@ mod tests { Shell { shell_type: ShellType::Bash, shell_path: PathBuf::from("/bin/bash"), + shell_snapshot: None, } } @@ -338,6 +335,7 @@ mod tests { Shell { shell_type: ShellType::Bash, shell_path: "/bin/bash".into(), + shell_snapshot: None, }, ); let context2 = EnvironmentContext::new( @@ -347,6 +345,7 @@ mod tests { Shell { shell_type: ShellType::Zsh, shell_path: "/bin/zsh".into(), + shell_snapshot: None, }, ); diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index ac115facb6..de798a6e96 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -1,6 +1,9 @@ use serde::Deserialize; use serde::Serialize; use std::path::PathBuf; +use std::sync::Arc; + +use crate::shell_snapshot::ShellSnapshot; #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] pub enum ShellType { @@ -15,6 +18,8 @@ pub enum ShellType { pub struct Shell { pub(crate) shell_type: ShellType, pub(crate) shell_path: PathBuf, + #[serde(skip_serializing, skip_deserializing, default)] + pub(crate) shell_snapshot: Option>, } impl Shell { @@ -58,6 +63,34 @@ impl Shell { } } } + + pub(crate) fn wrap_command_with_snapshot(&self, command: &[String]) -> Vec { + let Some(snapshot) = &self.shell_snapshot else { + return command.to_vec(); + }; + + if command.is_empty() { + return command.to_vec(); + } + + match self.shell_type { + ShellType::Zsh | ShellType::Bash | ShellType::Sh => { + let mut args = self.derive_exec_args(". \"$1\" && shift && exec \"$@\"", false); + args.push("codex-shell-snapshot".to_string()); + args.push(snapshot.path.to_string_lossy().to_string()); + args.extend_from_slice(command); + args + } + ShellType::PowerShell => { + let mut args = + self.derive_exec_args("param($snapshot) . $snapshot; & @args", false); + args.push(snapshot.path.to_string_lossy().to_string()); + args.extend_from_slice(command); + args + } + ShellType::Cmd => command.to_vec(), + } + } } #[cfg(unix)] @@ -134,6 +167,7 @@ fn get_zsh_shell(path: Option<&PathBuf>) -> Option { shell_path.map(|shell_path| Shell { shell_type: ShellType::Zsh, shell_path, + shell_snapshot: None, }) } @@ -143,6 +177,7 @@ fn get_bash_shell(path: Option<&PathBuf>) -> Option { shell_path.map(|shell_path| Shell { shell_type: ShellType::Bash, shell_path, + shell_snapshot: None, }) } @@ -152,6 +187,7 @@ fn get_sh_shell(path: Option<&PathBuf>) -> Option { shell_path.map(|shell_path| Shell { shell_type: ShellType::Sh, shell_path, + shell_snapshot: None, }) } @@ -167,6 +203,7 @@ fn get_powershell_shell(path: Option<&PathBuf>) -> Option { shell_path.map(|shell_path| Shell { shell_type: ShellType::PowerShell, shell_path, + shell_snapshot: None, }) } @@ -176,6 +213,7 @@ fn get_cmd_shell(path: Option<&PathBuf>) -> Option { shell_path.map(|shell_path| Shell { shell_type: ShellType::Cmd, shell_path, + shell_snapshot: None, }) } @@ -184,11 +222,13 @@ fn ultimate_fallback_shell() -> Shell { Shell { shell_type: ShellType::Cmd, shell_path: PathBuf::from("cmd.exe"), + shell_snapshot: None, } } else { Shell { shell_type: ShellType::Sh, shell_path: PathBuf::from("/bin/sh"), + shell_snapshot: None, } } } @@ -423,6 +463,7 @@ mod tests { Shell { shell_type: ShellType::Zsh, shell_path: PathBuf::from(shell_path), + shell_snapshot: None, } ); } diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index 16cb32f235..a68b5d196a 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -17,7 +17,7 @@ use crate::shell::Shell; use crate::shell::ShellType; use crate::shell::get_shell; -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct ShellSnapshot { pub path: PathBuf, } @@ -56,49 +56,6 @@ impl Drop for ShellSnapshot { } } -/// Wraps an existing shell command so that it is executed after applying a -/// previously captured shell snapshot. -/// -/// The snapshot script at `snapshot_path` replays functions, aliases, and -/// environment variables from an earlier shell session. This helper builds a -/// new command line that: -/// 1. Starts the user's shell in non-login mode, -/// 2. Sources or runs the snapshot script, and then -/// 3. Executes the original `command` with its arguments. -/// -/// The wrapper shell always runs in non-login mode; callers control login -/// behavior for the final command itself when they construct `command`. -pub fn wrap_command_with_snapshot( - shell: &Shell, - snapshot_path: &Path, - command: &[String], -) -> Vec { - if command.is_empty() { - return command.to_vec(); - } - - match shell.shell_type { - ShellType::Zsh | ShellType::Bash | ShellType::Sh => { - // `. "$1" && shift && exec "$@"`: - // 1. source the snapshot script passed as the first argument, - // 2. drop that argument so "$@" becomes the original command and args, - // 3. exec the original command, replacing the wrapper shell. - let mut args = shell.derive_exec_args(". \"$1\" && shift && exec \"$@\"", false); - args.push("codex-shell-snapshot".to_string()); - args.push(snapshot_path.to_string_lossy().to_string()); - args.extend_from_slice(command); - args - } - ShellType::PowerShell => { - let mut args = shell.derive_exec_args("param($snapshot) . $snapshot; & @args", false); - args.push(snapshot_path.to_string_lossy().to_string()); - args.extend_from_slice(command); - args - } - ShellType::Cmd => command.to_vec(), - } -} - pub async fn write_shell_snapshot(shell_type: ShellType, output_path: &Path) -> Result { let shell = get_shell(shell_type.clone(), None) .with_context(|| format!("No available shell for {shell_type:?}"))?; @@ -270,6 +227,7 @@ $envVars | ForEach-Object { mod tests { use super::*; use pretty_assertions::assert_eq; + use std::sync::Arc; use tempfile::tempdir; fn assert_posix_snapshot_sections(snapshot: &str) { @@ -294,18 +252,21 @@ mod tests { #[cfg(unix)] #[test] fn wrap_command_with_snapshot_wraps_bash_shell() { + let snapshot_path = PathBuf::from("/tmp/snapshot.sh"); let shell = Shell { shell_type: ShellType::Bash, shell_path: PathBuf::from("/bin/bash"), + shell_snapshot: Some(Arc::new(ShellSnapshot { + path: snapshot_path.clone(), + })), }; - let snapshot_path = PathBuf::from("/tmp/snapshot.sh"); let original_command = vec![ "bash".to_string(), "-lc".to_string(), "echo hello".to_string(), ]; - let wrapped = wrap_command_with_snapshot(&shell, &snapshot_path, &original_command); + let wrapped = shell.wrap_command_with_snapshot(&original_command); let mut expected = shell.derive_exec_args(". \"$1\" && shift && exec \"$@\"", false); expected.push("codex-shell-snapshot".to_string()); @@ -317,18 +278,21 @@ mod tests { #[test] fn wrap_command_with_snapshot_preserves_cmd_shell() { + let snapshot_path = PathBuf::from("C:\\snapshot.cmd"); let shell = Shell { shell_type: ShellType::Cmd, shell_path: PathBuf::from("cmd"), + shell_snapshot: Some(Arc::new(ShellSnapshot { + path: snapshot_path.clone(), + })), }; - let snapshot_path = PathBuf::from("C:\\snapshot.cmd"); let original_command = vec![ "cmd".to_string(), "/c".to_string(), "echo hello".to_string(), ]; - let wrapped = wrap_command_with_snapshot(&shell, &snapshot_path, &original_command); + let wrapped = shell.wrap_command_with_snapshot(&original_command); assert_eq!(wrapped, original_command); } @@ -340,6 +304,7 @@ mod tests { let shell = Shell { shell_type: ShellType::Bash, shell_path: PathBuf::from("/bin/bash"), + shell_snapshot: None, }; let snapshot = ShellSnapshot::try_new(dir.path(), &shell) diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index a35720a9bf..7387bcedae 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -18,7 +18,7 @@ pub(crate) struct SessionServices { pub(crate) unified_exec_manager: UnifiedExecSessionManager, pub(crate) notifier: UserNotifier, pub(crate) rollout: Mutex>, - pub(crate) user_shell: crate::shell::Shell, + pub(crate) user_shell: Arc, pub(crate) show_raw_agent_reasoning: bool, pub(crate) auth_manager: Arc, pub(crate) models_manager: Arc, diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 04e1eb3248..c61d188373 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -1,6 +1,5 @@ //! Session-wide mutable state. -use std::sync::Arc; use codex_protocol::models::ResponseItem; use crate::codex::SessionConfiguration; @@ -8,7 +7,6 @@ use crate::context_manager::ContextManager; use crate::protocol::RateLimitSnapshot; use crate::protocol::TokenUsage; use crate::protocol::TokenUsageInfo; -use crate::shell_snapshot::ShellSnapshot; use crate::truncate::TruncationPolicy; /// Persistent, session-scoped state previously stored directly on `Session`. @@ -16,21 +14,16 @@ pub(crate) struct SessionState { pub(crate) session_configuration: SessionConfiguration, pub(crate) history: ContextManager, pub(crate) latest_rate_limits: Option, - pub(crate) shell_snapshot: Option>, } impl SessionState { /// Create a new session state mirroring previous `State::default()` semantics. - pub(crate) fn new( - session_configuration: SessionConfiguration, - shell_snapshot: Option, - ) -> Self { + pub(crate) fn new(session_configuration: SessionConfiguration) -> Self { let history = ContextManager::new(); Self { session_configuration, history, latest_rate_limits: None, - shell_snapshot: shell_snapshot.map(Arc::new), } } diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 357ef7f8ac..4d816af430 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -288,18 +288,21 @@ mod tests { let bash_shell = Shell { shell_type: ShellType::Bash, shell_path: PathBuf::from("/bin/bash"), + shell_snapshot: None, }; assert_safe(&bash_shell, "ls -la"); let zsh_shell = Shell { shell_type: ShellType::Zsh, shell_path: PathBuf::from("/bin/zsh"), + shell_snapshot: None, }; assert_safe(&zsh_shell, "ls -la"); let powershell = Shell { shell_type: ShellType::PowerShell, shell_path: PathBuf::from("pwsh.exe"), + shell_snapshot: None, }; assert_safe(&powershell, "ls -Name"); } diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 1ea78fe8c6..b225a030fe 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -1,15 +1,11 @@ -use std::path::PathBuf; -use std::sync::Arc; use crate::function_tool::FunctionCallError; use crate::is_safe_command::is_known_safe_command; use crate::protocol::EventMsg; use crate::protocol::ExecCommandOutputDeltaEvent; use crate::protocol::ExecCommandSource; use crate::protocol::ExecOutputStream; -use crate::shell::default_user_shell; +use crate::shell::Shell; use crate::shell::get_shell_by_model_provided_path; -use crate::shell_snapshot::ShellSnapshot; -use crate::shell_snapshot::wrap_command_with_snapshot; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; @@ -26,6 +22,8 @@ use crate::unified_exec::UnifiedExecSessionManager; use crate::unified_exec::WriteStdinRequest; use async_trait::async_trait; use serde::Deserialize; +use std::path::PathBuf; +use std::sync::Arc; pub struct UnifiedExecHandler; @@ -91,7 +89,7 @@ impl ToolHandler for UnifiedExecHandler { let Ok(params) = serde_json::from_str::(arguments) else { return true; }; - let command = get_command(¶ms, invocation.session.shell_snapshot().await); + let command = get_command(¶ms, invocation.session.user_shell()); !is_known_safe_command(&command) } @@ -128,7 +126,7 @@ impl ToolHandler for UnifiedExecHandler { })?; let process_id = manager.allocate_process_id().await; - let command = get_command(&args, session.shell_snapshot().await); + let command = get_command(&args, session.user_shell()); let ExecCommandArgs { workdir, yield_time_ms, @@ -253,18 +251,16 @@ impl ToolHandler for UnifiedExecHandler { } } -fn get_command(args: &ExecCommandArgs, shell_snapshot: Option>) -> Vec { - let shell = if let Some(shell_str) = &args.shell { - get_shell_by_model_provided_path(&PathBuf::from(shell_str)) - } else { - default_user_shell() - }; - - let command = shell.derive_exec_args(&args.cmd, args.login.unwrap_or(shell_snapshot.is_none())); - if let Some(snapshot) = shell_snapshot { - return wrap_command_with_snapshot(&shell, &snapshot.path, &command); +fn get_command(args: &ExecCommandArgs, session_shell: Arc) -> Vec { + if let Some(shell_str) = &args.shell { + let mut shell = get_shell_by_model_provided_path(&PathBuf::from(shell_str)); + shell.shell_snapshot = None; + return shell.derive_exec_args(&args.cmd, args.login.unwrap_or(true)) } - command + + let use_login_shell = args.login.unwrap_or(session_shell.shell_snapshot.is_none()); + let command = session_shell.derive_exec_args(&args.cmd, use_login_shell); + session_shell.wrap_command_with_snapshot(&command) } fn format_response(response: &UnifiedExecResponse) -> String { @@ -299,6 +295,8 @@ fn format_response(response: &UnifiedExecResponse) -> String { #[cfg(test)] mod tests { use super::*; + use crate::shell::default_user_shell; + use std::sync::Arc; #[test] fn test_get_command_uses_default_shell_when_unspecified() { @@ -309,7 +307,7 @@ mod tests { assert!(args.shell.is_none()); - let command = get_command(&args, None); + let command = get_command(&args, Arc::new(default_user_shell())); assert_eq!(command.len(), 3); assert_eq!(command[2], "echo hello"); @@ -324,7 +322,7 @@ mod tests { assert_eq!(args.shell.as_deref(), Some("/bin/bash")); - let command = get_command(&args, None); + let command = get_command(&args, Arc::new(default_user_shell())); assert_eq!(command[2], "echo hello"); } @@ -338,7 +336,7 @@ mod tests { assert_eq!(args.shell.as_deref(), Some("powershell")); - let command = get_command(&args, None); + let command = get_command(&args, Arc::new(default_user_shell())); assert_eq!(command[2], "echo hello"); } @@ -352,7 +350,7 @@ mod tests { assert_eq!(args.shell.as_deref(), Some("cmd")); - let command = get_command(&args, None); + let command = get_command(&args, Arc::new(default_user_shell())); assert_eq!(command[2], "echo hello"); } From c05ee25e8197ef0cc1b87f1f8129f3400f451710 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 5 Dec 2025 18:51:18 +0000 Subject: [PATCH 13/19] fmt --- codex-rs/core/src/tools/handlers/unified_exec.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index b225a030fe..7f0b1aa36b 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -255,7 +255,7 @@ fn get_command(args: &ExecCommandArgs, session_shell: Arc) -> Vec if let Some(shell_str) = &args.shell { let mut shell = get_shell_by_model_provided_path(&PathBuf::from(shell_str)); shell.shell_snapshot = None; - return shell.derive_exec_args(&args.cmd, args.login.unwrap_or(true)) + return shell.derive_exec_args(&args.cmd, args.login.unwrap_or(true)); } let use_login_shell = args.login.unwrap_or(session_shell.shell_snapshot.is_none()); From 083f2d22ae04af70997e9031bae37613e6d19a42 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 5 Dec 2025 18:56:01 +0000 Subject: [PATCH 14/19] Clean --- codex-rs/core/src/shell.rs | 1 - codex-rs/core/src/shell_snapshot.rs | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index de798a6e96..8433058e78 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -76,7 +76,6 @@ impl Shell { match self.shell_type { ShellType::Zsh | ShellType::Bash | ShellType::Sh => { let mut args = self.derive_exec_args(". \"$1\" && shift && exec \"$@\"", false); - args.push("codex-shell-snapshot".to_string()); args.push(snapshot.path.to_string_lossy().to_string()); args.extend_from_slice(command); args diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index a68b5d196a..cc1671b91b 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -269,7 +269,6 @@ mod tests { let wrapped = shell.wrap_command_with_snapshot(&original_command); let mut expected = shell.derive_exec_args(". \"$1\" && shift && exec \"$@\"", false); - expected.push("codex-shell-snapshot".to_string()); expected.push(snapshot_path.to_string_lossy().to_string()); expected.extend_from_slice(&original_command); @@ -283,7 +282,7 @@ mod tests { shell_type: ShellType::Cmd, shell_path: PathBuf::from("cmd"), shell_snapshot: Some(Arc::new(ShellSnapshot { - path: snapshot_path.clone(), + path: snapshot_path, })), }; let original_command = vec![ From b02847fd2f76d0f151923caf2d6e5602729b7ac6 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 5 Dec 2025 19:05:14 +0000 Subject: [PATCH 15/19] Clippy --- codex-rs/core/src/shell_snapshot.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index cc1671b91b..40a97fb0bd 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -230,6 +230,7 @@ mod tests { use std::sync::Arc; use tempfile::tempdir; + #[cfg(not(target_os = "windows"))] fn assert_posix_snapshot_sections(snapshot: &str) { assert!(snapshot.contains("# Snapshot file")); assert!(snapshot.contains("aliases ")); From c0b2fe4532af26de867f40a46c1964b17c967604 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 8 Dec 2025 09:41:34 +0000 Subject: [PATCH 16/19] Better snapshotting --- codex-rs/apply-patch/src/lib.rs | 9 +- .../core/src/tools/handlers/unified_exec.rs | 16 +- codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/shell_snapshot.rs | 222 ++++++++++++++++++ 4 files changed, 243 insertions(+), 5 deletions(-) create mode 100644 codex-rs/core/tests/suite/shell_snapshot.rs diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index 867d19a2e8..645f396845 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -112,7 +112,7 @@ fn classify_shell_name(shell: &str) -> Option { fn classify_shell(shell: &str, flag: &str) -> Option { classify_shell_name(shell).and_then(|name| match name.as_str() { - "bash" | "zsh" | "sh" if flag == "-lc" => Some(ApplyPatchShell::Unix), + "bash" | "zsh" | "sh" if matches!(flag, "-lc" | "-c") => Some(ApplyPatchShell::Unix), "pwsh" | "powershell" if flag.eq_ignore_ascii_case("-command") => { Some(ApplyPatchShell::PowerShell) } @@ -1049,6 +1049,13 @@ mod tests { assert_match(&heredoc_script(""), None); } + #[test] + fn test_heredoc_non_login_shell() { + let script = heredoc_script(""); + let args = strs_to_strings(&["bash", "-c", &script]); + assert_match_args(args, None); + } + #[test] fn test_heredoc_applypatch() { let args = strs_to_strings(&[ diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 7f0b1aa36b..8d34e86089 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -126,9 +126,10 @@ impl ToolHandler for UnifiedExecHandler { })?; let process_id = manager.allocate_process_id().await; - let command = get_command(&args, session.user_shell()); + let command_for_intercept = get_command(&args, session.user_shell()); let ExecCommandArgs { workdir, + login, yield_time_ms, max_output_tokens, with_escalated_permissions, @@ -155,7 +156,7 @@ impl ToolHandler for UnifiedExecHandler { let cwd = workdir.clone().unwrap_or_else(|| context.turn.cwd.clone()); if let Some(output) = intercept_apply_patch( - &command, + &command_for_intercept, &cwd, Some(yield_time_ms), context.session.as_ref(), @@ -176,6 +177,14 @@ impl ToolHandler for UnifiedExecHandler { &context.call_id, None, ); + let command = if login.is_none() { + context + .session + .user_shell() + .wrap_command_with_snapshot(&command_for_intercept) + } else { + command_for_intercept + }; let emitter = ToolEmitter::unified_exec( &command, cwd.clone(), @@ -259,8 +268,7 @@ fn get_command(args: &ExecCommandArgs, session_shell: Arc) -> Vec } let use_login_shell = args.login.unwrap_or(session_shell.shell_snapshot.is_none()); - let command = session_shell.derive_exec_args(&args.cmd, use_login_shell); - session_shell.wrap_command_with_snapshot(&command) + session_shell.derive_exec_args(&args.cmd, use_login_shell) } fn format_response(response: &UnifiedExecResponse) -> String { diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 86f417801a..c3c2821706 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -47,6 +47,7 @@ mod rmcp_client; mod rollout_list_find; mod seatbelt; mod shell_serialization; +mod shell_snapshot; mod stream_error_allows_next_turn; mod stream_no_completed; mod text_encoding_fix; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs new file mode 100644 index 0000000000..df54b1ae33 --- /dev/null +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -0,0 +1,222 @@ +use anyhow::Result; +use codex_core::features::Feature; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::EventMsg; +use codex_core::protocol::ExecCommandBeginEvent; +use codex_core::protocol::ExecCommandEndEvent; +use codex_core::protocol::Op; +use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::test_codex::TestCodexHarness; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use core_test_support::wait_for_event_match; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::path::PathBuf; +use tokio::fs; + +#[derive(Debug)] +struct SnapshotRun { + begin: ExecCommandBeginEvent, + end: ExecCommandEndEvent, + snapshot_path: PathBuf, + snapshot_content: String, + codex_home: PathBuf, +} + +#[allow(clippy::expect_used)] +async fn run_snapshot_command(command: &str) -> Result { + let builder = test_codex().with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config.features.enable(Feature::UnifiedExec); + config.features.enable(Feature::ShellSnapshot); + }); + let harness = TestCodexHarness::with_builder(builder).await?; + let args = json!({ + "cmd": command, + "yield_time_ms": 50, + }); + let call_id = "shell-snapshot-exec"; + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(harness.server(), responses).await; + + let test = harness.test(); + let codex = test.codex.clone(); + let codex_home = test.home.path().to_path_buf(); + let session_model = test.session_configured.model.clone(); + let cwd = test.cwd_path().to_path_buf(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "run unified exec with shell snapshot".into(), + }], + final_output_json_schema: None, + cwd, + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + let begin = wait_for_event_match(&codex, |ev| match ev { + EventMsg::ExecCommandBegin(ev) if ev.call_id == call_id => Some(ev.clone()), + _ => None, + }) + .await; + + let snapshot_arg = begin + .command + .iter() + .find(|arg| arg.contains("shell_snapshots")) + .expect("command includes shell snapshot path") + .to_owned(); + let snapshot_path = PathBuf::from(&snapshot_arg); + let snapshot_content = fs::read_to_string(&snapshot_path).await?; + + let end = wait_for_event_match(&codex, |ev| match ev { + EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => Some(ev.clone()), + _ => None, + }) + .await; + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + Ok(SnapshotRun { + begin, + end, + snapshot_path, + snapshot_content, + codex_home, + }) +} + +fn normalize_newlines(text: &str) -> String { + text.replace("\r\n", "\n") +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn assert_posix_snapshot_sections(snapshot: &str) { + assert!(snapshot.contains("# Snapshot file")); + assert!(snapshot.contains("aliases ")); + assert!(snapshot.contains("exports ")); + assert!(snapshot.contains("setopts ")); + assert!( + snapshot.contains("PATH"), + "snapshot should include PATH exports; snapshot={snapshot:?}" + ); +} + +#[cfg(target_os = "linux")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn linux_unified_exec_uses_shell_snapshot() -> Result<()> { + let command = "echo snapshot-linux"; + let run = run_snapshot_command(command).await?; + + let shell_path = run + .begin + .command + .first() + .expect("shell path recorded") + .clone(); + assert_eq!(run.begin.command.get(1).map(String::as_str), Some("-c")); + assert_eq!( + run.begin.command.get(2).map(String::as_str), + Some(". \"$1\" && shift && exec \"$@\"") + ); + assert_eq!(run.begin.command.get(4), Some(&shell_path)); + assert_eq!(run.begin.command.get(5).map(String::as_str), Some("-c")); + assert_eq!(run.begin.command.last(), Some(&command.to_string())); + + assert!(run.snapshot_path.starts_with(&run.codex_home)); + assert_posix_snapshot_sections(&run.snapshot_content); + assert_eq!(normalize_newlines(&run.end.stdout).trim(), "snapshot-linux"); + assert_eq!(run.end.exit_code, 0); + + Ok(()) +} + +#[cfg(target_os = "macos")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn macos_unified_exec_uses_shell_snapshot() -> Result<()> { + let command = "echo snapshot-macos"; + let run = run_snapshot_command(command).await?; + + let shell_path = run + .begin + .command + .first() + .expect("shell path recorded") + .clone(); + assert_eq!(run.begin.command.get(1).map(String::as_str), Some("-c")); + assert_eq!( + run.begin.command.get(2).map(String::as_str), + Some(". \"$1\" && shift && exec \"$@\"") + ); + assert_eq!(run.begin.command.get(4), Some(&shell_path)); + assert_eq!(run.begin.command.get(5).map(String::as_str), Some("-c")); + assert_eq!(run.begin.command.last(), Some(&command.to_string())); + + assert!(run.snapshot_path.starts_with(&run.codex_home)); + assert_posix_snapshot_sections(&run.snapshot_content); + assert_eq!(normalize_newlines(&run.end.stdout).trim(), "snapshot-macos"); + assert_eq!(run.end.exit_code, 0); + + Ok(()) +} + +#[cfg(target_os = "windows")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn windows_unified_exec_uses_shell_snapshot() -> Result<()> { + let command = "Write-Output snapshot-windows"; + let run = run_snapshot_command(command).await?; + + let snapshot_index = run + .begin + .command + .iter() + .position(|arg| arg.contains("shell_snapshots")) + .expect("snapshot argument exists"); + assert!(run.begin.command.iter().any(|arg| arg == "-NoProfile")); + assert!( + run.begin + .command + .iter() + .any(|arg| arg == "param($snapshot) . $snapshot; & @args") + ); + assert!(snapshot_index > 0); + assert_eq!(run.begin.command.last(), Some(&command.to_string())); + + assert!(run.snapshot_path.starts_with(&run.codex_home)); + assert!(run.snapshot_content.contains("# Snapshot file")); + assert!(run.snapshot_content.contains("# aliases ")); + assert!(run.snapshot_content.contains("# exports ")); + assert_eq!( + normalize_newlines(&run.end.stdout).trim(), + "snapshot-windows" + ); + assert_eq!(run.end.exit_code, 0); + + Ok(()) +} From 7ca2071c3acceca3f8c3f4b487953cc500659859 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 8 Dec 2025 10:26:02 +0000 Subject: [PATCH 17/19] NIT --- codex-rs/core/src/shell.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index 5d8bc1d02c..3f41c28f56 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -452,6 +452,7 @@ mod tests { let test_bash_shell = Shell { shell_type: ShellType::Bash, shell_path: PathBuf::from("/bin/bash"), + shell_snapshot: None, }; assert_eq!( test_bash_shell.derive_exec_args("echo hello", false), @@ -465,6 +466,7 @@ mod tests { let test_zsh_shell = Shell { shell_type: ShellType::Zsh, shell_path: PathBuf::from("/bin/zsh"), + shell_snapshot: None, }; assert_eq!( test_zsh_shell.derive_exec_args("echo hello", false), @@ -478,6 +480,7 @@ mod tests { let test_powershell_shell = Shell { shell_type: ShellType::PowerShell, shell_path: PathBuf::from("pwsh.exe"), + shell_snapshot: None, }; assert_eq!( test_powershell_shell.derive_exec_args("echo hello", false), From acb92d2cb84bd038aab2cb7f495122c7a6f8ecfe Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 8 Dec 2025 11:34:45 +0000 Subject: [PATCH 18/19] More fixes --- codex-rs/core/src/shell.rs | 2 +- codex-rs/core/src/shell_snapshot.rs | 38 +++++++++++++++++---- codex-rs/core/tests/suite/shell_snapshot.rs | 4 +-- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index 3f41c28f56..608d806323 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -75,7 +75,7 @@ impl Shell { match self.shell_type { ShellType::Zsh | ShellType::Bash | ShellType::Sh => { - let mut args = self.derive_exec_args(". \"$1\" && shift && exec \"$@\"", false); + let mut args = self.derive_exec_args(". \"$0\" && exec \"$@\"", false); args.push(snapshot.path.to_string_lossy().to_string()); args.extend_from_slice(command); args diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index 40a97fb0bd..e7c8abb066 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -9,8 +9,6 @@ use anyhow::bail; use tokio::fs; use tokio::process::Command; use tokio::time::timeout; -use tracing::debug; -use tracing::warn; use uuid::Uuid; use crate::shell::Shell; @@ -33,9 +31,12 @@ impl ShellSnapshot { .join("shell_snapshots") .join(format!("{}.{}", Uuid::new_v4(), extension)); match write_shell_snapshot(shell.shell_type.clone(), &path).await { - Ok(path) => Some(Self { path }), + Ok(path) => { + tracing::info!("Shell snapshot successfully created: {}", path.display()); + Some(Self { path }) + } Err(err) => { - warn!( + tracing::warn!( "Failed to create shell snapshot for {}: {err:?}", shell.name() ); @@ -48,7 +49,7 @@ impl ShellSnapshot { impl Drop for ShellSnapshot { fn drop(&mut self) { if let Err(err) = std::fs::remove_file(&self.path) { - debug!( + tracing::warn!( "Failed to delete shell snapshot at {:?}: {err:?}", self.path ); @@ -60,7 +61,8 @@ pub async fn write_shell_snapshot(shell_type: ShellType, output_path: &Path) -> let shell = get_shell(shell_type.clone(), None) .with_context(|| format!("No available shell for {shell_type:?}"))?; - let snapshot = capture_snapshot(&shell).await?; + let raw_snapshot = capture_snapshot(&shell).await?; + let snapshot = strip_snapshot_preamble(&raw_snapshot)?; if let Some(parent) = output_path.parent() { let parent_display = parent.display(); @@ -88,6 +90,15 @@ async fn capture_snapshot(shell: &Shell) -> Result { } } +fn strip_snapshot_preamble(snapshot: &str) -> Result { + let marker = "# Snapshot file"; + let Some(start) = snapshot.find(marker) else { + bail!("Snapshot output missing marker {marker}"); + }; + + Ok(snapshot[start..].to_string()) +} + async fn run_shell_script(shell: &Shell, script: &str) -> Result { let args = shell.derive_exec_args(script, true); let shell_name = shell.name(); @@ -250,6 +261,19 @@ mod tests { Ok(content) } + #[test] + fn strip_snapshot_preamble_removes_leading_output() { + let snapshot = "noise\n# Snapshot file\nexport PATH=/bin\n"; + let cleaned = strip_snapshot_preamble(snapshot).expect("snapshot marker exists"); + assert_eq!(cleaned, "# Snapshot file\nexport PATH=/bin\n"); + } + + #[test] + fn strip_snapshot_preamble_requires_marker() { + let result = strip_snapshot_preamble("missing header"); + assert!(result.is_err()); + } + #[cfg(unix)] #[test] fn wrap_command_with_snapshot_wraps_bash_shell() { @@ -269,7 +293,7 @@ mod tests { let wrapped = shell.wrap_command_with_snapshot(&original_command); - let mut expected = shell.derive_exec_args(". \"$1\" && shift && exec \"$@\"", false); + let mut expected = shell.derive_exec_args(". \"$0\" && exec \"$@\"", false); expected.push(snapshot_path.to_string_lossy().to_string()); expected.extend_from_slice(&original_command); diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index df54b1ae33..d6aaa636a0 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -143,7 +143,7 @@ async fn linux_unified_exec_uses_shell_snapshot() -> Result<()> { assert_eq!(run.begin.command.get(1).map(String::as_str), Some("-c")); assert_eq!( run.begin.command.get(2).map(String::as_str), - Some(". \"$1\" && shift && exec \"$@\"") + Some(". \"$0\" && exec \"$@\"") ); assert_eq!(run.begin.command.get(4), Some(&shell_path)); assert_eq!(run.begin.command.get(5).map(String::as_str), Some("-c")); @@ -172,7 +172,7 @@ async fn macos_unified_exec_uses_shell_snapshot() -> Result<()> { assert_eq!(run.begin.command.get(1).map(String::as_str), Some("-c")); assert_eq!( run.begin.command.get(2).map(String::as_str), - Some(". \"$1\" && shift && exec \"$@\"") + Some(". \"$0\" && exec \"$@\"") ); assert_eq!(run.begin.command.get(4), Some(&shell_path)); assert_eq!(run.begin.command.get(5).map(String::as_str), Some("-c")); From c12891f749eb94e9bf0bb5b1f9b8bf1233352425 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 8 Dec 2025 15:52:22 +0000 Subject: [PATCH 19/19] More time for powershell --- codex-rs/core/tests/suite/shell_snapshot.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index d6aaa636a0..950b328607 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -42,7 +42,7 @@ async fn run_snapshot_command(command: &str) -> Result { let harness = TestCodexHarness::with_builder(builder).await?; let args = json!({ "cmd": command, - "yield_time_ms": 50, + "yield_time_ms": 1000, }); let call_id = "shell-snapshot-exec"; let responses = vec![