Compare commits
2
Commits
02af0af55f
...
62df16c605
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62df16c605 | ||
|
|
be5433177d |
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1059,7 +1059,7 @@ impl ResponseStream {
|
|||||||
|
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
fn raw_model_request_payload(params: &api::RequestParams, max_chars: usize) -> serde_json::Value {
|
fn raw_model_request_payload(params: &api::RequestParams, max_chars: usize) -> serde_json::Value {
|
||||||
use serde::ser::{SerializeMap, Serializer};
|
use serde::ser::{SerializeMap, SerializeSeq, Serializer};
|
||||||
|
|
||||||
// collect_str writes debug output directly through serde_json's escaping
|
// collect_str writes debug output directly through serde_json's escaping
|
||||||
// writer, without format! allocating another copy of potentially large input.
|
// writer, without format! allocating another copy of potentially large input.
|
||||||
@@ -1070,7 +1070,18 @@ fn raw_model_request_payload(params: &api::RequestParams, max_chars: usize) -> s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
remote_logging::bounded_payload_context(max_chars, |writer| {
|
struct NewestFirst<'a, T>(&'a [T]);
|
||||||
|
impl<T: serde::Serialize> serde::Serialize for NewestFirst<'_, T> {
|
||||||
|
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
let mut sequence = serializer.serialize_seq(Some(self.0.len()))?;
|
||||||
|
for message in self.0.iter().rev() {
|
||||||
|
sequence.serialize_element(message)?;
|
||||||
|
}
|
||||||
|
sequence.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remote_logging::prefix_bounded_payload_context(max_chars, |writer| {
|
||||||
let mut serializer = serde_json::Serializer::pretty(writer);
|
let mut serializer = serde_json::Serializer::pretty(writer);
|
||||||
let mut map = serializer.serialize_map(None)?;
|
let mut map = serializer.serialize_map(None)?;
|
||||||
map.serialize_entry("model_id", ¶ms.model.as_str())?;
|
map.serialize_entry("model_id", ¶ms.model.as_str())?;
|
||||||
@@ -1079,10 +1090,14 @@ fn raw_model_request_payload(params: &api::RequestParams, max_chars: usize) -> s
|
|||||||
map.serialize_entry("computer_use_model_id", ¶ms.computer_use_model.as_str())?;
|
map.serialize_entry("computer_use_model_id", ¶ms.computer_use_model.as_str())?;
|
||||||
map.serialize_entry("input", &DebugPayload(¶ms.input))?;
|
map.serialize_entry("input", &DebugPayload(¶ms.input))?;
|
||||||
map.serialize_entry("tool_results", &DebugPayload(¶ms.tool_results))?;
|
map.serialize_entry("tool_results", &DebugPayload(¶ms.tool_results))?;
|
||||||
map.serialize_entry("tasks", &DebugPayload(¶ms.tasks))?;
|
|
||||||
map.serialize_entry("message_history", ¶ms.message_history)?;
|
|
||||||
map.serialize_entry("progressive_summary", ¶ms.progressive_summary)?;
|
map.serialize_entry("progressive_summary", ¶ms.progressive_summary)?;
|
||||||
map.serialize_entry("tool_result_archive", ¶ms.tool_result_archive)?;
|
// Preserve the newest request context before old task/debug archives can
|
||||||
|
// consume the diagnostic budget. This ordering affects diagnostics only.
|
||||||
|
map.serialize_entry("message_history_order", "newest_first")?;
|
||||||
|
map.serialize_entry(
|
||||||
|
"message_history",
|
||||||
|
&NewestFirst(params.message_history.as_slice()),
|
||||||
|
)?;
|
||||||
map.serialize_entry("global_rules", ¶ms.global_rules)?;
|
map.serialize_entry("global_rules", ¶ms.global_rules)?;
|
||||||
map.serialize_entry("mcp_context", &DebugPayload(¶ms.mcp_context))?;
|
map.serialize_entry("mcp_context", &DebugPayload(¶ms.mcp_context))?;
|
||||||
map.serialize_entry("autonomy_level", &DebugPayload(¶ms.autonomy_level))?;
|
map.serialize_entry("autonomy_level", &DebugPayload(¶ms.autonomy_level))?;
|
||||||
@@ -1115,7 +1130,9 @@ fn raw_model_request_payload(params: &api::RequestParams, max_chars: usize) -> s
|
|||||||
"omitted_sensitive_fields",
|
"omitted_sensitive_fields",
|
||||||
&["api_keys", "custom_model_providers", "custom_model_routers"],
|
&["api_keys", "custom_model_providers", "custom_model_routers"],
|
||||||
)?;
|
)?;
|
||||||
map.end()
|
map.serialize_entry("tasks", &DebugPayload(¶ms.tasks))?;
|
||||||
|
map.serialize_entry("tool_result_archive", ¶ms.tool_result_archive)?;
|
||||||
|
SerializeMap::end(map)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -311,6 +311,89 @@ fn truncate_chars(value: &str, max_chars: usize) -> String {
|
|||||||
truncated
|
truncated
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stop serialization once the diagnostic prefix is full. Unlike the tail
|
||||||
|
/// collector below, this does not walk the remaining history or tool archive.
|
||||||
|
/// A truncated prefix is diagnostic text, not a complete serialized request.
|
||||||
|
pub(crate) fn prefix_bounded_payload_context(
|
||||||
|
max_chars: usize,
|
||||||
|
serialize: impl FnOnce(&mut dyn std::io::Write) -> serde_json::Result<()>,
|
||||||
|
) -> Value {
|
||||||
|
struct PrefixWriter {
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
max_chars: usize,
|
||||||
|
max_bytes: usize,
|
||||||
|
chars: usize,
|
||||||
|
truncated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::io::Write for PrefixWriter {
|
||||||
|
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||||
|
if self.truncated {
|
||||||
|
return Err(std::io::Error::other("diagnostic prefix limit reached"));
|
||||||
|
}
|
||||||
|
let mut included = 0;
|
||||||
|
for byte in bytes {
|
||||||
|
let starts_character = byte & 0xc0 != 0x80;
|
||||||
|
if (starts_character && self.chars == self.max_chars)
|
||||||
|
|| self.bytes.len().saturating_add(included) == self.max_bytes
|
||||||
|
{
|
||||||
|
self.bytes.extend_from_slice(&bytes[..included]);
|
||||||
|
self.truncated = true;
|
||||||
|
return if included == 0 {
|
||||||
|
Err(std::io::Error::other("diagnostic prefix limit reached"))
|
||||||
|
} else {
|
||||||
|
// Respect Write's short-write contract. serde's write_all
|
||||||
|
// immediately retries the remainder and receives the error.
|
||||||
|
Ok(included)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if starts_character {
|
||||||
|
self.chars += 1;
|
||||||
|
}
|
||||||
|
included += 1;
|
||||||
|
}
|
||||||
|
self.bytes.extend_from_slice(bytes);
|
||||||
|
Ok(bytes.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> std::io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut writer = PrefixWriter {
|
||||||
|
bytes: Vec::new(),
|
||||||
|
max_chars,
|
||||||
|
max_bytes: max_chars.saturating_mul(4),
|
||||||
|
chars: 0,
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
let result = serialize(&mut writer);
|
||||||
|
if let Err(error) = result {
|
||||||
|
if !writer.truncated {
|
||||||
|
return json!({ "serialization_error": sanitize_error(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Writers may split a UTF-8 character across calls. Keep only a complete
|
||||||
|
// prefix if serialization stopped before the last character was finished.
|
||||||
|
let valid_length = match std::str::from_utf8(&writer.bytes) {
|
||||||
|
Ok(_) => writer.bytes.len(),
|
||||||
|
Err(error) => error.valid_up_to(),
|
||||||
|
};
|
||||||
|
writer.bytes.truncate(valid_length);
|
||||||
|
let payload = String::from_utf8(writer.bytes).expect("validated diagnostic UTF-8 prefix");
|
||||||
|
let included_chars = payload.chars().count();
|
||||||
|
json!({
|
||||||
|
"payload": payload,
|
||||||
|
"payload_total_chars": if writer.truncated { None } else { Some(included_chars) },
|
||||||
|
"payload_included_chars": included_chars,
|
||||||
|
"payload_max_chars": max_chars,
|
||||||
|
"truncated": writer.truncated,
|
||||||
|
"truncation_strategy": if writer.truncated { Some("prefix") } else { None },
|
||||||
|
"serialization_stopped_early": writer.truncated,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Serialize directly into a bounded UTF-8 tail instead of materializing the full
|
/// Serialize directly into a bounded UTF-8 tail instead of materializing the full
|
||||||
/// request and its archive as a JSON Value and then another large String.
|
/// request and its archive as a JSON Value and then another large String.
|
||||||
pub(crate) fn bounded_payload_context(
|
pub(crate) fn bounded_payload_context(
|
||||||
|
|||||||
@@ -64,3 +64,79 @@ fn streamed_json_matches_serialized_payload_without_materializing_the_archive()
|
|||||||
});
|
});
|
||||||
assert_eq!(actual, tail_limited_payload_context(&expected, 100));
|
assert_eq!(actual, tail_limited_payload_context(&expected, 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prefix_payload_preserves_utf8_and_reports_unknown_truncated_size() {
|
||||||
|
let payload = "é日本🙂 ending";
|
||||||
|
for limit in [0, 1, 3, 4, payload.chars().count(), payload.len()] {
|
||||||
|
for chunk_size in [1, 2, 7, payload.len()] {
|
||||||
|
let actual = super::prefix_bounded_payload_context(limit, |writer| {
|
||||||
|
for chunk in payload.as_bytes().chunks(chunk_size) {
|
||||||
|
writer.write_all(chunk).map_err(serde_json::Error::io)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
let expected = payload.chars().take(limit).collect::<String>();
|
||||||
|
let truncated = limit < payload.chars().count();
|
||||||
|
assert_eq!(actual["payload"], expected);
|
||||||
|
assert_eq!(actual["payload_included_chars"], expected.chars().count());
|
||||||
|
assert_eq!(actual["truncated"], truncated);
|
||||||
|
assert_eq!(actual["serialization_stopped_early"], truncated);
|
||||||
|
assert_eq!(
|
||||||
|
actual["payload_total_chars"],
|
||||||
|
if truncated {
|
||||||
|
serde_json::Value::Null
|
||||||
|
} else {
|
||||||
|
json!(payload.chars().count())
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prefix_payload_stops_pretty_debug_serialization_at_the_budget() {
|
||||||
|
use std::cell::Cell;
|
||||||
|
|
||||||
|
struct LargeDebug<'a>(&'a Cell<usize>);
|
||||||
|
impl std::fmt::Debug for LargeDebug<'_> {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
for _ in 0..1_000_000 {
|
||||||
|
self.0.set(self.0.get() + 1);
|
||||||
|
formatter.write_str("large archive entry ")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
struct DebugPayload<'a>(LargeDebug<'a>);
|
||||||
|
impl serde::Serialize for DebugPayload<'_> {
|
||||||
|
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
serializer.collect_str(&format_args!("{:#?}", self.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let visited = Cell::new(0);
|
||||||
|
let actual = super::prefix_bounded_payload_context(64, |writer| {
|
||||||
|
serde_json::to_writer(writer, &DebugPayload(LargeDebug(&visited)))
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(visited.get() < 10);
|
||||||
|
assert_eq!(actual["payload_included_chars"], 64);
|
||||||
|
assert_eq!(actual["truncated"], true);
|
||||||
|
assert_eq!(actual["payload_total_chars"], serde_json::Value::Null);
|
||||||
|
assert_eq!(actual["truncation_strategy"], "prefix");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prefix_payload_keeps_real_serialization_errors_visible() {
|
||||||
|
let actual = super::prefix_bounded_payload_context(100, |_writer| {
|
||||||
|
Err(serde_json::Error::io(std::io::Error::other(
|
||||||
|
"test serializer failed",
|
||||||
|
)))
|
||||||
|
});
|
||||||
|
assert!(actual["serialization_error"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("test serializer failed"));
|
||||||
|
assert!(actual.get("payload").is_none());
|
||||||
|
}
|
||||||
|
|||||||
@@ -844,6 +844,11 @@ fn provider_safe_mcp_tool_name(server_id: Option<&str>, tool_name: &str) -> Stri
|
|||||||
format!("{prefix}{component}{separator}{hash}")
|
format!("{prefix}{component}{separator}{hash}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn deduplicate_context_entries<T: Clone + Eq + std::hash::Hash>(entries: &mut Vec<T>) {
|
||||||
|
let mut seen = HashSet::with_capacity(entries.len());
|
||||||
|
entries.retain(|entry| seen.insert(entry.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
fn build_system_prompt(
|
fn build_system_prompt(
|
||||||
inputs: &[AIAgentInput],
|
inputs: &[AIAgentInput],
|
||||||
tools: &[ToolDefinition],
|
tools: &[ToolDefinition],
|
||||||
@@ -988,6 +993,13 @@ fn build_system_prompt(
|
|||||||
AIAgentContext::Image(_) => {}
|
AIAgentContext::Image(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Grouped inputs, such as background-job completions, can share the same context.
|
||||||
|
// Emit each exact context entry once, retaining its first position and all distinct
|
||||||
|
// content. Conversational inputs and tool results remain separate and unchanged.
|
||||||
|
deduplicate_context_entries(&mut environment);
|
||||||
|
deduplicate_context_entries(&mut project_rules);
|
||||||
|
deduplicate_context_entries(&mut attached_context);
|
||||||
|
deduplicate_context_entries(&mut available_skills);
|
||||||
if !environment.is_empty() {
|
if !environment.is_empty() {
|
||||||
prompt.push_str("## Environment\n");
|
prompt.push_str("## Environment\n");
|
||||||
for item in environment {
|
for item in environment {
|
||||||
|
|||||||
@@ -81,6 +81,177 @@ fn native_context_reaches_rig_without_a_proto_context_conversion() {
|
|||||||
assert!(prompt.contains("Indexed codebase: galaxy (/repo)"));
|
assert!(prompt.contains("Indexed codebase: galaxy (/repo)"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn shared_prompt_context(
|
||||||
|
directory: &str,
|
||||||
|
rule: &str,
|
||||||
|
attachment: &str,
|
||||||
|
skill_description: &str,
|
||||||
|
) -> Vec<AIAgentContext> {
|
||||||
|
vec![
|
||||||
|
AIAgentContext::Directory {
|
||||||
|
pwd: Some(directory.to_string()),
|
||||||
|
home_dir: Some("/home/user".to_string()),
|
||||||
|
are_file_symbols_indexed: false,
|
||||||
|
},
|
||||||
|
AIAgentContext::ProjectRules {
|
||||||
|
root_path: "/repo".to_string(),
|
||||||
|
active_rules: vec![FileContext::new(
|
||||||
|
"/repo/AGENTS.md".to_string(),
|
||||||
|
AnyFileContent::StringContent(rule.to_string()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)],
|
||||||
|
additional_rule_paths: Vec::new(),
|
||||||
|
},
|
||||||
|
AIAgentContext::File(FileContext::new(
|
||||||
|
"/repo/context.txt".to_string(),
|
||||||
|
AnyFileContent::StringContent(attachment.to_string()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)),
|
||||||
|
AIAgentContext::Skills {
|
||||||
|
skills: vec![SkillDescriptor {
|
||||||
|
reference: SkillReference::Path(LocalOrRemotePath::Local(PathBuf::from(
|
||||||
|
"/repo/.agents/skills/context-probe/SKILL.md",
|
||||||
|
))),
|
||||||
|
name: "context-probe".to_string(),
|
||||||
|
description: skill_description.to_string(),
|
||||||
|
scope: SkillScope::Project,
|
||||||
|
provider: SkillProvider::Agents,
|
||||||
|
icon_override: None,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grouped_completions_share_context_without_dropping_inputs_or_tool_results() {
|
||||||
|
let context = shared_prompt_context(
|
||||||
|
"/repo",
|
||||||
|
"shared-rule-probe",
|
||||||
|
"shared-attachment-probe",
|
||||||
|
"shared-skill-description-probe",
|
||||||
|
);
|
||||||
|
let mut params = RequestParams::new_for_test();
|
||||||
|
params.input = (0..4)
|
||||||
|
.map(|index| AIAgentInput::CommandCompletionAssessment {
|
||||||
|
prompt: format!("Completion evidence for job {index}"),
|
||||||
|
context: Arc::from(context.clone()),
|
||||||
|
completed_command: crate::ai::agent::RunningCommand {
|
||||||
|
command: format!("command-{index}"),
|
||||||
|
block_id: format!("background-job:{index}").into(),
|
||||||
|
grid_contents: String::new(),
|
||||||
|
cursor: String::new(),
|
||||||
|
requested_command_id: None,
|
||||||
|
is_alt_screen_active: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// Repeated user restrictions remain distinct conversational inputs.
|
||||||
|
params.input.extend([
|
||||||
|
user_query_with_context("Do not deploy", context.clone()),
|
||||||
|
user_query_with_context("Do not deploy", context),
|
||||||
|
]);
|
||||||
|
params.message_history = vec![galaxy_agent_core::ConversationMessage {
|
||||||
|
role: MessageRole::Assistant,
|
||||||
|
content: MessageContent::ToolUse {
|
||||||
|
tool_use_id: "retained-call".to_string(),
|
||||||
|
name: "read_files".to_string(),
|
||||||
|
input: serde_json::json!({ "files": ["/repo/file.rs"] }),
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
params.tool_results = vec![ToolResult {
|
||||||
|
call_id: "retained-call".to_string(),
|
||||||
|
content: "retained-tool-result-probe".to_string(),
|
||||||
|
status: ToolResultStatus::Success,
|
||||||
|
images: Vec::new(),
|
||||||
|
}];
|
||||||
|
|
||||||
|
let prepared = prepare_rig_turn(
|
||||||
|
&config(),
|
||||||
|
params,
|
||||||
|
vec![ToolType::ReadFiles, ToolType::ReadSkill],
|
||||||
|
Vec::new(),
|
||||||
|
);
|
||||||
|
let prompt = prepared.request.system_prompt.expect("system prompt");
|
||||||
|
for entry in [
|
||||||
|
"Working directory: /repo",
|
||||||
|
"Home directory: /home/user",
|
||||||
|
"shared-rule-probe",
|
||||||
|
"shared-attachment-probe",
|
||||||
|
"shared-skill-description-probe",
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
prompt.matches(entry).count(),
|
||||||
|
1,
|
||||||
|
"repeated context: {entry}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for index in 0..4 {
|
||||||
|
assert!(prepared.request.messages.iter().any(|message| {
|
||||||
|
matches!(&message.content, MessageContent::Text(text)
|
||||||
|
if text == &format!("Completion evidence for job {index}"))
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
prepared
|
||||||
|
.request
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.filter(|message| {
|
||||||
|
matches!(&message.content, MessageContent::Text(text) if text == "Do not deploy")
|
||||||
|
})
|
||||||
|
.count(),
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
assert!(prepared.request.messages.iter().any(|message| {
|
||||||
|
matches!(&message.content, MessageContent::ToolResult { tool_use_id, content, .. }
|
||||||
|
if tool_use_id == "retained-call" && content == "retained-tool-result-probe")
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn distinct_context_content_keeps_its_first_order_even_with_shared_paths() {
|
||||||
|
let first = shared_prompt_context(
|
||||||
|
"/repo/first",
|
||||||
|
"first-rule-probe",
|
||||||
|
"first-attachment-probe",
|
||||||
|
"first-skill-description-probe",
|
||||||
|
);
|
||||||
|
let second = shared_prompt_context(
|
||||||
|
"/repo/second",
|
||||||
|
"second-rule-probe",
|
||||||
|
"second-attachment-probe",
|
||||||
|
"second-skill-description-probe",
|
||||||
|
);
|
||||||
|
let mut params = RequestParams::new_for_test();
|
||||||
|
params.input = vec![
|
||||||
|
user_query_with_context("First instruction", first.clone()),
|
||||||
|
user_query_with_context("Second instruction", second),
|
||||||
|
user_query_with_context("Third instruction", first),
|
||||||
|
];
|
||||||
|
|
||||||
|
let prepared = prepare_rig_turn(&config(), params, vec![ToolType::ReadSkill], Vec::new());
|
||||||
|
let prompt = prepared.request.system_prompt.expect("system prompt");
|
||||||
|
for (first, second) in [
|
||||||
|
(
|
||||||
|
"Working directory: /repo/first",
|
||||||
|
"Working directory: /repo/second",
|
||||||
|
),
|
||||||
|
("first-rule-probe", "second-rule-probe"),
|
||||||
|
("first-attachment-probe", "second-attachment-probe"),
|
||||||
|
(
|
||||||
|
"first-skill-description-probe",
|
||||||
|
"second-skill-description-probe",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert_eq!(prompt.matches(first).count(), 1);
|
||||||
|
assert_eq!(prompt.matches(second).count(), 1);
|
||||||
|
assert!(prompt.find(first).unwrap() < prompt.find(second).unwrap());
|
||||||
|
}
|
||||||
|
assert_eq!(prepared.request.messages.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn volatile_request_time_follows_the_cacheable_system_prompt_prefix() {
|
fn volatile_request_time_follows_the_cacheable_system_prompt_prefix() {
|
||||||
let mut params = RequestParams::new_for_test();
|
let mut params = RequestParams::new_for_test();
|
||||||
|
|||||||
@@ -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