diff --git a/app/src/ai/background_jobs/runtime.rs b/app/src/ai/background_jobs/runtime.rs index 4f5c8026..ccd62d1a 100644 --- a/app/src/ai/background_jobs/runtime.rs +++ b/app/src/ai/background_jobs/runtime.rs @@ -322,9 +322,9 @@ const NONINTERACTIVE_ENVIRONMENT: &[(&str, &str)] = &[ ("SSH_ASKPASS_REQUIRE", "never"), ]; -// Only profile initialization is interactive. The approved command is passed as -// one untouched argv item to a fresh noninteractive shell, never interpolated -// into these setup scripts. exec keeps the existing owned process-group leader. +// Commands are passed as untouched data to constant setup scripts and execute +// noninteractively in the owned process tree. Bash/fish use a profile bootstrap; +// zsh keeps rc-defined shell state in its noninteractive command worker. // Private environment shuttles preserve explicit overrides through rc files // without exposing their values in argv, files, or captured environment output. const BASH_PROFILE_SETUP: &str = r#" @@ -355,18 +355,31 @@ __galaxy_job_cwd=$1 __galaxy_job_prefix=$2 __galaxy_job_count=$3 builtin shift 3 +__galaxy_job_keys=() while [ "$__galaxy_job_count" -gt 0 ]; do - __galaxy_job_key=$1 + __galaxy_job_keys+=("$1") + builtin shift + __galaxy_job_count=$((__galaxy_job_count - 1)) +done +__galaxy_job_command=$1 +builtin set -- +# Keep rc-defined functions, aliases and unexported state in the command worker. +# Login files are loaded by zsh; noninteractive shells need an explicit zshrc. +__galaxy_job_rc="${ZDOTDIR:-$HOME}/.zshrc" +if [[ -r "$__galaxy_job_rc" ]]; then + builtin source "$__galaxy_job_rc" +fi +builtin unsetopt XTRACE VERBOSE MONITOR +builtin set -- +for __galaxy_job_key in "${__galaxy_job_keys[@]}"; do __galaxy_job_slot="$__galaxy_job_prefix$__galaxy_job_key" builtin typeset -p "$__galaxy_job_slot" >/dev/null 2>&1 || exit 126 builtin export "$__galaxy_job_key=${(P)__galaxy_job_slot}" || exit 126 builtin unset "$__galaxy_job_slot" - builtin shift - __galaxy_job_count=$((__galaxy_job_count - 1)) done builtin unset SSH_ASKPASS SUDO_ASKPASS BASH_ENV ENV builtin cd -- "$__galaxy_job_cwd" || exit 126 -builtin exec -- "$@" +builtin eval -- "$__galaxy_job_command" "#; const FISH_PROFILE_SETUP: &str = r#" @@ -551,35 +564,45 @@ fn configure_profile_bootstrap( shell_type: ShellType, environment: &HashMap, ) -> Result<(), String> { + let msys = matches!(&launch.shell, Some(ShellLaunchData::MSYS2 { .. })); let prefix = format!("{BACKGROUND_ENV_PREFIX}{}_", uuid::Uuid::new_v4().simple()); let mut keys = environment.keys().collect::>(); keys.sort(); let (setup, inner_flags): (&str, &[&str]) = match shell_type { ShellType::Bash => (BASH_PROFILE_SETUP, &["--noprofile", "--norc", "-c"]), - ShellType::Zsh => (ZSH_PROFILE_SETUP, &["-f", "-c"]), + ShellType::Zsh => (ZSH_PROFILE_SETUP, &[]), ShellType::Fish => (FISH_PROFILE_SETUP, &["--no-config", "-c"]), ShellType::PowerShell => return Err("Invalid shell bootstrap selection".to_owned()), }; - command.args(["-i", "-l"]); - if shell_type != ShellType::Fish { - // No terminal is attached: profile initialization must not create - // separate interactive job-control groups before the owned exec. - command.arg("+m"); + if shell_type == ShellType::Zsh { + // Source zshrc and execute in one noninteractive process. A second zsh + // would discard rc-defined functions such as user command wrappers. + command.args(["-l", "-c", setup]); + command.arg(shell_path_argument(program, msys)?); + } else { + command.args(["-i", "-l"]); + if shell_type == ShellType::Bash { + // No terminal is attached: profile initialization must not create + // separate interactive job-control groups before the owned exec. + command.arg("+m"); + } + command.args(["-c", setup]); + // Bash reserves this as $0; fish uses every following argument as $argv. + if shell_type == ShellType::Bash { + command.arg("galaxy-background-job"); + } } - command.args(["-c", setup]); - // Bash/zsh reserve this argument as $0; fish puts every following argument in $argv. - if shell_type != ShellType::Fish { - command.arg("galaxy-background-job"); - } - let msys = matches!(&launch.shell, Some(ShellLaunchData::MSYS2 { .. })); command.arg(shell_path_argument(approved_directory, msys)?); command.arg(&prefix).arg(keys.len().to_string()); for key in keys { command.arg(key); command.env(format!("{prefix}{key}"), &environment[key]); } - command.arg(shell_path_argument(program, msys)?); - command.args(inner_flags).arg(&launch.command); + if shell_type != ShellType::Zsh { + command.arg(shell_path_argument(program, msys)?); + command.args(inner_flags); + } + command.arg(&launch.command); Ok(()) } diff --git a/app/src/ai/background_jobs/runtime_tests.rs b/app/src/ai/background_jobs/runtime_tests.rs index de196065..60240759 100644 --- a/app/src/ai/background_jobs/runtime_tests.rs +++ b/app/src/ai/background_jobs/runtime_tests.rs @@ -183,6 +183,145 @@ line2" assert!(!std::fs::read_to_string(argv_log).unwrap().contains(secret)); } +#[cfg(unix)] +#[tokio::test] +async fn zsh_worker_retains_profile_functions_aliases_and_state_without_interactivity() { + let Some(zsh) = std::env::var_os("PATH").and_then(|paths| { + std::env::split_paths(&paths) + .map(|path| path.join("zsh")) + .find(|path| path.is_file()) + }) else { + // Optional host tool: fixture coverage never sources real user profiles. + return; + }; + let root = tempfile::tempdir().unwrap(); + let cwd = std::fs::canonicalize(root.path()).unwrap(); + let shell = cwd.join("fixture-zsh"); + let home = cwd.join("home"); + let rc_directory = cwd.join("rc"); + let bin = home.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::create_dir(&rc_directory).unwrap(); + // Exercise the selected zsh worker and constant setup script while + // disabling automatic user/login profiles in this isolated fixture. + write_executable( + &shell, + r#"#!/bin/sh +printf '%s\n' "$@" >> "$GALAXY_TEST_ARGV" +printf '%s' "$$" > "$GALAXY_TEST_PID" +if [ "$1" != '-l' ] || [ "$2" != '-c' ]; then exit 95; fi +shift +exec "$GALAXY_TEST_ZSH" -f "$@" +"#, + ); + write_executable( + &bin.join("galaxy-fixture-tool"), + r#"#!/bin/sh +if [ "${GALAXY_TEST_UNEXPORTED+x}" = x ]; then exit 94; fi +printf 'tool:%s:%s\n' "$1" "$2" +"#, + ); + std::fs::write( + bin.join("worktree.zsh"), + r#"wt() { + "$HOME/bin/galaxy-fixture-tool" "$GALAXY_TEST_UNEXPORTED" "$@" +} +alias galaxy-fixture-alias='wt alias' +"#, + ) + .unwrap(); + std::fs::write( + rc_directory.join(".zshrc"), + r#"if [[ -o interactive || $# -ne 0 ]]; then return 93; fi +printf '%s\n' loaded >> "$GALAXY_TEST_PROFILE_COUNT" +source "$HOME/bin/worktree.zsh" +GALAXY_TEST_UNEXPORTED='worker-state' +GALAXY_TEST_PROFILE_PID=$$ +export PATH="$HOME/bin:$PATH" +export GALAXY_TEST_OVERRIDE=profile +export CI=profile +export TERM=profile +export SSH_ASKPASS=profile +export SUDO_ASKPASS=profile +export BASH_ENV=profile +export ENV=profile +builtin set -- 'rc changed argv' 'discard these arguments' +builtin cd / +"#, + ) + .unwrap(); + let script = r#"printf '%s\n' "$PWD" "$0" "$GALAXY_TEST_PROFILE_PID" "$GALAXY_TEST_OVERRIDE" "$CI" "$TERM" "${SSH_ASKPASS-unset}" "${SUDO_ASKPASS-unset}" "${BASH_ENV-unset}" "${ENV-unset}" "$#" +if [[ -o interactive || -t 0 || -t 1 || -t 2 ]]; then exit 99; fi +if IFS= read -r ignored; then exit 98; fi +wt direct +galaxy-fixture-alias +galaxy-fixture-tool lookup via-path +printf '%s\n' "a'b \"quoted\"" "line1 +line2" +# trailing comment +"#; + let secret = "synthetic-zsh-session-value-not-for-argv"; + let argv_log = cwd.join("argv"); + let pid_file = cwd.join("pid"); + let profile_count = cwd.join("profile-count"); + for (index, (text, expected_code, expected_tail)) in [ + (script, 0, None), + ( + "wt exit-check\nexit 17\n# trailing comment", + 17, + Some("tool:worker-state:exit-check\n"), + ), + ( + "wt failure-check\nfalse\n# trailing comment", + 1, + Some("tool:worker-state:failure-check\n"), + ), + ] + .into_iter() + .enumerate() + { + let mut launch = fixture_launch(shell.clone(), ShellType::Zsh, text, &cwd); + launch.environment = Some(HashMap::from([( + "GALAXY_TEST_OVERRIDE".into(), + secret.into(), + )])); + let mut command = command_for_launch(&launch, &cwd).unwrap(); + command + .current_dir(&cwd) + .env("HOME", &home) + .env("ZDOTDIR", &rc_directory) + .env("GALAXY_TEST_ZSH", &zsh) + .env("GALAXY_TEST_ARGV", &argv_log) + .env("GALAXY_TEST_PID", &pid_file) + .env("GALAXY_TEST_PROFILE_COUNT", &profile_count) + .env_remove("GALAXY_TEST_UNEXPORTED") + .env_remove("GALAXY_TEST_PROFILE_PID"); + let output = command.output().await.unwrap(); + assert_eq!( + output.status.code(), + Some(expected_code), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let expected = if let Some(tail) = expected_tail { + tail.to_owned() + } else { + let original_pid = std::fs::read_to_string(&pid_file).unwrap(); + format!( + "{}\n{}\n{original_pid}\n{secret}\n1\ndumb\nunset\nunset\nunset\nunset\n0\ntool:worker-state:direct\ntool:worker-state:alias\ntool:lookup:via-path\na'b \"quoted\"\nline1\nline2\n", + cwd.display(), + shell.display() + ) + }; + assert_eq!(String::from_utf8(output.stdout).unwrap(), expected); + assert_eq!( + std::fs::read_to_string(&profile_count).unwrap(), + "loaded\n".repeat(index + 1) + ); + } + assert!(!std::fs::read_to_string(argv_log).unwrap().contains(secret)); +} + #[cfg(unix)] #[tokio::test] async fn powershell_setup_preserves_explicit_exit_native_failure_and_script_text() {