Keep zsh profile functions in background jobs

Load zshrc and evaluate the submitted command in the same noninteractive worker so functions, aliases and unexported shell state survive. Preserve private overrides, working directory, stdin restrictions and command exit status.
This commit is contained in:
Ryan Ward
2026-11-03 18:34:26 +00:00
parent 62df16c605
commit a5eda544c4
2 changed files with 183 additions and 21 deletions
+44 -21
View File
@@ -322,9 +322,9 @@ const NONINTERACTIVE_ENVIRONMENT: &[(&str, &str)] = &[
("SSH_ASKPASS_REQUIRE", "never"), ("SSH_ASKPASS_REQUIRE", "never"),
]; ];
// Only profile initialization is interactive. The approved command is passed as // Commands are passed as untouched data to constant setup scripts and execute
// one untouched argv item to a fresh noninteractive shell, never interpolated // noninteractively in the owned process tree. Bash/fish use a profile bootstrap;
// into these setup scripts. exec keeps the existing owned process-group leader. // zsh keeps rc-defined shell state in its noninteractive command worker.
// Private environment shuttles preserve explicit overrides through rc files // Private environment shuttles preserve explicit overrides through rc files
// without exposing their values in argv, files, or captured environment output. // without exposing their values in argv, files, or captured environment output.
const BASH_PROFILE_SETUP: &str = r#" const BASH_PROFILE_SETUP: &str = r#"
@@ -355,18 +355,31 @@ __galaxy_job_cwd=$1
__galaxy_job_prefix=$2 __galaxy_job_prefix=$2
__galaxy_job_count=$3 __galaxy_job_count=$3
builtin shift 3 builtin shift 3
__galaxy_job_keys=()
while [ "$__galaxy_job_count" -gt 0 ]; do 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" __galaxy_job_slot="$__galaxy_job_prefix$__galaxy_job_key"
builtin typeset -p "$__galaxy_job_slot" >/dev/null 2>&1 || exit 126 builtin typeset -p "$__galaxy_job_slot" >/dev/null 2>&1 || exit 126
builtin export "$__galaxy_job_key=${(P)__galaxy_job_slot}" || exit 126 builtin export "$__galaxy_job_key=${(P)__galaxy_job_slot}" || exit 126
builtin unset "$__galaxy_job_slot" builtin unset "$__galaxy_job_slot"
builtin shift
__galaxy_job_count=$((__galaxy_job_count - 1))
done done
builtin unset SSH_ASKPASS SUDO_ASKPASS BASH_ENV ENV builtin unset SSH_ASKPASS SUDO_ASKPASS BASH_ENV ENV
builtin cd -- "$__galaxy_job_cwd" || exit 126 builtin cd -- "$__galaxy_job_cwd" || exit 126
builtin exec -- "$@" builtin eval -- "$__galaxy_job_command"
"#; "#;
const FISH_PROFILE_SETUP: &str = r#" const FISH_PROFILE_SETUP: &str = r#"
@@ -551,35 +564,45 @@ fn configure_profile_bootstrap(
shell_type: ShellType, shell_type: ShellType,
environment: &HashMap<String, String>, environment: &HashMap<String, String>,
) -> Result<(), String> { ) -> Result<(), String> {
let msys = matches!(&launch.shell, Some(ShellLaunchData::MSYS2 { .. }));
let prefix = format!("{BACKGROUND_ENV_PREFIX}{}_", uuid::Uuid::new_v4().simple()); let prefix = format!("{BACKGROUND_ENV_PREFIX}{}_", uuid::Uuid::new_v4().simple());
let mut keys = environment.keys().collect::<Vec<_>>(); let mut keys = environment.keys().collect::<Vec<_>>();
keys.sort(); keys.sort();
let (setup, inner_flags): (&str, &[&str]) = match shell_type { let (setup, inner_flags): (&str, &[&str]) = match shell_type {
ShellType::Bash => (BASH_PROFILE_SETUP, &["--noprofile", "--norc", "-c"]), 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::Fish => (FISH_PROFILE_SETUP, &["--no-config", "-c"]),
ShellType::PowerShell => return Err("Invalid shell bootstrap selection".to_owned()), ShellType::PowerShell => return Err("Invalid shell bootstrap selection".to_owned()),
}; };
command.args(["-i", "-l"]); if shell_type == ShellType::Zsh {
if shell_type != ShellType::Fish { // Source zshrc and execute in one noninteractive process. A second zsh
// No terminal is attached: profile initialization must not create // would discard rc-defined functions such as user command wrappers.
// separate interactive job-control groups before the owned exec. command.args(["-l", "-c", setup]);
command.arg("+m"); 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(shell_path_argument(approved_directory, msys)?);
command.arg(&prefix).arg(keys.len().to_string()); command.arg(&prefix).arg(keys.len().to_string());
for key in keys { for key in keys {
command.arg(key); command.arg(key);
command.env(format!("{prefix}{key}"), &environment[key]); command.env(format!("{prefix}{key}"), &environment[key]);
} }
command.arg(shell_path_argument(program, msys)?); if shell_type != ShellType::Zsh {
command.args(inner_flags).arg(&launch.command); command.arg(shell_path_argument(program, msys)?);
command.args(inner_flags);
}
command.arg(&launch.command);
Ok(()) Ok(())
} }
+139
View File
@@ -183,6 +183,145 @@ line2"
assert!(!std::fs::read_to_string(argv_log).unwrap().contains(secret)); 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)] #[cfg(unix)]
#[tokio::test] #[tokio::test]
async fn powershell_setup_preserves_explicit_exit_native_failure_and_script_text() { async fn powershell_setup_preserves_explicit_exit_native_failure_and_script_text() {