diff --git a/codex-rs/exec-server/tests/exec_process/shell_snapshot.rs b/codex-rs/exec-server/tests/exec_process/shell_snapshot.rs index 9f1396bf79..47082e0a7a 100644 --- a/codex-rs/exec-server/tests/exec_process/shell_snapshot.rs +++ b/codex-rs/exec-server/tests/exec_process/shell_snapshot.rs @@ -14,6 +14,8 @@ enum SnapshotSandbox { #[cfg_attr(target_os = "macos", test_case("zsh", false, SnapshotSandbox::None; "zsh"))] #[cfg_attr(target_os = "macos", test_case("zsh", true, SnapshotSandbox::None; "zsh_tty"))] #[test_case("bash", false, SnapshotSandbox::DenyFdPath; "blocked_descriptor_path")] +#[cfg_attr(target_os = "macos", test_case("zsh", false, SnapshotSandbox::DenyFdPath; "zsh_blocked_descriptor_path"))] +#[cfg_attr(target_os = "macos", test_case("zsh", true, SnapshotSandbox::DenyFdPath; "zsh_blocked_descriptor_path_tty"))] #[test_case("bash", false, SnapshotSandbox::WorkspaceWrite; "bash_protected_transport")] #[test_case("bash", true, SnapshotSandbox::WorkspaceWrite; "bash_protected_transport_tty")] #[cfg_attr(target_os = "macos", test_case("zsh", false, SnapshotSandbox::WorkspaceWrite; "zsh_protected_transport"))] @@ -66,10 +68,15 @@ async fn shell_snapshot_concurrent_replays_keep_independent_readers( } else { format!("case \"{source}\" in /dev/fd/*) ;; *) return 42 ;; esac; ") }; + let aliases = if shell == "zsh" { + "module_path=()\nsetopt RC_QUOTES\nalias snapshot_quoted=\"printf '%s|' 'one''two'\"\nalias eval='exit 44'\nalias case='exit 45'\n" + } else { + "" + }; std::fs::write( home.path().join(format!(".{shell}rc")), format!( - "printf x >> \"$HOME/captures\"\nprofile_helper() {{ {source_check}local payload='{payload}'; [ \"${{#payload}}\" = {payload_len} ] || return 43; printf 'restored:%s' \"$1\"; }}\nexec() {{ exit 41; }}\nset -u\n" + "printf x >> \"$HOME/captures\"\nprofile_helper() {{ {source_check}local payload='{payload}'; [ \"${{#payload}}\" = {payload_len} ] || return 43; printf 'restored:%s' \"$1\"; }}\nexec() {{ exit 41; }}\nset -u\n{aliases}" ), )?; let protected_file = @@ -81,7 +88,13 @@ async fn shell_snapshot_concurrent_replays_keep_independent_readers( } else { "" }; - let command = format!("{command}IFS= read -r line; profile_helper \"$line\"; exit 7"); + let replay_checks = if shell == "zsh" { + "eval snapshot_quoted\n[[ -o rcquotes ]] || exit 46\n" + } else { + "" + }; + let command = + format!("{command}IFS= read -r line\n{replay_checks}profile_helper \"$line\"; exit 7"); let sandbox = if use_sandbox { let mut policy = FileSystemSandboxPolicy::read_only(); policy.entries.push(FileSystemSandboxEntry::new( @@ -161,6 +174,12 @@ async fn shell_snapshot_concurrent_replays_keep_independent_readers( output.ends_with(&format!("restored:input-{index}")), "{output:?}" ); + if shell == "zsh" { + assert!( + output.ends_with(&format!("one'two|restored:input-{index}")), + "{output:?}" + ); + } assert_eq!((errors, status, closed), (String::new(), Some(7), true)); Ok::<_, anyhow::Error>(()) } diff --git a/codex-rs/shell-command/src/shell_snapshot_capture.rs b/codex-rs/shell-command/src/shell_snapshot_capture.rs index 547f146edc..cbd1f235e9 100644 --- a/codex-rs/shell-command/src/shell_snapshot_capture.rs +++ b/codex-rs/shell-command/src/shell_snapshot_capture.rs @@ -88,6 +88,13 @@ fn capture_script( script .replace("SNAPSHOT_OPTIONS_BEGIN", match replay { Replay::Eval => "", Replay::Source => "printf '{\\n'" }) .replace("SNAPSHOT_ALIASES_END", match replay { Replay::Eval => "", Replay::Source => "printf \"case '' in '') ;; esac\\n}\\n\"" }) + // The source group is parsed before its options take effect. Serialize + // aliases with the replay shell's initial quoting rules, not RC_QUOTES. + // setopt works without the optional zsh/parameter module. + .replace("SNAPSHOT_ZSH_ALIASES", match replay { + Replay::Eval => "\\alias -L", + Replay::Source => "(\\setopt NO_RC_QUOTES; \\alias -L)", + }) .replace("SNAPSHOT_EXPORTS", &declarations) .replace( "SNAPSHOT_DECLARATION_ENVIRONMENT", @@ -133,7 +140,7 @@ print '' printf '\0' alias_count=$(\alias -L | __codex_snapshot_command wc -l | __codex_snapshot_command tr -d ' ') print "# aliases $alias_count" -\alias -L +SNAPSHOT_ZSH_ALIASES print '' SNAPSHOT_ALIASES_END printf '\0' diff --git a/codex-rs/shell-command/src/shell_snapshot_tests.rs b/codex-rs/shell-command/src/shell_snapshot_tests.rs index 9b6a2d8efa..43a8952752 100644 --- a/codex-rs/shell-command/src/shell_snapshot_tests.rs +++ b/codex-rs/shell-command/src/shell_snapshot_tests.rs @@ -2236,50 +2236,72 @@ fn captured_script(shell_type: ShellType, source: &str) -> Result { #[test] fn streamed_snapshot_preserves_alias_and_option_parse_order() -> Result<()> { - for (shell, shell_type, options) in [ - ("/bin/bash", ShellType::Bash, "shopt -s expand_aliases"), - ("/bin/zsh", ShellType::Zsh, "setopt RC_QUOTES"), + for (shell, shell_type, options, expected) in [ + ( + "/bin/bash", + ShellType::Bash, + "shopt -s expand_aliases", + "onetwohelper", + ), + ( + "/bin/zsh", + ShellType::Zsh, + "module_path=()\nsetopt RC_QUOTES", + "one'twohelper", + ), + ( + "/bin/zsh", + ShellType::Zsh, + "module_path=()\nsetopt NO_RC_QUOTES", + "onetwohelper", + ), ] { if !std::path::Path::new(shell).exists() { continue; } let dir = tempdir()?; - let mut setup = format!( + let setup = format!( "{options}\nhelper() {{ printf helper; }}\nfunction : () {{ exit 41; }}\nalias snapshot_alias=\"printf 'one''two'\"\n" ); - if shell_type == ShellType::Zsh { - setup.push_str("alias alias=false\n"); - } let mut results = Vec::new(); - for source in [false, true] { - let capture = if source { + // Cover V1's native source, V2's source, and V2's environment fallback. + for (source_capture, source_replay) in [(false, true), (true, true), (true, false)] { + let capture = if source_capture { super::snapshot_source_capture_script(shell_type, CAPTURE_NON_INTERACTIVE) } else { snapshot_capture_script(shell_type, CAPTURE_NON_INTERACTIVE) } .unwrap(); + let shadow = if source_capture && shell_type == ShellType::Zsh { + "alias alias=false\n" + } else { + "" + }; let output = Command::new(shell) - .args(["-c", &format!("{setup}{capture}")]) + .args(["-c", &format!("{setup}{shadow}{capture}")]) .output()?; assert!(output.status.success(), "{shell}: {output:?}"); let captured = CapturedSnapshot::parse(shell_type, &output.stdout).unwrap(); let path = dir.path().join("state"); std::fs::write(&path, captured.render_state())?; - let restore = if source { + let restore = if source_replay { ". \"$1\"" } else { "eval \"$(cat \"$1\")\"" }; - let command = format!("{restore}\n\\alias snapshot_alias\nhelper\n"); + let command = + format!("{restore}\n\\alias snapshot_alias\neval snapshot_alias\nhelper\n"); let output = Command::new(shell) .args(["-c", &command, "snapshot-test"]) .arg(&path) .output()?; assert!(output.status.success(), "{shell}: {output:?}"); assert!(String::from_utf8_lossy(&output.stdout).contains("snapshot_alias=")); + assert!(String::from_utf8_lossy(&output.stdout).ends_with(expected)); results.push((output.stdout, output.stderr)); } assert_eq!(results[0], results[1], "{shell}"); + assert_eq!(results[0], results[2], "{shell}"); } Ok(()) }