Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62df16c605 | ||
|
|
be5433177d |
@@ -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}"))?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", ¶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("input", &DebugPayload(¶ms.input))?;
|
||||
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("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("mcp_context", &DebugPayload(¶ms.mcp_context))?;
|
||||
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",
|
||||
&["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
|
||||
}
|
||||
|
||||
/// 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(
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user