Compare commits

...
2 Commits
Author SHA1 Message Date
Ryan Ward 62df16c605 Reduce repeated request context and diagnostic formatting work
Deduplicate exact shared context without dropping distinct instructions or tool results. Stop raw request diagnostic serialization at the configured prefix limit and mark truncated sizes as unknown.
2026-11-03 16:20:18 +00:00
Ryan Ward be5433177d 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.
2026-11-03 16:20:13 +00:00
9 changed files with 897 additions and 43 deletions
+3 -1
View File
@@ -107,6 +107,8 @@ pub struct BackgroundJobLaunch {
pub command: String,
pub working_directory: PathBuf,
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 host_id: Option<HostId>,
}
@@ -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}"))?;
+298 -34
View File
@@ -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<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 {
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<Command
Some(ShellLaunchData::DockerSandbox { .. }) => 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
let mut command = Command::new_with_process_group(&program);
#[cfg(windows)]
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 {
Some(ShellType::Bash) => {
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<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.stdin(command::Stdio::null());
// 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);
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;
+229
View File
@@ -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"))]
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
// 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 map = serializer.serialize_map(None)?;
map.serialize_entry("model_id", &params.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", &params.computer_use_model.as_str())?;
map.serialize_entry("input", &DebugPayload(&params.input))?;
map.serialize_entry("tool_results", &DebugPayload(&params.tool_results))?;
map.serialize_entry("tasks", &DebugPayload(&params.tasks))?;
map.serialize_entry("message_history", &params.message_history)?;
map.serialize_entry("progressive_summary", &params.progressive_summary)?;
map.serialize_entry("tool_result_archive", &params.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", &params.global_rules)?;
map.serialize_entry("mcp_context", &DebugPayload(&params.mcp_context))?;
map.serialize_entry("autonomy_level", &DebugPayload(&params.autonomy_level))?;
@@ -1115,7 +1130,9 @@ fn raw_model_request_payload(params: &api::RequestParams, max_chars: usize) -> s
"omitted_sensitive_fields",
&["api_keys", "custom_model_providers", "custom_model_routers"],
)?;
map.end()
map.serialize_entry("tasks", &DebugPayload(&params.tasks))?;
map.serialize_entry("tool_result_archive", &params.tool_result_archive)?;
SerializeMap::end(map)
})
}
+83
View File
@@ -311,6 +311,89 @@ fn truncate_chars(value: &str, max_chars: usize) -> String {
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
/// request and its archive as a JSON Value and then another large String.
pub(crate) fn bounded_payload_context(
+76
View File
@@ -64,3 +64,79 @@ fn streamed_json_matches_serialized_payload_without_materializing_the_archive()
});
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());
}
+12
View File
@@ -844,6 +844,11 @@ fn provider_safe_mcp_tool_name(server_id: Option<&str>, tool_name: &str) -> Stri
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(
inputs: &[AIAgentInput],
tools: &[ToolDefinition],
@@ -988,6 +993,13 @@ fn build_system_prompt(
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() {
prompt.push_str("## Environment\n");
for item in environment {
+171
View File
@@ -81,6 +81,177 @@ fn native_context_reaches_rig_without_a_proto_context_conversion() {
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]
fn volatile_request_time_follows_the_cacheable_system_prompt_prefix() {
let mut params = RequestParams::new_for_test();
@@ -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,