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:
@@ -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()),
|
||||||
};
|
};
|
||||||
|
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"]);
|
command.args(["-i", "-l"]);
|
||||||
if shell_type != ShellType::Fish {
|
if shell_type == ShellType::Bash {
|
||||||
// No terminal is attached: profile initialization must not create
|
// No terminal is attached: profile initialization must not create
|
||||||
// separate interactive job-control groups before the owned exec.
|
// separate interactive job-control groups before the owned exec.
|
||||||
command.arg("+m");
|
command.arg("+m");
|
||||||
}
|
}
|
||||||
command.args(["-c", setup]);
|
command.args(["-c", setup]);
|
||||||
// Bash/zsh reserve this argument as $0; fish puts every following argument in $argv.
|
// Bash reserves this as $0; fish uses every following argument as $argv.
|
||||||
if shell_type != ShellType::Fish {
|
if shell_type == ShellType::Bash {
|
||||||
command.arg("galaxy-background-job");
|
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]);
|
||||||
}
|
}
|
||||||
|
if shell_type != ShellType::Zsh {
|
||||||
command.arg(shell_path_argument(program, msys)?);
|
command.arg(shell_path_argument(program, msys)?);
|
||||||
command.args(inner_flags).arg(&launch.command);
|
command.args(inner_flags);
|
||||||
|
}
|
||||||
|
command.arg(&launch.command);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user