#[cfg(any(feature = "aws", feature = "kubernetes"))]
pub mod agent_protocol;
#[cfg(feature = "aws")]
pub mod aws;
#[cfg(feature = "azure")]
pub mod azure;
#[cfg(feature = "gcp")]
pub mod gcp_agent_platform;
#[cfg(feature = "kubernetes")]
pub mod kubernetes;
#[cfg(feature = "local")]
pub mod local;
#[cfg(feature = "aws")]
mod refusal;
#[cfg(all(
test,
feature = "aws",
feature = "azure",
feature = "gcp",
feature = "kubernetes",
feature = "local"
))]
#[path = "jobs_capability_tests.rs"]
mod jobs_capability_tests;
#[cfg(all(
test,
feature = "aws",
feature = "azure",
feature = "gcp",
feature = "kubernetes",
feature = "local"
))]
#[path = "lifetime_capability_tests.rs"]
mod lifetime_capability_tests;
#[cfg(any(feature = "aws", feature = "gcp"))]
pub(crate) fn requested_lifetime_seconds(
timeout_ms: u64,
declared_ceiling: Option<u32>,
operation: &str,
) -> crate::error::Result<u32> {
let refuse = |details: String| {
alien_error::AlienError::new(crate::error::ErrorData::InvalidInput {
operation_context: operation.to_string(),
details,
field_name: Some("timeoutMs".to_string()),
})
};
if timeout_ms == 0 {
return Err(refuse(
"a sandbox lifetime must be at least one millisecond".to_string(),
));
}
let asked = u32::try_from(timeout_ms.div_ceil(1_000)).map_err(|_| {
refuse(format!(
"a sandbox lifetime must be at most {} seconds",
u32::MAX
))
})?;
Ok(match declared_ceiling {
Some(ceiling) => asked.min(ceiling),
None => asked,
})
}
#[cfg(any(feature = "azure", feature = "local"))]
pub(crate) const MAX_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
#[cfg(any(feature = "azure", feature = "local"))]
pub(crate) const TIMEOUT_GRACE: std::time::Duration = std::time::Duration::from_secs(10);
#[cfg(any(feature = "azure", feature = "local"))]
pub(crate) struct TimeoutReport;
#[cfg(any(feature = "azure", feature = "local"))]
const NONCE_HEXITS: usize = 32;
#[cfg(any(feature = "azure", feature = "local"))]
impl TimeoutReport {
pub(crate) fn bounded_program(timeout: std::time::Duration) -> String {
format!(
"unset nonce command_pid killer_pid sleeper status; \
command -v setsid >/dev/null 2>&1 || exit {unboundable}; \
nonce=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \\n') || exit {unboundable}; \
printf '%s\\n' \"$nonce\" >&2; \
setsid \"$@\" & command_pid=$!; \
( sleep {timeout} & sleeper=$!; trap 'kill \"$sleeper\" 2>/dev/null; exit' TERM; \
wait \"$sleeper\"; \
trap '' TERM; kill -KILL -\"$command_pid\" 2>/dev/null && printf %s \"$nonce\" >&2 ) & killer_pid=$!; \
wait \"$command_pid\"; status=$?; \
kill \"$killer_pid\" 2>/dev/null; wait \"$killer_pid\"; \
exit \"$status\"",
unboundable = Self::UNBOUNDABLE_EXIT_CODE,
timeout = timeout_seconds(timeout)
)
}
const UNBOUNDABLE_EXIT_CODE: i32 = 126;
const KILLED_EXIT_CODE: i32 = 137;
pub(crate) fn read(exit_code: Option<i32>, stderr: &str) -> Bounded {
let announced = stderr.split('\n').enumerate().find_map(|(index, line)| {
let line = line.strip_suffix('\r').unwrap_or(line);
let is_nonce =
line.len() == NONCE_HEXITS && line.chars().all(|c| c.is_ascii_hexdigit());
is_nonce.then(|| {
let after = stderr
.split('\n')
.skip(index + 1)
.collect::<Vec<_>>()
.join("\n");
(line, after)
})
});
let Some((nonce, rest)) = announced.as_ref().map(|(n, r)| (*n, r.as_str())) else {
return Bounded::NotRun {
reason: if exit_code == Some(Self::UNBOUNDABLE_EXIT_CODE) {
"the sandbox image cannot hold a command to a timeout: `setsid` and \
`/dev/urandom` are required"
.to_string()
} else {
format!(
"the sandbox could not start a bounded command: {}",
stderr.trim()
)
},
};
};
match rest.find(nonce) {
Some(at) => Bounded::Ran {
killed: exit_code == Some(Self::KILLED_EXIT_CODE),
stderr: format!("{}{}", &rest[..at], &rest[at + nonce.len()..]),
},
None => Bounded::Ran {
killed: false,
stderr: rest.to_string(),
},
}
}
}
#[cfg(any(feature = "azure", feature = "local"))]
pub(crate) enum Bounded {
NotRun { reason: String },
Ran { killed: bool, stderr: String },
}
#[cfg(any(feature = "azure", feature = "local"))]
fn timeout_seconds(timeout: std::time::Duration) -> String {
let millis = timeout.as_millis().max(1);
if millis % 1000 == 0 {
(millis / 1000).to_string()
} else {
format!("{}.{:03}", millis / 1000, millis % 1000)
}
}
#[cfg(any(feature = "azure", feature = "local"))]
pub(crate) fn guard_for(timeout: std::time::Duration) -> crate::error::Result<std::time::Duration> {
let refuse = |reason: &str| {
alien_error::AlienError::new(crate::error::ErrorData::SandboxCommandFailed {
failure: "invalidRequest".to_string(),
reason: reason.to_string(),
})
};
if timeout < std::time::Duration::from_millis(1) {
return Err(refuse("a command timeout must be at least one millisecond"));
}
if timeout > MAX_TIMEOUT {
return Err(refuse(&format!(
"a command timeout must be at most {} hours",
MAX_TIMEOUT.as_secs() / 3600
)));
}
Ok(timeout + TIMEOUT_GRACE)
}
#[cfg(all(test, any(feature = "aws", feature = "gcp")))]
mod lifetime_tests {
use super::requested_lifetime_seconds;
#[test]
fn a_requested_lifetime_rounds_up_and_never_raises_the_declared_ceiling() {
let seconds = |timeout_ms, ceiling| {
requested_lifetime_seconds(timeout_ms, ceiling, "sandbox.create")
.expect("a lifetime the backends can serve")
};
assert_eq!(seconds(60_000, None), 60);
assert_eq!(seconds(1_500, None), 2);
assert_eq!(seconds(1, None), 1);
assert_eq!(seconds(600_000, Some(1_800)), 600);
assert_eq!(seconds(7_200_000, Some(1_800)), 1_800);
}
#[test]
fn a_lifetime_no_backend_can_serve_is_refused_rather_than_adjusted() {
for (what, timeout_ms, ceiling) in [
("zero", 0_u64, None),
("zero under a ceiling", 0, Some(1_800)),
("wider than seconds hold", u64::MAX, None),
(
"wider than seconds hold, under a ceiling",
u64::MAX,
Some(1_800),
),
] {
let error = requested_lifetime_seconds(timeout_ms, ceiling, "sandbox.create")
.expect_err("a lifetime no backend can serve");
assert!(
matches!(
&error.error,
Some(crate::error::ErrorData::InvalidInput { field_name, .. })
if field_name.as_deref() == Some("timeoutMs")
),
"{what} has to name the field the caller sent, which is what a client turns into \
a field-level message: {error:?}"
);
assert_eq!(
error.code, "INVALID_INPUT",
"{what} has to be refused as the caller's, not reported as a backend failure it \
could retry: {error}"
);
assert!(
error.to_string().contains("sandbox lifetime"),
"{what} has to say which field is wrong: {error}"
);
}
}
}
#[cfg(all(test, any(feature = "azure", feature = "local")))]
mod tests {
use super::*;
#[test]
fn the_timeout_reaches_the_shell_to_the_millisecond() {
assert_eq!(timeout_seconds(std::time::Duration::from_secs(30)), "30");
assert_eq!(
timeout_seconds(std::time::Duration::from_millis(1500)),
"1.500"
);
assert_eq!(
timeout_seconds(std::time::Duration::from_millis(500)),
"0.500"
);
}
#[test]
fn only_the_sandbox_can_report_a_timeout() {
let killed = match TimeoutReport::read(
Some(137),
"a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4\nboom\na1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4Killed\n",
) {
Bounded::Ran { killed, stderr } => {
assert_eq!(stderr, "boom\nKilled\n");
killed
}
Bounded::NotRun { reason } => panic!("the command ran: {reason}"),
};
assert!(killed);
assert!(matches!(
TimeoutReport::read(
Some(0),
"a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4\nboom\ndeadbeef\n"
),
Bounded::Ran { killed: false, .. }
));
}
#[test]
fn a_sandbox_that_cannot_bound_a_command_runs_nothing() {
let Bounded::NotRun { reason } = TimeoutReport::read(Some(126), "") else {
panic!("an unboundable sandbox must not look like a command that ran");
};
assert!(reason.contains("setsid"), "{reason}");
let Bounded::NotRun { reason } = TimeoutReport::read(Some(127), "sh: not found\n") else {
panic!("stderr with no announcement is not a command that ran");
};
assert!(reason.contains("could not start"), "{reason}");
}
#[test]
fn the_announcement_is_found_by_shape_rather_than_by_position() {
let traced = format!("+ unset nonce command_pid\n+ printf\na1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4\nboom\na1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4Killed\n");
let Bounded::Ran { killed, stderr } = TimeoutReport::read(Some(137), &traced) else {
panic!("the trace must not hide the announcement");
};
assert!(killed, "the killer's repeat still reports the kill");
assert_eq!(
stderr, "boom\nKilled\n",
"what precedes the announcement was written before the command started, so it is the \
sandbox's own noise rather than the command's — and one of those lines is the trace \
of the announcement itself"
);
let crlf = format!("a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4\r\nboom\r\na1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4Killed\r\n");
assert!(
matches!(
TimeoutReport::read(Some(137), &crlf),
Bounded::Ran { killed: true, .. }
),
"a carriage return is not part of the nonce"
);
assert!(
matches!(
TimeoutReport::read(Some(137), "ab\nboom\nabc\n"),
Bounded::NotRun { .. }
),
"a hex fragment is not a nonce"
);
}
#[test]
fn a_command_that_finished_as_the_killer_fired_keeps_its_result() {
let Bounded::Ran { killed, stderr } = TimeoutReport::read(
Some(0),
"a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4\nboom\na1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4",
) else {
panic!("the command ran");
};
assert!(
!killed,
"a command that exited 0 was not ended by the timeout, whatever the signal reached"
);
assert_eq!(stderr, "boom\n", "the announcement is removed either way");
}
#[test]
fn the_bounded_program_kills_and_reports_only_what_it_killed() {
let program = TimeoutReport::bounded_program(std::time::Duration::from_millis(1500));
assert!(program.contains("/dev/urandom"), "{program}");
assert!(program.contains("command -v setsid"), "{program}");
assert!(
program.contains("setsid \"$@\" & command_pid=$!"),
"{program}"
);
assert!(program.contains("sleep 1.500"), "{program}");
assert!(
program.contains("kill -KILL -\"$command_pid\""),
"the kill reaches the command's process group, not one pid: {program}"
);
assert!(
program.contains("2>/dev/null && printf %s \"$nonce\""),
"the repeat follows a signal that was delivered: {program}"
);
assert!(
program.contains("kill \"$killer_pid\" 2>/dev/null; wait \"$killer_pid\""),
"the killer is stopped and then awaited, whatever the command's exit: {program}"
);
assert!(
program.contains(r#"wait "$sleeper"; trap '' TERM; kill -KILL"#),
"past its sleep the killer ignores the stop, so its report is never cut off, and the \
pid is quoted so an inherited IFS cannot split it into words that are not children: \
{program}"
);
assert!(
program.contains(r#"trap 'kill "$sleeper" 2>/dev/null; exit' TERM"#),
"a stopped killer reaps its own sleeper, so none outlives the command: {program}"
);
assert!(
!program.contains("\"$nonce\" &") && program.contains("( sleep"),
"the killer is a subshell: the nonce reaches it as a variable, never as an argument \
the command could read from /proc: {program}"
);
}
#[test]
#[cfg(unix)]
fn the_wrapper_never_hands_its_nonce_to_the_command() {
use std::os::unix::fs::PermissionsExt;
let bin = std::env::temp_dir().join(format!("alien-sandbox-{}", std::process::id()));
std::fs::create_dir_all(&bin).expect("a directory for the stand-in");
let setsid = bin.join("setsid");
std::fs::write(&setsid, "#!/bin/sh\nexec \"$@\"\n").expect("the stand-in is written");
std::fs::set_permissions(&setsid, std::fs::Permissions::from_mode(0o755))
.expect("the stand-in is executable");
let path = format!(
"{}:{}",
bin.display(),
std::env::var("PATH").unwrap_or_default()
);
let run = std::process::Command::new("/bin/sh")
.arg("-c")
.arg(TimeoutReport::bounded_program(
std::time::Duration::from_secs(5),
))
.arg("sh")
.arg("printenv")
.arg("nonce")
.env("PATH", path)
.env("nonce", "inherited-from-the-sandbox")
.output()
.expect("a shell runs");
std::fs::remove_dir_all(&bin).ok();
let announced = String::from_utf8_lossy(&run.stderr);
let announced = announced.lines().next().unwrap_or_default().to_string();
assert!(
announced.len() == 32 && announced.chars().all(|c| c.is_ascii_hexdigit()),
"the sandbox has to reach the point of drawing a nonce, or this proves nothing: \
stderr {:?}",
String::from_utf8_lossy(&run.stderr)
);
let seen = String::from_utf8_lossy(&run.stdout);
assert!(
seen.trim().is_empty(),
"the command must inherit no `nonce` at all, and it saw {seen:?}"
);
}
#[tokio::test]
async fn a_timeout_outside_what_the_backends_can_honour_is_refused() {
let too_fine = guard_for(std::time::Duration::from_micros(500))
.expect_err("half a millisecond cannot be bounded");
assert!(
too_fine.to_string().contains("invalidRequest"),
"{too_fine}"
);
let too_long = guard_for(std::time::Duration::MAX)
.expect_err("a timeout with no representable instant cannot be waited out");
assert!(
too_long.to_string().contains("invalidRequest"),
"{too_long}"
);
assert_eq!(
guard_for(std::time::Duration::from_secs(30)).expect("an ordinary timeout"),
std::time::Duration::from_secs(30) + TIMEOUT_GRACE,
"the guard is the timeout plus the grace"
);
}
}