use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use serde_json::json;
use crate::context::AppContext;
use crate::protocol::{RawRequest, Response, ERROR_PERMISSION_REQUIRED};
const DEFAULT_PTY_ROWS: u16 = 24;
const DEFAULT_PTY_COLS: u16 = 80;
const MAX_PTY_ROWS: u16 = 60;
const MAX_PTY_COLS: u16 = 140;
const BLOCKED_ENV_VARS: &[&str] = &[
"LD_PRELOAD",
"LD_LIBRARY_PATH",
"LD_AUDIT",
"DYLD_INSERT_LIBRARIES",
"DYLD_LIBRARY_PATH",
"DYLD_FALLBACK_LIBRARY_PATH",
"BASH_ENV",
"ENV",
"IFS",
"PATH",
];
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "lowercase")]
enum BashSandbox {
Host,
}
#[derive(Debug, Deserialize)]
struct BashParams {
command: String,
#[serde(default)]
timeout: Option<u64>,
#[serde(default)]
workdir: Option<PathBuf>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
background: bool,
#[serde(default)]
wait: bool,
#[serde(default)]
pty: bool,
#[serde(default)]
pty_rows: Option<u16>,
#[serde(default)]
pty_cols: Option<u16>,
#[serde(default = "default_notify_on_completion")]
notify_on_completion: bool,
#[serde(default = "default_compressed")]
compressed: bool,
#[serde(default)]
sandbox: Option<BashSandbox>,
#[serde(default)]
permissions_granted: Vec<String>,
#[serde(default)]
permissions_requested: bool,
#[serde(default)]
env: HashMap<String, String>,
}
pub fn handle(req: &RawRequest, ctx: &AppContext) -> Response {
let raw_params = req
.params
.get("params")
.cloned()
.unwrap_or_else(|| req.params.clone());
let params = match serde_json::from_value::<BashParams>(raw_params) {
Ok(params) => params,
Err(e) => {
return Response::error(
&req.id,
"invalid_request",
format!("bash: invalid params: {e}"),
);
}
};
if let Some(description) = params.description.as_deref() {
log::debug!("bash description: {description}");
}
if params.wait && params.pty {
return Response::error(
&req.id,
"invalid_request",
"bash: wait:true cannot be used with pty:true because PTY sessions run in background",
);
}
if params.wait && params.background {
return Response::error(
&req.id,
"invalid_request",
"bash: wait:true cannot be used with background:true",
);
}
if let Err(message) = validate_pty_dimensions(params.pty_rows, params.pty_cols) {
return Response::error(&req.id, "invalid_request", message);
}
if let Some(blocked) = blocked_env_var(¶ms.env) {
return Response::error(
&req.id,
"blocked_env_var",
format!("bash env contains blocked variable: {blocked}"),
);
}
let workdir = params
.workdir
.clone()
.unwrap_or_else(|| default_workdir(ctx));
let principal = crate::sandbox_spawn::current_authenticated_principal();
let host_requested = matches!(params.sandbox, Some(BashSandbox::Host));
if host_requested && !crate::sandbox_spawn::principal_is_first_party(&principal) {
return Response::error(
&req.id,
"sandbox_escalation_denied",
"sandbox host escalation is unavailable to untrusted principals",
);
}
#[cfg(unix)]
let mut spawn_workdir = params.workdir.clone();
#[cfg(not(unix))]
let spawn_workdir = params.workdir.clone();
#[cfg(unix)]
let mut host_escalation: Option<crate::sandbox_spawn::HostEscalationAttempt> = None;
#[cfg(not(unix))]
let host_escalation: Option<crate::sandbox_spawn::HostEscalationAttempt> = None;
#[cfg(unix)]
if host_requested && ctx.config().sandbox.enabled {
let configured_root = ctx
.config()
.project_root
.clone()
.unwrap_or_else(|| default_workdir(ctx));
let root = match std::fs::canonicalize(&configured_root) {
Ok(root) => root,
Err(error) => {
return Response::error(
&req.id,
"sandbox_escalation_denied",
format!("failed to canonicalize sandbox escalation root: {error}"),
);
}
};
let candidate_cwd = if workdir.is_absolute() {
workdir.clone()
} else {
root.join(&workdir)
};
let cwd = match std::fs::canonicalize(&candidate_cwd) {
Ok(cwd) => cwd,
Err(error) => {
return Response::error(
&req.id,
"sandbox_escalation_denied",
format!("failed to canonicalize sandbox escalation cwd: {error}"),
);
}
};
let shell_path = crate::bash_background::resolved_shell_path(params.pty);
let shell_path = std::fs::canonicalize(&shell_path).unwrap_or(shell_path);
let environment = crate::sandbox_spawn::capture_child_environment(¶ms.env);
if let Some(grant_id) = params
.permissions_granted
.iter()
.find(|grant| grant.starts_with("esc_"))
{
host_escalation = Some(crate::sandbox_spawn::HostEscalationAttempt {
grant_id: grant_id.clone(),
command: params.command.as_bytes().to_vec(),
root,
cwd: cwd.clone(),
shell_path,
environment,
});
spawn_workdir = Some(cwd);
} else {
let grant_id = match crate::sandbox_spawn::mint_host_escalation_grant(
ctx,
&principal,
params.command.as_bytes(),
&root,
&cwd,
&shell_path,
&environment,
) {
Ok(grant_id) => grant_id,
Err(error) => {
return Response::error(&req.id, "sandbox_escalation_denied", error);
}
};
return Response::error_with_data(
&req.id,
ERROR_PERMISSION_REQUIRED,
"bash command requires host escalation approval",
json!({
"asks": [{
"kind": "escalation",
"command": params.command,
"cwd": cwd,
"grant_id": grant_id,
}]
}),
);
}
}
let native_report_only =
crate::sandbox_spawn::native_sandbox_enforced(ctx, &principal) && host_escalation.is_none();
let permission_asks =
if native_report_only || params.permissions_requested || ctx.config().bash_permissions {
crate::bash_permissions::scan::scan_with_cwd(¶ms.command, ctx, &workdir)
} else {
Vec::new()
};
if !native_report_only
&& !permission_asks.is_empty()
&& !permissions_granted_cover(&permission_asks, ¶ms.permissions_granted)
{
return Response::error_with_data(
&req.id,
ERROR_PERMISSION_REQUIRED,
"bash command requires permission",
json!({ "asks": permission_asks }),
);
}
if host_escalation.is_none() && workdir_matches_project_root(&workdir, ctx) {
if let Some(mut response) = crate::bash_rewrite::try_rewrite(
¶ms.command,
req.session_id.as_deref(),
ctx,
&principal,
) {
response.id = req.id.clone();
return response;
}
}
let workdir = spawn_workdir;
let env = (!params.env.is_empty()).then_some(params.env.clone());
let effective_background = params.background || params.pty;
let pty_rows = params
.pty_rows
.filter(|v| *v > 0)
.unwrap_or(DEFAULT_PTY_ROWS);
let pty_cols = params
.pty_cols
.filter(|v| *v > 0)
.unwrap_or(DEFAULT_PTY_COLS);
let scanner_report = native_report_only
.then_some(permission_asks)
.unwrap_or_default();
crate::bash_background::spawn(
&req.id,
req.session(),
¶ms.command,
workdir,
env,
params.timeout,
ctx,
effective_background,
params.notify_on_completion,
params.compressed,
params.pty,
pty_rows,
pty_cols,
scanner_report,
host_escalation,
)
}
fn validate_pty_dimensions(rows: Option<u16>, cols: Option<u16>) -> Result<(), &'static str> {
if rows.is_some_and(|value| value > MAX_PTY_ROWS) {
return Err("ptyRows must be an integer between 1 and 60");
}
if cols.is_some_and(|value| value > MAX_PTY_COLS) {
return Err("ptyCols must be an integer between 1 and 140");
}
Ok(())
}
fn blocked_env_var(env: &HashMap<String, String>) -> Option<&str> {
env.keys()
.find(|key| {
BLOCKED_ENV_VARS.iter().any(|blocked| {
#[cfg(windows)]
{
key.eq_ignore_ascii_case(blocked)
}
#[cfg(not(windows))]
{
key.as_str() == *blocked
}
})
})
.map(String::as_str)
}
fn permissions_granted_cover(
asks: &[crate::bash_permissions::PermissionAsk],
granted: &[String],
) -> bool {
if asks.is_empty() {
return true;
}
if granted.is_empty() {
return false;
}
asks.iter().all(|ask| {
ask.patterns
.iter()
.chain(ask.always.iter())
.any(|pattern| granted.iter().any(|grant| grant == pattern))
})
}
fn default_compressed() -> bool {
true
}
fn default_notify_on_completion() -> bool {
true
}
fn workdir_matches_project_root(workdir: &Path, ctx: &AppContext) -> bool {
let Some(root) = ctx.config().project_root.clone() else {
return workdir == default_workdir(ctx);
};
let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
canon(workdir) == canon(&root) || workdir == root
}
fn default_workdir(ctx: &AppContext) -> PathBuf {
if let Some(root) = ctx.config().project_root.clone() {
return root;
}
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
#[cfg(test)]
fn try_spawn_with_fallback<C, F>(
candidates: &[crate::windows_shell::WindowsShell],
mut try_one: F,
) -> Result<C, String>
where
F: FnMut(&crate::windows_shell::WindowsShell) -> std::io::Result<C>,
{
let mut last_error: Option<String> = None;
for (idx, shell) in candidates.iter().enumerate() {
match try_one(shell) {
Ok(child) => {
if idx > 0 {
crate::slog_warn!(
"bash spawn fell back to {} after {} earlier candidate(s) failed; \
the cached PATH probe disagreed with runtime spawn — likely PATH \
inheritance, antivirus / AppLocker / Defender ASR, or sandbox policy.",
shell.binary(),
idx
);
}
return Ok(child);
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
crate::slog_warn!(
"bash spawn: {} returned NotFound at runtime — trying next candidate",
shell.binary()
);
last_error = Some(format!("{}: {e}", shell.binary()));
continue;
}
Err(e) => {
return Err(format!(
"failed to spawn bash command via {}: {e}",
shell.binary()
));
}
}
}
Err(format!(
"failed to spawn bash command: no Windows shell could be spawned. \
Last error: {}. PATH-probed candidates: {:?}",
last_error.unwrap_or_else(|| "no candidates were attempted".to_string()),
candidates.iter().map(|s| s.binary()).collect::<Vec<_>>()
))
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(windows)]
use crate::windows_shell::WindowsShell;
fn ctx_with_root(root: &std::path::Path) -> AppContext {
AppContext::new(
Box::new(crate::parser::TreeSitterProvider::new()),
crate::config::Config {
project_root: Some(root.to_path_buf()),
..crate::config::Config::default()
},
)
}
#[test]
fn workdir_gate_matches_root_and_rejects_subdir() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let sub = root.join("subdir");
std::fs::create_dir_all(&sub).unwrap();
let ctx = ctx_with_root(root);
assert!(workdir_matches_project_root(root, &ctx));
assert!(workdir_matches_project_root(
&std::fs::canonicalize(root).unwrap(),
&ctx
));
assert!(!workdir_matches_project_root(&sub, &ctx));
}
#[cfg(windows)]
#[test]
fn windows_shell_args_match_each_shells_invocation_contract() {
let cmd = "echo hello";
let pwsh_args = WindowsShell::Pwsh.args(cmd);
assert!(
pwsh_args.contains(&"-Command"),
"pwsh args missing -Command: {pwsh_args:?}"
);
assert!(pwsh_args.contains(&cmd), "pwsh args missing command body");
assert!(
pwsh_args.contains(&"-NonInteractive"),
"pwsh args missing -NonInteractive (would hang on prompts)"
);
let ps_args = WindowsShell::Powershell.args(cmd);
assert_eq!(
pwsh_args, ps_args,
"pwsh and powershell share the same arg set"
);
let cmd_args = WindowsShell::Cmd.args(cmd);
assert_eq!(
cmd_args,
vec!["/D", "/C", cmd],
"cmd.exe must use /D /C contract"
);
assert!(
!cmd_args.contains(&"-Command"),
"cmd args must not leak PowerShell flags: {cmd_args:?}"
);
}
#[cfg(windows)]
#[test]
fn windows_shell_binary_names_have_exe_suffix() {
assert_eq!(WindowsShell::Pwsh.binary(), "pwsh.exe");
assert_eq!(WindowsShell::Powershell.binary(), "powershell.exe");
assert_eq!(WindowsShell::Cmd.binary(), "cmd.exe");
}
#[test]
fn try_spawn_with_fallback_retries_on_notfound_until_success() {
use crate::windows_shell::WindowsShell;
use std::cell::RefCell;
use std::io::{Error, ErrorKind};
let candidates = [
WindowsShell::Pwsh,
WindowsShell::Powershell,
WindowsShell::Cmd,
];
let attempts: RefCell<Vec<WindowsShell>> = RefCell::new(Vec::new());
let result: Result<&'static str, String> = try_spawn_with_fallback(&candidates, |shell| {
attempts.borrow_mut().push(shell.clone());
match shell {
WindowsShell::Pwsh | WindowsShell::Powershell => {
Err(Error::new(ErrorKind::NotFound, "blocked"))
}
WindowsShell::Cmd => Ok("ok-from-cmd"),
WindowsShell::Posix(_) => unreachable!("test fixture has no Posix shell"),
}
});
assert_eq!(result, Ok("ok-from-cmd"));
assert_eq!(
attempts.into_inner(),
vec![
WindowsShell::Pwsh,
WindowsShell::Powershell,
WindowsShell::Cmd,
],
"retry loop must walk candidates in order until one succeeds"
);
}
#[test]
fn try_spawn_with_fallback_stops_at_first_success() {
use crate::windows_shell::WindowsShell;
use std::cell::RefCell;
let candidates = [
WindowsShell::Pwsh,
WindowsShell::Powershell,
WindowsShell::Cmd,
];
let attempts: RefCell<usize> = RefCell::new(0);
let result: Result<u32, String> = try_spawn_with_fallback(&candidates, |_shell| {
*attempts.borrow_mut() += 1;
Ok(42)
});
assert_eq!(result, Ok(42));
assert_eq!(
attempts.into_inner(),
1,
"first success must short-circuit; later candidates not attempted"
);
}
#[test]
fn try_spawn_with_fallback_returns_immediately_on_non_notfound_error() {
use crate::windows_shell::WindowsShell;
use std::cell::RefCell;
use std::io::{Error, ErrorKind};
let candidates = [
WindowsShell::Pwsh,
WindowsShell::Powershell,
WindowsShell::Cmd,
];
let attempts: RefCell<Vec<WindowsShell>> = RefCell::new(Vec::new());
let result: Result<&'static str, String> = try_spawn_with_fallback(&candidates, |shell| {
attempts.borrow_mut().push(shell.clone());
Err(Error::new(ErrorKind::PermissionDenied, "denied by ACL"))
});
assert!(result.is_err(), "PermissionDenied must error out");
let err = result.unwrap_err();
assert!(
err.contains("pwsh.exe"),
"error must name the failing shell: {err}"
);
assert!(
err.contains("denied by ACL"),
"error must include underlying io error: {err}"
);
assert_eq!(
attempts.into_inner(),
vec![WindowsShell::Pwsh],
"non-NotFound must NOT retry with later candidates"
);
}
#[test]
fn try_spawn_with_fallback_reports_all_candidates_when_none_succeed() {
use crate::windows_shell::WindowsShell;
use std::io::{Error, ErrorKind};
let candidates = [WindowsShell::Pwsh, WindowsShell::Cmd];
let result: Result<&'static str, String> = try_spawn_with_fallback(&candidates, |_shell| {
Err(Error::new(ErrorKind::NotFound, "no shell"))
});
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.contains("pwsh.exe"),
"error must list pwsh.exe candidate: {err}"
);
assert!(
err.contains("cmd.exe"),
"error must list cmd.exe candidate: {err}"
);
assert!(
err.contains("no Windows shell could be spawned"),
"error message must indicate exhaustion: {err}"
);
}
#[test]
fn try_spawn_with_fallback_handles_empty_candidates_list() {
use crate::windows_shell::WindowsShell;
let candidates: [WindowsShell; 0] = [];
let result: Result<&'static str, String> = try_spawn_with_fallback(&candidates, |_shell| {
panic!("try_one must not be called for empty candidates")
});
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.contains("no candidates were attempted"),
"empty list must report no-attempt error: {err}"
);
}
fn spawn_test_context(project_root: &Path, storage_dir: &Path) -> AppContext {
AppContext::new(
Box::new(crate::parser::TreeSitterProvider::new()),
crate::config::Config {
project_root: Some(project_root.to_path_buf()),
storage_dir: Some(storage_dir.to_path_buf()),
experimental_bash_background: true,
..crate::config::Config::default()
},
)
}
fn spawn_test_request(id: &str, command: &str, background: bool) -> RawRequest {
serde_json::from_value(json!({
"id": id,
"command": "bash",
"session_id": "sandbox-spawn-test",
"params": {
"command": command,
"background": background,
"compressed": false,
},
}))
.unwrap()
}
fn stop_spawned_test_task(ctx: &AppContext, response: &Response) {
if let Some(task_id) = response
.data
.get("task_id")
.and_then(serde_json::Value::as_str)
{
let _ = ctx.bash_background().kill(task_id, "sandbox-spawn-test");
}
}
#[cfg(unix)]
#[test]
fn host_request_returns_exact_escalation_ask_and_untrusted_request_is_denied() {
let project = tempfile::tempdir().unwrap();
let storage = tempfile::tempdir().unwrap();
let ctx = spawn_test_context(project.path(), storage.path());
ctx.update_config(|config| config.sandbox.enabled = true);
crate::sandbox_spawn::install_sandbox_spawn_test_seam(project.path().to_path_buf());
let mut request = spawn_test_request("host-ask", "printf 'exact value'", false);
request.params["params"]["sandbox"] = json!("host");
let response = handle(&request, &ctx);
assert!(!response.success);
assert_eq!(
response
.data
.get("code")
.and_then(serde_json::Value::as_str),
Some(ERROR_PERMISSION_REQUIRED)
);
let ask = &response.data["asks"][0];
assert_eq!(ask["kind"], "escalation");
assert_eq!(ask["command"], "printf 'exact value'");
let canonical_project = project.path().canonicalize().unwrap();
assert_eq!(ask["cwd"].as_str(), canonical_project.to_str());
let grant_id = ask["grant_id"].as_str().unwrap().to_string();
assert!(grant_id.starts_with("esc_"));
let mut changed = spawn_test_request("host-changed", "printf 'exact valuE'", false);
changed.params["params"]["sandbox"] = json!("host");
changed.params["params"]["permissions_granted"] = json!([grant_id]);
let changed_response = handle(&changed, &ctx);
assert!(!changed_response.success);
assert_eq!(changed_response.data["code"], "sandbox_escalation_denied");
assert_eq!(changed_response.data["mismatch_class"], "digest_mismatch");
assert_eq!(
ctx.bash_background().try_health_counts().unwrap().running,
0
);
let mut approved = spawn_test_request("host-approved", "printf 'exact value'", false);
approved.params["params"]["sandbox"] = json!("host");
approved.params["params"]["permissions_granted"] = json!([grant_id]);
let invalidated_response = handle(&approved, &ctx);
assert!(!invalidated_response.success);
assert_eq!(invalidated_response.data["mismatch_class"], "consumed");
approved.params["params"]
.as_object_mut()
.unwrap()
.remove("permissions_granted");
let remint_response = handle(&approved, &ctx);
assert!(!remint_response.success);
assert_eq!(remint_response.data["code"], ERROR_PERMISSION_REQUIRED);
let fresh_grant = remint_response.data["asks"][0]["grant_id"]
.as_str()
.unwrap()
.to_string();
approved.params["params"]["permissions_granted"] = json!([fresh_grant]);
let approved_response = handle(&approved, &ctx);
assert!(approved_response.success, "{:#?}", approved_response.data);
assert_eq!(
crate::sandbox_spawn::sandbox_spawn_test_observations(project.path())
.last()
.map(|observation| observation.requested_tier),
Some(crate::sandbox_spawn::RequestedSandboxTier::Host)
);
stop_spawned_test_task(&ctx, &approved_response);
let consumed_response = handle(&approved, &ctx);
assert!(!consumed_response.success);
assert_eq!(consumed_response.data["mismatch_class"], "consumed");
let principal = crate::sandbox_spawn::AuthenticatedPrincipal::RouteBind {
trust: crate::sandbox_spawn::PrincipalTrust::Untrusted,
route_channel: 4,
route_epoch: 2,
project_root: project.path().to_path_buf(),
harness: "mcp:test".to_string(),
session_id: "mcp-session".to_string(),
principal_id: Some("unverified".to_string()),
};
let denied = crate::sandbox_spawn::with_authenticated_principal(principal, || {
handle(&request, &ctx)
});
assert!(!denied.success);
assert_eq!(denied.data["code"], "sandbox_escalation_denied");
assert_eq!(ctx.escalation_grants().lock().len_for_test(), 2);
crate::sandbox_spawn::clear_sandbox_spawn_test_seam(project.path());
}
#[test]
fn foreground_and_background_bash_both_resolve_a_spawn_plan() {
use crate::sandbox_spawn::{
clear_sandbox_spawn_test_seam, install_sandbox_spawn_test_seam,
sandbox_spawn_test_observations, AuthenticatedPrincipal, RequestedSandboxTier,
SandboxTaskKind,
};
let project = tempfile::tempdir().unwrap();
let storage = tempfile::tempdir().unwrap();
let ctx = spawn_test_context(project.path(), storage.path());
install_sandbox_spawn_test_seam(project.path().to_path_buf());
let foreground = handle(
&spawn_test_request("sandbox-foreground", "echo foreground", false),
&ctx,
);
assert!(
foreground.success,
"foreground spawn failed: {foreground:?}"
);
let background = handle(
&spawn_test_request("sandbox-background", "echo background", true),
&ctx,
);
assert!(
background.success,
"background spawn failed: {background:?}"
);
let observations = sandbox_spawn_test_observations(project.path());
clear_sandbox_spawn_test_seam(project.path());
assert_eq!(observations.len(), 2);
assert_eq!(
observations
.iter()
.map(|observation| observation.task_kind)
.collect::<Vec<_>>(),
vec![
SandboxTaskKind::BashForeground,
SandboxTaskKind::BashBackground,
]
);
assert!(observations.iter().all(|observation| {
observation.principal == AuthenticatedPrincipal::FirstParty
&& observation.requested_tier == RequestedSandboxTier::Disabled
}));
stop_spawned_test_task(&ctx, &foreground);
stop_spawned_test_task(&ctx, &background);
}
#[test]
fn refused_test_plan_fails_closed_before_process_creation() {
use crate::sandbox_spawn::{with_spawn_plan_for_test, SpawnPlan};
let project = tempfile::tempdir().unwrap();
let storage = tempfile::tempdir().unwrap();
let sentinel = project.path().join("must-not-exist");
let ctx = spawn_test_context(project.path(), storage.path());
let request = spawn_test_request(
"sandbox-refused",
&format!("touch {}", sentinel.display()),
false,
);
let response = with_spawn_plan_for_test(
SpawnPlan::refused_for_test("sandbox_principal_unknown"),
|| handle(&request, &ctx),
);
assert!(!response.success);
assert_eq!(
response
.data
.get("code")
.and_then(serde_json::Value::as_str),
Some("sandbox_principal_unknown")
);
assert!(!sentinel.exists());
}
#[cfg(unix)]
#[test]
fn launcher_test_plan_wraps_the_spawned_command_line() {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::time::{Duration, Instant};
use crate::sandbox_profile::SandboxProfile;
use crate::sandbox_spawn::{with_spawn_plan_for_test, SpawnPlan};
let project = tempfile::tempdir().unwrap();
let storage = tempfile::tempdir().unwrap();
let args_log = project.path().join("launcher-args.txt");
let launcher = project.path().join("fake-sandbox-launcher.sh");
fs::write(
&launcher,
r#"#!/bin/sh
printf '%s\n' "$0" "$@" > "$AFT_TEST_LAUNCH_ARGS.tmp"
mv -f "$AFT_TEST_LAUNCH_ARGS.tmp" "$AFT_TEST_LAUNCH_ARGS"
[ "$1" = "sandbox-launch" ] || exit 91
[ "$2" = "--profile-file" ] || exit 92
[ -f "$3" ] || exit 93
[ "$4" = "--failure-marker" ] || exit 94
[ "$6" = "--" ] || exit 95
rm -f -- "$3"
shift 6
exec "$@"
"#,
)
.unwrap();
let mut permissions = fs::metadata(&launcher).unwrap().permissions();
permissions.set_mode(0o700);
fs::set_permissions(&launcher, permissions).unwrap();
let profile = SandboxProfile::build(
vec![project.path().to_path_buf()],
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
storage.path().to_path_buf(),
)
.unwrap();
let plan = SpawnPlan::launcher_for_test(profile, launcher.clone());
let ctx = spawn_test_context(project.path(), storage.path());
let request: RawRequest = serde_json::from_value(json!({
"id": "sandbox-launcher",
"command": "bash",
"session_id": "sandbox-spawn-test",
"params": {
"command": "printf launcher-wrapped",
"compressed": false,
"env": { "AFT_TEST_LAUNCH_ARGS": args_log.to_string_lossy() },
},
}))
.unwrap();
let response = with_spawn_plan_for_test(plan, || handle(&request, &ctx));
assert!(response.success, "launcher spawn failed: {response:?}");
let started = Instant::now();
while !args_log.exists() {
assert!(
started.elapsed() < Duration::from_secs(5),
"fake launcher did not record its argv"
);
std::thread::sleep(Duration::from_millis(10));
}
let args = fs::read_to_string(&args_log).unwrap();
let lines: Vec<&str> = args.lines().collect();
assert_eq!(lines[0], launcher.to_string_lossy());
assert_eq!(lines[1], "sandbox-launch");
assert_eq!(lines[2], "--profile-file");
assert!(lines[3].ends_with(".sandbox-profile.json"));
assert_eq!(lines[4], "--failure-marker");
assert!(lines[5].ends_with(".sandbox-unavailable"));
assert_eq!(lines[6], "--");
assert!(lines.len() >= 9, "wrapped argv missing target: {lines:?}");
stop_spawned_test_task(&ctx, &response);
}
#[cfg(unix)]
#[test]
fn launcher_exit_78_is_persisted_for_structured_error_mapping() {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::time::{Duration, Instant};
use crate::sandbox_profile::SandboxProfile;
use crate::sandbox_spawn::{with_spawn_plan_for_test, SpawnPlan};
let project = tempfile::tempdir().unwrap();
let storage = tempfile::tempdir().unwrap();
let launcher = project.path().join("unavailable-sandbox-launcher.sh");
fs::write(
&launcher,
"#!/bin/sh\necho 'sandbox_unavailable: test backend failed' >&2\nexit 78\n",
)
.unwrap();
let mut permissions = fs::metadata(&launcher).unwrap().permissions();
permissions.set_mode(0o700);
fs::set_permissions(&launcher, permissions).unwrap();
let profile = SandboxProfile::build(
vec![project.path().to_path_buf()],
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
storage.path().to_path_buf(),
)
.unwrap();
let plan = SpawnPlan::launcher_for_test(profile, launcher);
let ctx = spawn_test_context(project.path(), storage.path());
let request = spawn_test_request("sandbox-unavailable", "echo never-runs", false);
let response = with_spawn_plan_for_test(plan, || handle(&request, &ctx));
assert!(response.success, "launcher spawn failed: {response:?}");
let task_id = response.data["task_id"].as_str().unwrap();
let started = Instant::now();
loop {
let snapshot = ctx
.bash_background()
.status(
task_id,
"sandbox-spawn-test",
Some(project.path()),
Some(storage.path()),
4096,
)
.expect("sandbox task status");
if snapshot.info.status.is_terminal() {
assert_eq!(
snapshot.exit_code,
Some(crate::sandbox_spawn::SANDBOX_UNAVAILABLE_EXIT_CODE)
);
assert!(snapshot.output_preview.contains("sandbox_unavailable:"));
assert!(snapshot.sandbox_unavailable);
break;
}
assert!(
started.elapsed() < Duration::from_secs(5),
"launcher failure was not persisted: {snapshot:?}"
);
std::thread::sleep(Duration::from_millis(10));
}
}
}