Load profile environments for background commands
Treat terminal metadata as partial overrides, preserve the host environment, and initialize shell profiles before running the unchanged command noninteractively. Restore the approved directory and prompt guards while retaining background process ownership.
This commit is contained in:
@@ -107,6 +107,8 @@ pub struct BackgroundJobLaunch {
|
|||||||
pub command: String,
|
pub command: String,
|
||||||
pub working_directory: PathBuf,
|
pub working_directory: PathBuf,
|
||||||
pub shell: Option<ShellLaunchData>,
|
pub shell: Option<ShellLaunchData>,
|
||||||
|
/// Partial session metadata overrides, not a complete exported environment.
|
||||||
|
/// The execution host's environment and shell profile supply other values.
|
||||||
pub environment: Option<HashMap<String, String>>,
|
pub environment: Option<HashMap<String, String>>,
|
||||||
pub host_id: Option<HostId>,
|
pub host_id: Option<HostId>,
|
||||||
}
|
}
|
||||||
@@ -291,7 +293,7 @@ impl BackgroundJobsModel {
|
|||||||
if !cwd.is_dir() {
|
if !cwd.is_dir() {
|
||||||
return Err("The command working directory is not a directory".to_owned());
|
return Err("The command working directory is not a directory".to_owned());
|
||||||
}
|
}
|
||||||
let mut command = runtime::command_for_launch(&launch)?;
|
let mut command = runtime::command_for_launch(&launch, &cwd)?;
|
||||||
command.current_dir(&cwd);
|
command.current_dir(&cwd);
|
||||||
ensure_private_directory(&self.log_directory)
|
ensure_private_directory(&self.log_directory)
|
||||||
.map_err(|error| format!("Cannot create temporary job log directory: {error}"))?;
|
.map_err(|error| format!("Cannot create temporary job log directory: {error}"))?;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::ffi::OsString;
|
||||||
use std::io;
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::ExitStatus;
|
use std::process::ExitStatus;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@@ -304,7 +306,128 @@ impl JobOutcome {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn command_for_launch(launch: &BackgroundJobLaunch) -> Result<Command, String> {
|
const BACKGROUND_ENV_PREFIX: &str = "_GALAXY_BACKGROUND_JOB_";
|
||||||
|
const POWERSHELL_SETUP_KEY: &str = "_GALAXY_BACKGROUND_JOB_SETUP_KEY";
|
||||||
|
const NONINTERACTIVE_ENVIRONMENT: &[(&str, &str)] = &[
|
||||||
|
("CI", "1"),
|
||||||
|
("TERM", "dumb"),
|
||||||
|
("NO_COLOR", "1"),
|
||||||
|
("PAGER", "cat"),
|
||||||
|
("GIT_PAGER", "cat"),
|
||||||
|
("GH_PAGER", "cat"),
|
||||||
|
("SYSTEMD_PAGER", "cat"),
|
||||||
|
("AWS_PAGER", ""),
|
||||||
|
("GIT_TERMINAL_PROMPT", "0"),
|
||||||
|
("GCM_INTERACTIVE", "Never"),
|
||||||
|
("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.
|
||||||
|
// 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#"
|
||||||
|
builtin set +xv
|
||||||
|
builtin set +m
|
||||||
|
__galaxy_job_cwd=$1
|
||||||
|
__galaxy_job_prefix=$2
|
||||||
|
__galaxy_job_count=$3
|
||||||
|
builtin shift 3
|
||||||
|
while [ "$__galaxy_job_count" -gt 0 ]; do
|
||||||
|
__galaxy_job_key=$1
|
||||||
|
__galaxy_job_slot="$__galaxy_job_prefix$__galaxy_job_key"
|
||||||
|
builtin declare -p "$__galaxy_job_slot" >/dev/null 2>&1 || exit 126
|
||||||
|
builtin export "$__galaxy_job_key=${!__galaxy_job_slot}" || exit 126
|
||||||
|
builtin unset "$__galaxy_job_slot"
|
||||||
|
builtin shift
|
||||||
|
__galaxy_job_count=$((__galaxy_job_count - 1))
|
||||||
|
done
|
||||||
|
# BASH_ENV/ENV would initialize another profile in the noninteractive child.
|
||||||
|
builtin unset SSH_ASKPASS SUDO_ASKPASS BASH_ENV ENV
|
||||||
|
builtin cd -- "$__galaxy_job_cwd" || exit 126
|
||||||
|
builtin exec -- "$@"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
const ZSH_PROFILE_SETUP: &str = r#"
|
||||||
|
builtin unsetopt XTRACE VERBOSE MONITOR
|
||||||
|
__galaxy_job_cwd=$1
|
||||||
|
__galaxy_job_prefix=$2
|
||||||
|
__galaxy_job_count=$3
|
||||||
|
builtin shift 3
|
||||||
|
while [ "$__galaxy_job_count" -gt 0 ]; do
|
||||||
|
__galaxy_job_key=$1
|
||||||
|
__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 -- "$@"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
const FISH_PROFILE_SETUP: &str = r#"
|
||||||
|
builtin set -e fish_trace
|
||||||
|
builtin set -l __galaxy_job_cwd "$argv[1]"
|
||||||
|
builtin set -l __galaxy_job_prefix "$argv[2]"
|
||||||
|
builtin set -l __galaxy_job_count "$argv[3]"
|
||||||
|
builtin set -e argv[1..3]
|
||||||
|
while builtin test "$__galaxy_job_count" -gt 0
|
||||||
|
builtin set -l __galaxy_job_key "$argv[1]"
|
||||||
|
builtin set -l __galaxy_job_slot "$__galaxy_job_prefix$__galaxy_job_key"
|
||||||
|
builtin set -q "$__galaxy_job_slot"; or exit 126
|
||||||
|
builtin set -gx "$__galaxy_job_key" "$$__galaxy_job_slot"; or exit 126
|
||||||
|
builtin set -e "$__galaxy_job_slot"
|
||||||
|
builtin set -e argv[1]
|
||||||
|
builtin set __galaxy_job_count (builtin math "$__galaxy_job_count - 1")
|
||||||
|
end
|
||||||
|
for __galaxy_job_key in SSH_ASKPASS SUDO_ASKPASS BASH_ENV ENV
|
||||||
|
builtin set -e "$__galaxy_job_key"
|
||||||
|
end
|
||||||
|
builtin cd -- "$__galaxy_job_cwd"; or exit 126
|
||||||
|
exec $argv
|
||||||
|
"#;
|
||||||
|
|
||||||
|
// PowerShell loads profiles in the noninteractive bootstrap, then starts a
|
||||||
|
// profile-free command host. A real host boundary preserves the original
|
||||||
|
// command's exit semantics, including nonterminating errors and explicit exit.
|
||||||
|
const POWERSHELL_PROFILE_SETUP: &str = r#"
|
||||||
|
Microsoft.PowerShell.Core\Set-PSDebug -Off
|
||||||
|
try {
|
||||||
|
$__galaxyJobKey = [Environment]::GetEnvironmentVariable('_GALAXY_BACKGROUND_JOB_SETUP_KEY', 'Process')
|
||||||
|
if ([String]::IsNullOrEmpty($__galaxyJobKey)) { throw 'Missing background job setup' }
|
||||||
|
$__galaxyJobPayload = [Environment]::GetEnvironmentVariable($__galaxyJobKey, 'Process')
|
||||||
|
[Environment]::SetEnvironmentVariable($__galaxyJobKey, $null, 'Process')
|
||||||
|
[Environment]::SetEnvironmentVariable('_GALAXY_BACKGROUND_JOB_SETUP_KEY', $null, 'Process')
|
||||||
|
$__galaxyJob = ConvertFrom-Json -InputObject $__galaxyJobPayload -ErrorAction Stop
|
||||||
|
$__galaxyJobPayload = $null
|
||||||
|
foreach ($__galaxyJobEntry in $__galaxyJob.environment.PSObject.Properties) {
|
||||||
|
[Environment]::SetEnvironmentVariable($__galaxyJobEntry.Name, [String]$__galaxyJobEntry.Value, 'Process')
|
||||||
|
}
|
||||||
|
[Environment]::SetEnvironmentVariable('SSH_ASKPASS', $null, 'Process')
|
||||||
|
[Environment]::SetEnvironmentVariable('SUDO_ASKPASS', $null, 'Process')
|
||||||
|
Set-Location -LiteralPath $__galaxyJob.cwd -ErrorAction Stop
|
||||||
|
$__galaxyJobProgram = [String]$__galaxyJob.program
|
||||||
|
$__galaxyJobCommand = [String]$__galaxyJob.encoded_command
|
||||||
|
$__galaxyJob = $null
|
||||||
|
} catch {
|
||||||
|
[Console]::Error.WriteLine('Galaxy could not initialize the background command shell.')
|
||||||
|
exit 126
|
||||||
|
}
|
||||||
|
$PSNativeCommandUseErrorActionPreference = $false
|
||||||
|
$LASTEXITCODE = $null
|
||||||
|
& $__galaxyJobProgram -NoLogo -NoProfile -NonInteractive -EncodedCommand $__galaxyJobCommand
|
||||||
|
if ($null -eq $LASTEXITCODE) { exit 126 }
|
||||||
|
exit $LASTEXITCODE
|
||||||
|
"#;
|
||||||
|
|
||||||
|
pub(super) fn command_for_launch(
|
||||||
|
launch: &BackgroundJobLaunch,
|
||||||
|
approved_directory: &Path,
|
||||||
|
) -> Result<Command, String> {
|
||||||
let (program, shell_type) = match &launch.shell {
|
let (program, shell_type) = match &launch.shell {
|
||||||
Some(ShellLaunchData::Executable { executable_path, shell_type })
|
Some(ShellLaunchData::Executable { executable_path, shell_type })
|
||||||
| Some(ShellLaunchData::MSYS2 { executable_path, shell_type }) => (executable_path.clone(), Some(*shell_type)),
|
| Some(ShellLaunchData::MSYS2 { executable_path, shell_type }) => (executable_path.clone(), Some(*shell_type)),
|
||||||
@@ -312,7 +435,7 @@ pub(super) fn command_for_launch(launch: &BackgroundJobLaunch) -> Result<Command
|
|||||||
Some(ShellLaunchData::DockerSandbox { .. }) => return Err("Background jobs cannot run container commands on the host. Run this command yourself inside the sandbox.".to_owned()),
|
Some(ShellLaunchData::DockerSandbox { .. }) => return Err("Background jobs cannot run container commands on the host. Run this command yourself inside the sandbox.".to_owned()),
|
||||||
None => {
|
None => {
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{ (std::path::PathBuf::from("/bin/sh"), None) }
|
{ fallback_unix_shell(std::env::var_os("SHELL")) }
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{ (std::path::PathBuf::from(std::env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into())), None) }
|
{ (std::path::PathBuf::from(std::env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into())), None) }
|
||||||
#[cfg(not(any(unix, windows)))]
|
#[cfg(not(any(unix, windows)))]
|
||||||
@@ -325,22 +448,42 @@ pub(super) fn command_for_launch(launch: &BackgroundJobLaunch) -> Result<Command
|
|||||||
let mut command = Command::new_with_process_group(&program);
|
let mut command = Command::new_with_process_group(&program);
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
command.creation_flags(windows::Win32::System::Threading::CREATE_SUSPENDED.0);
|
command.creation_flags(windows::Win32::System::Threading::CREATE_SUSPENDED.0);
|
||||||
|
let environment = background_environment(launch.environment.as_ref(), cfg!(windows))?;
|
||||||
|
// Precmd currently provides partial metadata (for example KUBECONFIG), not
|
||||||
|
// a complete export snapshot. Preserve the host's HOME, PATH and other vars.
|
||||||
|
if let Some(overrides) = &launch.environment {
|
||||||
|
command.envs(overrides);
|
||||||
|
}
|
||||||
|
// CI/TERM guards can deliberately skip rc initialization. Apply the full
|
||||||
|
// job environment after profiles, while suppressing auth prompts throughout.
|
||||||
|
command
|
||||||
|
.env("SSH_ASKPASS_REQUIRE", "never")
|
||||||
|
.env("GIT_TERMINAL_PROMPT", "0")
|
||||||
|
.env("GCM_INTERACTIVE", "Never");
|
||||||
match shell_type {
|
match shell_type {
|
||||||
Some(ShellType::Bash) => {
|
Some(kind @ (ShellType::Bash | ShellType::Zsh | ShellType::Fish)) => {
|
||||||
command.args(["--noprofile", "--norc", "-c"]);
|
configure_profile_bootstrap(
|
||||||
}
|
&mut command,
|
||||||
Some(ShellType::Zsh) => {
|
launch,
|
||||||
command.args(["-f", "-c"]);
|
approved_directory,
|
||||||
}
|
&program,
|
||||||
Some(ShellType::Fish) => {
|
kind,
|
||||||
command.args(["--no-config", "-c"]);
|
&environment,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
Some(ShellType::PowerShell) => {
|
Some(ShellType::PowerShell) => {
|
||||||
command.args(["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"]);
|
configure_powershell_bootstrap(
|
||||||
|
&mut command,
|
||||||
|
launch,
|
||||||
|
approved_directory,
|
||||||
|
&program,
|
||||||
|
&environment,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
|
command.envs(&environment);
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
command.arg("-c");
|
command.arg("-c").arg(&launch.command);
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
use command::windows::CommandExt;
|
use command::windows::CommandExt;
|
||||||
@@ -348,28 +491,6 @@ pub(super) fn command_for_launch(launch: &BackgroundJobLaunch) -> Result<Command
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if shell_type.is_some() || cfg!(unix) {
|
|
||||||
command.arg(&launch.command);
|
|
||||||
}
|
|
||||||
let mut environment = HashMap::from([
|
|
||||||
("CI", "1"),
|
|
||||||
("TERM", "dumb"),
|
|
||||||
("NO_COLOR", "1"),
|
|
||||||
("PAGER", "cat"),
|
|
||||||
("GIT_PAGER", "cat"),
|
|
||||||
("GH_PAGER", "cat"),
|
|
||||||
("SYSTEMD_PAGER", "cat"),
|
|
||||||
("AWS_PAGER", ""),
|
|
||||||
("GIT_TERMINAL_PROMPT", "0"),
|
|
||||||
("GCM_INTERACTIVE", "Never"),
|
|
||||||
("SSH_ASKPASS_REQUIRE", "never"),
|
|
||||||
]);
|
|
||||||
if let Some(snapshot) = &launch.environment {
|
|
||||||
// The shell integration supplies its complete exported environment. Inheriting
|
|
||||||
// the app's environment here would restore variables explicitly unset in that shell.
|
|
||||||
command.env_clear().envs(snapshot);
|
|
||||||
}
|
|
||||||
command.envs(environment.drain());
|
|
||||||
command.env_remove("SSH_ASKPASS").env_remove("SUDO_ASKPASS");
|
command.env_remove("SSH_ASKPASS").env_remove("SUDO_ASKPASS");
|
||||||
command.stdin(command::Stdio::null());
|
command.stdin(command::Stdio::null());
|
||||||
// On Unix ProcessGuard kills the group before dropping/reaping Child. A separate
|
// On Unix ProcessGuard kills the group before dropping/reaping Child. A separate
|
||||||
@@ -378,3 +499,146 @@ pub(super) fn command_for_launch(launch: &BackgroundJobLaunch) -> Result<Command
|
|||||||
command.kill_on_drop(true);
|
command.kill_on_drop(true);
|
||||||
Ok(command)
|
Ok(command)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn fallback_unix_shell(host_shell: Option<OsString>) -> (PathBuf, Option<ShellType>) {
|
||||||
|
if let Some(shell) = host_shell.filter(|shell| Path::new(shell).is_absolute()) {
|
||||||
|
if let Some(kind) = shell.to_str().and_then(ShellType::from_name) {
|
||||||
|
return (PathBuf::from(shell), Some(kind));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(PathBuf::from("/bin/sh"), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn background_environment(
|
||||||
|
overrides: Option<&HashMap<String, String>>,
|
||||||
|
case_insensitive: bool,
|
||||||
|
) -> Result<HashMap<String, String>, String> {
|
||||||
|
let mut environment = overrides.cloned().unwrap_or_default();
|
||||||
|
if environment
|
||||||
|
.iter()
|
||||||
|
.any(|(name, value)| !valid_override_name(name) || value.contains('\0'))
|
||||||
|
{
|
||||||
|
return Err("The background command has an invalid environment override".to_owned());
|
||||||
|
}
|
||||||
|
for (name, value) in NONINTERACTIVE_ENVIRONMENT {
|
||||||
|
if case_insensitive {
|
||||||
|
environment.retain(|key, _| !key.eq_ignore_ascii_case(name));
|
||||||
|
}
|
||||||
|
environment.insert((*name).to_owned(), (*value).to_owned());
|
||||||
|
}
|
||||||
|
Ok(environment)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_override_name(name: &str) -> bool {
|
||||||
|
let mut bytes = name.bytes();
|
||||||
|
let valid_start = bytes
|
||||||
|
.next()
|
||||||
|
.is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_');
|
||||||
|
valid_start
|
||||||
|
&& bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|
||||||
|
&& !name.to_ascii_uppercase().starts_with(BACKGROUND_ENV_PREFIX)
|
||||||
|
&& !name.to_ascii_lowercase().starts_with("__galaxy_job_")
|
||||||
|
&& name != "argv"
|
||||||
|
&& name != "fish_trace"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configure_profile_bootstrap(
|
||||||
|
command: &mut Command,
|
||||||
|
launch: &BackgroundJobLaunch,
|
||||||
|
approved_directory: &Path,
|
||||||
|
program: &Path,
|
||||||
|
shell_type: ShellType,
|
||||||
|
environment: &HashMap<String, String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let prefix = format!("{BACKGROUND_ENV_PREFIX}{}_", uuid::Uuid::new_v4().simple());
|
||||||
|
let mut keys = environment.keys().collect::<Vec<_>>();
|
||||||
|
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::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");
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shell_path_argument(path: &Path, msys: bool) -> Result<OsString, String> {
|
||||||
|
if !msys {
|
||||||
|
return Ok(path.as_os_str().to_owned());
|
||||||
|
}
|
||||||
|
let value = path
|
||||||
|
.to_str()
|
||||||
|
.ok_or("The MSYS2 background command path is not valid Unicode")?;
|
||||||
|
let value = if let Some(unc) = value.strip_prefix(r"\\?\UNC\") {
|
||||||
|
format!(r"\\{unc}")
|
||||||
|
} else {
|
||||||
|
value.strip_prefix(r"\\?\").unwrap_or(value).to_owned()
|
||||||
|
};
|
||||||
|
Ok(galaxy_util::path::convert_windows_path_to_msys2(&value).into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configure_powershell_bootstrap(
|
||||||
|
command: &mut Command,
|
||||||
|
launch: &BackgroundJobLaunch,
|
||||||
|
approved_directory: &Path,
|
||||||
|
program: &Path,
|
||||||
|
environment: &HashMap<String, String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let cwd = approved_directory
|
||||||
|
.to_str()
|
||||||
|
.ok_or("The PowerShell command directory is not valid Unicode")?;
|
||||||
|
let program = program
|
||||||
|
.to_str()
|
||||||
|
.ok_or("The PowerShell executable path is not valid Unicode")?;
|
||||||
|
let payload = serde_json::to_string(&serde_json::json!({
|
||||||
|
"program": program,
|
||||||
|
"encoded_command": encode_powershell_command(&launch.command),
|
||||||
|
"cwd": cwd,
|
||||||
|
"environment": environment,
|
||||||
|
}))
|
||||||
|
.map_err(|_| "Could not prepare the background command environment".to_owned())?;
|
||||||
|
let key = format!(
|
||||||
|
"{BACKGROUND_ENV_PREFIX}{}_SETUP",
|
||||||
|
uuid::Uuid::new_v4().simple()
|
||||||
|
);
|
||||||
|
command.env(POWERSHELL_SETUP_KEY, &key).env(&key, payload);
|
||||||
|
let encoded = encode_powershell_command(POWERSHELL_PROFILE_SETUP);
|
||||||
|
command.args(["-NoLogo", "-NonInteractive", "-EncodedCommand", &encoded]);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_powershell_command(script: &str) -> String {
|
||||||
|
use base64::Engine as _;
|
||||||
|
|
||||||
|
base64::engine::general_purpose::STANDARD.encode(
|
||||||
|
script
|
||||||
|
.encode_utf16()
|
||||||
|
.flat_map(u16::to_le_bytes)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "runtime_tests.rs"]
|
||||||
|
mod tests;
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn partial_environment_overrides_preserve_inheritance_and_protected_guards() {
|
||||||
|
let overrides = HashMap::from([
|
||||||
|
("KUBECONFIG".to_owned(), "/session/config".to_owned()),
|
||||||
|
("CI".to_owned(), "profile-value".to_owned()),
|
||||||
|
]);
|
||||||
|
let environment = background_environment(Some(&overrides), false).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
environment.get("KUBECONFIG").map(String::as_str),
|
||||||
|
Some("/session/config")
|
||||||
|
);
|
||||||
|
for (key, value) in NONINTERACTIVE_ENVIRONMENT {
|
||||||
|
assert_eq!(environment.get(*key).map(String::as_str), Some(*value));
|
||||||
|
}
|
||||||
|
// These remain inherited from the execution host, not fabricated or cleared
|
||||||
|
// when shell integration supplies an empty/partial map.
|
||||||
|
assert!(!background_environment(Some(&HashMap::new()), false)
|
||||||
|
.unwrap()
|
||||||
|
.contains_key("HOME"));
|
||||||
|
assert!(!environment.contains_key("PATH"));
|
||||||
|
let windows =
|
||||||
|
background_environment(Some(&HashMap::from([("ci".into(), "wrong".into())])), true)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(windows.get("CI").map(String::as_str), Some("1"));
|
||||||
|
assert!(!windows.contains_key("ci"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn environment_override_names_cannot_inject_shell_syntax_or_private_setup() {
|
||||||
|
for name in [
|
||||||
|
"",
|
||||||
|
"1BAD",
|
||||||
|
"A;echo bad",
|
||||||
|
"A=B",
|
||||||
|
"A\0B",
|
||||||
|
"_GALAXY_BACKGROUND_JOB_SETUP_KEY",
|
||||||
|
"__galaxy_job_count",
|
||||||
|
"argv",
|
||||||
|
] {
|
||||||
|
let overrides = HashMap::from([(name.to_owned(), "test-value".to_owned())]);
|
||||||
|
assert!(background_environment(Some(&overrides), false).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn fallback_shell_uses_only_a_recognized_absolute_host_shell() {
|
||||||
|
assert_eq!(
|
||||||
|
fallback_unix_shell(Some("/usr/bin/zsh".into())),
|
||||||
|
(PathBuf::from("/usr/bin/zsh"), Some(ShellType::Zsh))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fallback_unix_shell(Some("/usr/bin/fish".into())),
|
||||||
|
(PathBuf::from("/usr/bin/fish"), Some(ShellType::Fish))
|
||||||
|
);
|
||||||
|
for value in [None, Some("bash".into()), Some("/bin/unknown-shell".into())] {
|
||||||
|
assert_eq!(fallback_unix_shell(value), (PathBuf::from("/bin/sh"), None));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn msys_bootstrap_paths_use_posix_drive_and_unc_forms() {
|
||||||
|
assert_eq!(
|
||||||
|
shell_path_argument(Path::new(r"C:\Program Files\Git\bin\bash.exe"), true).unwrap(),
|
||||||
|
OsString::from("/c/Program Files/Git/bin/bash.exe")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
shell_path_argument(Path::new(r"\\?\C:\Users\name\project"), true).unwrap(),
|
||||||
|
OsString::from("/c/Users/name/project")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
shell_path_argument(Path::new(r"\\?\UNC\server\share\project"), true).unwrap(),
|
||||||
|
OsString::from("//server/share/project")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn fixture_launch(
|
||||||
|
shell: PathBuf,
|
||||||
|
shell_type: ShellType,
|
||||||
|
command: &str,
|
||||||
|
cwd: &Path,
|
||||||
|
) -> BackgroundJobLaunch {
|
||||||
|
BackgroundJobLaunch {
|
||||||
|
conversation_id: crate::ai::agent::conversation::AIConversationId::new(),
|
||||||
|
action_id: "profile-test-action".to_owned().into(),
|
||||||
|
command: command.to_owned(),
|
||||||
|
working_directory: cwd.to_path_buf(),
|
||||||
|
shell: Some(ShellLaunchData::Executable {
|
||||||
|
executable_path: shell,
|
||||||
|
shell_type,
|
||||||
|
}),
|
||||||
|
environment: Some(HashMap::new()),
|
||||||
|
host_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn write_executable(path: &Path, contents: &str) {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::write(path, contents).unwrap();
|
||||||
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn profile_bootstrap_keeps_command_noninteractive_and_restores_overrides_cwd_and_guards() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let cwd = std::fs::canonicalize(root.path()).unwrap();
|
||||||
|
let shell = cwd.join("fixture-bash");
|
||||||
|
// Exercise actual bash initialization state and argv forwarding without
|
||||||
|
// sourcing /etc/profile, the developer's HOME, or any real user rc files.
|
||||||
|
write_executable(
|
||||||
|
&shell,
|
||||||
|
r#"#!/bin/sh
|
||||||
|
printf '%s\n' "$@" >> "$GALAXY_TEST_ARGV"
|
||||||
|
if [ "$1" = '-i' ] && [ "$2" = '-l' ] && [ "$3" = '+m' ] && [ "$4" = '-c' ]; then
|
||||||
|
printf '%s' "$$" > "$GALAXY_TEST_PID"
|
||||||
|
shift 4
|
||||||
|
GALAXY_TEST_SETUP=$1
|
||||||
|
export GALAXY_TEST_SETUP
|
||||||
|
shift
|
||||||
|
exec /bin/bash --noprofile --norc -i +m -c '. "$GALAXY_TEST_PROFILE"; builtin eval "$GALAXY_TEST_SETUP"' "$@"
|
||||||
|
fi
|
||||||
|
exec /bin/bash "$@"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
let profile = cwd.join("fixture-profile");
|
||||||
|
std::fs::write(
|
||||||
|
&profile,
|
||||||
|
r#"
|
||||||
|
case $- in *i*) ;; *) return ;; esac
|
||||||
|
if [ "$CI" = 1 ] || [ "$TERM" = dumb ]; then return; fi
|
||||||
|
export GALAXY_TEST_PROFILE_LOADED=loaded
|
||||||
|
export GALAXY_TEST_OVERRIDE=profile
|
||||||
|
export CI=profile
|
||||||
|
export SSH_ASKPASS=profile
|
||||||
|
export BASH_ENV="$GALAXY_TEST_BAD_STARTUP"
|
||||||
|
export PATH="$GALAXY_TEST_TOOLS:$PATH"
|
||||||
|
cd /
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let tools = cwd.join("tools");
|
||||||
|
std::fs::create_dir(&tools).unwrap();
|
||||||
|
write_executable(
|
||||||
|
&tools.join("galaxy-fixture-tool"),
|
||||||
|
"#!/bin/sh\nprintf 'tool-found\\n'\n",
|
||||||
|
);
|
||||||
|
let bad_startup = cwd.join("unexpected-noninteractive-startup");
|
||||||
|
std::fs::write(&bad_startup, "exit 97\n").unwrap();
|
||||||
|
let text = r#"printf '%s\n' "$PWD" "$GALAXY_TEST_PROFILE_LOADED" "$GALAXY_TEST_OVERRIDE" "$CI" "${SSH_ASKPASS-unset}" "$$"
|
||||||
|
case $- in *i*) exit 99 ;; esac
|
||||||
|
if IFS= read -r ignored; then exit 98; fi
|
||||||
|
galaxy-fixture-tool
|
||||||
|
printf '%s\n' "a'b \"quoted\"" "line1
|
||||||
|
line2"
|
||||||
|
"#;
|
||||||
|
let mut launch = fixture_launch(shell, ShellType::Bash, text, &cwd);
|
||||||
|
let secret = "synthetic-session-value-not-for-argv";
|
||||||
|
launch.environment = Some(HashMap::from([
|
||||||
|
("GALAXY_TEST_OVERRIDE".into(), secret.into()),
|
||||||
|
("CI".into(), "profile-startup".into()),
|
||||||
|
("TERM".into(), "profile-startup".into()),
|
||||||
|
]));
|
||||||
|
let mut command = command_for_launch(&launch, &cwd).unwrap();
|
||||||
|
let argv_log = cwd.join("argv");
|
||||||
|
let pid_file = cwd.join("pid");
|
||||||
|
command
|
||||||
|
.current_dir(&cwd)
|
||||||
|
.env("GALAXY_TEST_PROFILE", profile)
|
||||||
|
.env("GALAXY_TEST_TOOLS", tools)
|
||||||
|
.env("GALAXY_TEST_ARGV", &argv_log)
|
||||||
|
.env("GALAXY_TEST_PID", &pid_file)
|
||||||
|
.env("GALAXY_TEST_BAD_STARTUP", bad_startup)
|
||||||
|
.env_remove("BASH_ENV");
|
||||||
|
let output = command.output().await.unwrap();
|
||||||
|
assert!(output.status.success());
|
||||||
|
let original_pid = std::fs::read_to_string(pid_file).unwrap();
|
||||||
|
assert_eq!(String::from_utf8(output.stdout).unwrap(), format!("{}\nloaded\n{secret}\n1\nunset\n{original_pid}\ntool-found\na'b \"quoted\"\nline1\nline2\n", cwd.display()));
|
||||||
|
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() {
|
||||||
|
let Some(pwsh) = std::env::var_os("PATH").and_then(|paths| {
|
||||||
|
std::env::split_paths(&paths)
|
||||||
|
.map(|path| path.join("pwsh"))
|
||||||
|
.find(|path| path.is_file())
|
||||||
|
}) else {
|
||||||
|
// Optional host tool: the Unix bootstrap coverage does not require PowerShell.
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let shell = root.path().join("fixture-pwsh");
|
||||||
|
// Disable real profiles in the test host only. Production configuration
|
||||||
|
// intentionally omits -NoProfile and uses the same constant setup script.
|
||||||
|
write_executable(
|
||||||
|
&shell,
|
||||||
|
"#!/bin/sh\nexec \"$GALAXY_TEST_PWSH\" -NoProfile \"$@\"\n",
|
||||||
|
);
|
||||||
|
for (script, expected_code, expected_stdout) in [
|
||||||
|
("exit 17", 17, ""),
|
||||||
|
("& /bin/sh -c 'exit 9'", 1, ""),
|
||||||
|
("Write-Error 'failure'", 1, ""),
|
||||||
|
(
|
||||||
|
"Write-Error 'failure'; Write-Output 'success'",
|
||||||
|
0,
|
||||||
|
"success\n",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"param()\nWrite-Output 'quoted \"value\"'\n# trailing comment",
|
||||||
|
0,
|
||||||
|
"quoted \"value\"\n",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let launch = fixture_launch(shell.clone(), ShellType::PowerShell, script, root.path());
|
||||||
|
let mut command = command_for_launch(&launch, root.path()).unwrap();
|
||||||
|
command
|
||||||
|
.current_dir(root.path())
|
||||||
|
.env("GALAXY_TEST_PWSH", &pwsh);
|
||||||
|
let output = command.output().await.unwrap();
|
||||||
|
assert_eq!(output.status.code(), Some(expected_code));
|
||||||
|
assert_eq!(String::from_utf8(output.stdout).unwrap(), expected_stdout);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,9 +93,9 @@ impl ActiveSession {
|
|||||||
self.current_working_directory.as_ref()
|
self.current_working_directory.as_ref()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot the shell integration's environment for an independent command process.
|
/// Partial environment metadata reported by shell integration (for example KUBECONFIG).
|
||||||
|
/// This is not a complete export snapshot and must not clear a child process's environment.
|
||||||
/// This does not execute in or change the user's interactive shell.
|
/// This does not execute in or change the user's interactive shell.
|
||||||
/// `None` means integration has not supplied a snapshot; an empty snapshot is intentional.
|
|
||||||
pub fn environment_variables(
|
pub fn environment_variables(
|
||||||
&self,
|
&self,
|
||||||
app: &AppContext,
|
app: &AppContext,
|
||||||
|
|||||||
Reference in New Issue
Block a user