diff --git a/app/src/ai/background_jobs/mod.rs b/app/src/ai/background_jobs/mod.rs index 01fbcac7..3b08858d 100644 --- a/app/src/ai/background_jobs/mod.rs +++ b/app/src/ai/background_jobs/mod.rs @@ -107,6 +107,8 @@ pub struct BackgroundJobLaunch { pub command: String, pub working_directory: PathBuf, pub shell: Option, + /// Partial session metadata overrides, not a complete exported environment. + /// The execution host's environment and shell profile supply other values. pub environment: Option>, pub host_id: Option, } @@ -291,7 +293,7 @@ impl BackgroundJobsModel { if !cwd.is_dir() { 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); ensure_private_directory(&self.log_directory) .map_err(|error| format!("Cannot create temporary job log directory: {error}"))?; diff --git a/app/src/ai/background_jobs/runtime.rs b/app/src/ai/background_jobs/runtime.rs index e893b5d0..4f5c8026 100644 --- a/app/src/ai/background_jobs/runtime.rs +++ b/app/src/ai/background_jobs/runtime.rs @@ -1,5 +1,7 @@ use std::collections::HashMap; +use std::ffi::OsString; use std::io; +use std::path::{Path, PathBuf}; use std::process::ExitStatus; use std::sync::Arc; #[cfg(unix)] @@ -304,7 +306,128 @@ impl JobOutcome { } } -pub(super) fn command_for_launch(launch: &BackgroundJobLaunch) -> Result { +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 { let (program, shell_type) = match &launch.shell { Some(ShellLaunchData::Executable { executable_path, 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 return Err("Background jobs cannot run container commands on the host. Run this command yourself inside the sandbox.".to_owned()), None => { #[cfg(unix)] - { (std::path::PathBuf::from("/bin/sh"), None) } + { fallback_unix_shell(std::env::var_os("SHELL")) } #[cfg(windows)] { (std::path::PathBuf::from(std::env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into())), None) } #[cfg(not(any(unix, windows)))] @@ -325,22 +448,42 @@ pub(super) fn command_for_launch(launch: &BackgroundJobLaunch) -> Result { - command.args(["--noprofile", "--norc", "-c"]); - } - Some(ShellType::Zsh) => { - command.args(["-f", "-c"]); - } - Some(ShellType::Fish) => { - command.args(["--no-config", "-c"]); + Some(kind @ (ShellType::Bash | ShellType::Zsh | ShellType::Fish)) => { + configure_profile_bootstrap( + &mut command, + launch, + approved_directory, + &program, + kind, + &environment, + )?; } Some(ShellType::PowerShell) => { - command.args(["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"]); + configure_powershell_bootstrap( + &mut command, + launch, + approved_directory, + &program, + &environment, + )?; } None => { + command.envs(&environment); #[cfg(unix)] - command.arg("-c"); + command.arg("-c").arg(&launch.command); #[cfg(windows)] { use command::windows::CommandExt; @@ -348,28 +491,6 @@ pub(super) fn command_for_launch(launch: &BackgroundJobLaunch) -> Result Result) -> (PathBuf, Option) { + 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>, + case_insensitive: bool, +) -> Result, 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, +) -> Result<(), String> { + 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::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 { + 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, +) -> 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::>(), + ) +} + +#[cfg(test)] +#[path = "runtime_tests.rs"] +mod tests; diff --git a/app/src/ai/background_jobs/runtime_tests.rs b/app/src/ai/background_jobs/runtime_tests.rs new file mode 100644 index 00000000..de196065 --- /dev/null +++ b/app/src/ai/background_jobs/runtime_tests.rs @@ -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); + } +} diff --git a/app/src/terminal/model/session/active_session.rs b/app/src/terminal/model/session/active_session.rs index 71a39d27..c074cd61 100644 --- a/app/src/terminal/model/session/active_session.rs +++ b/app/src/terminal/model/session/active_session.rs @@ -93,9 +93,9 @@ impl ActiveSession { 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. - /// `None` means integration has not supplied a snapshot; an empty snapshot is intentional. pub fn environment_variables( &self, app: &AppContext,