pub mod bedrock;
pub mod profile;
use std::time::Duration;
use crate::control::artifact::{BaseImage, default_dockerfile};
use crate::control::{CreateImageRequest, Image};
use crate::error::{Error, ErrorKind};
use crate::region::Region;
use crate::sandbox::{RunRequest, Sandbox, TeardownOpts, TeardownReport};
use crate::session::{ExecHandle, ExecResult, Session};
use crate::sizing::SizeClass;
pub use bedrock::{BearerToken, Minted};
pub use profile::{Agent, AgentPermissionMode, CODEX_CONFIG_FILE, ENV_FILE, MARKER_FILE, Profile};
pub const AGENT_UID: u32 = 1000;
pub const AGENT_GID: u32 = 1000;
pub const WORKDIR: &str = "/workspace";
pub const DEFAULT_SIZE: SizeClass = SizeClass::Mib1024;
pub const DEFAULT_PROMPT_TIMEOUT: Duration = Duration::from_secs(900);
const CHOWN_TIMEOUT: Duration = Duration::from_secs(60);
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AgentSpec {
pub agent: Agent,
pub model: Option<String>,
pub cli_version: Option<String>,
}
impl AgentSpec {
pub fn new(agent: Agent) -> Self {
Self {
agent,
model: None,
cli_version: None,
}
}
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
pub fn with_cli_version(mut self, version: impl Into<String>) -> Self {
self.cli_version = Some(version.into());
self
}
pub fn model(&self) -> &str {
self.model
.as_deref()
.unwrap_or(self.agent.profile().default_model)
}
fn npm_spec(&self) -> String {
match &self.cli_version {
Some(version) => format!("{}@{version}", self.agent.profile().npm_package),
None => self.agent.profile().npm_package.to_string(),
}
}
}
pub fn require_specs(specs: &[AgentSpec]) -> Result<(), Error> {
if specs.is_empty() {
return Err(Error::invalid_arg(
"no agent named. An agent VM installs at least one of: claude-code, codex.",
));
}
for (index, spec) in specs.iter().enumerate() {
if specs[..index]
.iter()
.any(|earlier| earlier.agent == spec.agent)
{
return Err(Error::invalid_arg(format!(
"the agent {} is named twice; one row per agent, because a second row \
would be a second model for the same CLI.",
spec.agent
)));
}
}
Ok(())
}
fn ordered(specs: &[AgentSpec]) -> Vec<&AgentSpec> {
let mut sorted: Vec<&AgentSpec> = specs.iter().collect();
sorted.sort_by_key(|spec| spec.agent);
sorted
}
pub fn spec_for(specs: &[AgentSpec], agent: Agent) -> Result<&AgentSpec, Error> {
specs
.iter()
.find(|spec| spec.agent == agent)
.ok_or_else(|| {
Error::invalid_arg(format!(
"this VM carries {}, not {agent}.",
image_stem(specs).trim_start_matches("agent-vm-")
))
})
}
pub fn image_name_for(
sandbox: &Sandbox,
specs: &[AgentSpec],
request: &CreateImageRequest,
) -> String {
let identity = crate::control::ensure::create_identity_hash(
&sandbox.artifact_content_hash_for(request),
request,
);
let stem = image_stem(specs);
crate::control::ensure::ensured_image_name(&stem, &identity)
.unwrap_or_else(|_| format!("{stem}-{}", &identity[..12]))
}
pub fn ensure_request_for(
sandbox: &Sandbox,
specs: &[AgentSpec],
binary: Vec<u8>,
build_role_arn: impl Into<String>,
size: SizeClass,
s3_bucket: impl Into<String>,
s3_key_prefix: Option<String>,
) -> Result<crate::control::EnsureImageRequest, Error> {
require_specs(specs)?;
let base = BaseImage::al2023();
let dockerfile = dockerfile(specs, &base, sandbox.port())?;
let mut request = crate::control::EnsureImageRequest::new(
image_stem(specs),
binary,
dockerfile,
s3_bucket,
build_role_arn,
);
request.base_image = Some(base);
request.size = size;
request.s3_key_prefix = s3_key_prefix;
Ok(request)
}
pub fn image_request_for(
sandbox: &Sandbox,
specs: &[AgentSpec],
binary: Vec<u8>,
build_role_arn: impl Into<String>,
size: SizeClass,
) -> Result<CreateImageRequest, Error> {
require_specs(specs)?;
let base = BaseImage::al2023();
let dockerfile = dockerfile(specs, &base, sandbox.port())?;
let mut request =
CreateImageRequest::new(image_stem(specs), binary, String::new(), build_role_arn);
request.base_image = base;
request.dockerfile = Some(dockerfile);
request.size = size;
let name = image_name_for(sandbox, specs, &request);
request.name = name.clone();
request.token_scope = Some(name);
Ok(request)
}
pub fn launch_request_for(
specs: &[AgentSpec],
image_identifier: impl Into<String>,
execution_role_arn: Option<String>,
) -> RunRequest {
let identifier = image_identifier.into();
let mut request = RunRequest::new().with_image(&identifier).with_egress();
request.execution_role_arn = execution_role_arn;
request.token_scope = Some(image_stem(specs));
request
}
pub fn default_specs() -> Vec<AgentSpec> {
vec![AgentSpec::new(Agent::ClaudeCode)]
}
pub fn launched_session(sandbox: &Sandbox) -> Result<&Session, Error> {
sandbox.session().ok_or_else(|| {
Error::new(
ErrorKind::Precondition,
"this agent VM has not been launched; call `launch` first.",
)
})
}
pub fn agent_vm_egress_posture() -> crate::control::EgressPosture {
launch_request_for(&[], "arn:image", None).egress_posture()
}
pub fn image_stem(specs: &[AgentSpec]) -> String {
let agents: Vec<&str> = ordered(specs)
.into_iter()
.map(|spec| spec.agent.as_str())
.collect();
format!("agent-vm-{}", agents.join("-"))
}
pub fn dockerfile(specs: &[AgentSpec], base: &BaseImage, port: u16) -> Result<String, Error> {
require_specs(specs)?;
let stanza = default_dockerfile(port, Some(WORKDIR), base, None);
let mut lines: Vec<String> = stanza.lines().map(str::to_string).collect();
let chmod = lines
.iter()
.position(|line| line == "RUN chmod 0755 /agentd")
.ok_or_else(|| stanza_drift("RUN chmod 0755 /agentd"))?;
let mut layers = vec![
String::new(),
"# The coding agents. Node 22 runs both CLIs; the rest is what a coding agent".to_string(),
"# expects of a working shell. Unpinned unless a cli_version was given, so the".to_string(),
"# image carries the registry's latest at build time and a pin changes the reuse hash."
.to_string(),
format!(
"RUN dnf install -y --setopt=install_weak_deps=0 {} && dnf clean all",
profile::SYSTEM_PACKAGES
),
];
let packages: Vec<String> = ordered(specs)
.into_iter()
.map(AgentSpec::npm_spec)
.collect();
layers.push(format!(
"RUN npm install -g {} && npm cache clean --force",
packages.join(" ")
));
layers.extend([
String::new(),
"# The non-root user the agents run as. Appended directly because `useradd` is not"
.to_string(),
"# in the minimal base. Not decoration: Claude Code refuses --dangerously-skip-permissions"
.to_string(),
"# as root and then denies its own tool calls while still reporting (measured: zero"
.to_string(),
"# shell calls as uid 0, 147 as uid 1000 on the same task).".to_string(),
format!(
"RUN echo \"agent:x:{AGENT_UID}:{AGENT_GID}::{WORKDIR}:/bin/bash\" >> /etc/passwd \\"
),
format!(" && echo \"agent:x:{AGENT_GID}:\" >> /etc/group"),
]);
lines.splice(chmod + 1..chmod + 1, layers);
let mkdir = lines
.iter()
.position(|line| line == &format!("RUN mkdir -p {WORKDIR}"))
.ok_or_else(|| stanza_drift("RUN mkdir -p /workspace"))?;
lines[mkdir] = format!("RUN mkdir -p {WORKDIR} && chown {AGENT_UID}:{AGENT_GID} {WORKDIR}");
let mut text = lines.join("\n");
text.push('\n');
Ok(text)
}
fn stanza_drift(line: &str) -> Error {
Error::new(
ErrorKind::Unexpected,
format!(
"default_dockerfile no longer emits `{line}`, so the agent layers have no splice \
point. Update agents::dockerfile beside the stanza change."
),
)
}
#[derive(Clone, Debug)]
pub struct BedrockAccess {
pub region: Region,
pub token: BearerToken,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GuestFile {
pub path: String,
pub contents: Vec<u8>,
pub mode: &'static str,
}
pub fn sh_double_quote(value: &str) -> String {
let mut quoted = String::with_capacity(value.len() + 2);
quoted.push('"');
for ch in value.chars() {
if matches!(ch, '"' | '\\' | '$' | '`') {
quoted.push('\\');
}
quoted.push(ch);
}
quoted.push('"');
quoted
}
pub fn sh_single_quote(value: &str) -> String {
let mut quoted = String::with_capacity(value.len() + 2);
quoted.push('\'');
for ch in value.chars() {
if ch == '\'' {
quoted.push_str("'\\''");
} else {
quoted.push(ch);
}
}
quoted.push('\'');
quoted
}
pub fn provisioning_files(
specs: &[AgentSpec],
access: &BedrockAccess,
) -> Result<Vec<GuestFile>, Error> {
require_specs(specs)?;
let specs = ordered(specs);
let mut env = vec![
("HOME", WORKDIR.to_string()),
("PATH", "/usr/local/bin:/usr/bin:/bin".to_string()),
("AWS_REGION", access.region.as_str().to_string()),
];
for spec in &specs {
env.extend(profile::env_pairs(
spec.agent,
spec.model(),
access.token.expose(),
));
}
let mut env_file = String::new();
for (key, value) in env {
env_file.push_str(&format!("export {key}={}\n", sh_double_quote(&value)));
}
let mut files = vec![GuestFile {
path: ENV_FILE.to_string(),
contents: env_file.into_bytes(),
mode: "0600",
}];
if let Some(codex) = specs.iter().find(|spec| spec.agent == Agent::Codex) {
files.push(GuestFile {
path: CODEX_CONFIG_FILE.to_string(),
contents: profile::codex_config(codex.model(), &access.region).into_bytes(),
mode: "0600",
});
}
let marker = serde_json::json!({
"version": 1,
"region": access.region.as_str(),
"agents": specs.iter().map(|spec| serde_json::json!({
"agent": spec.agent.as_str(),
"model": spec.model(),
"cliVersion": spec.cli_version,
})).collect::<Vec<_>>(),
});
files.push(GuestFile {
path: MARKER_FILE.to_string(),
contents: serde_json::to_vec_pretty(&marker).map_err(|error| {
Error::new(
ErrorKind::Unexpected,
format!("the marker will not serialize: {error}"),
)
})?,
mode: "0644",
});
Ok(files)
}
pub async fn install_access(
session: &Session,
specs: &[AgentSpec],
access: &BedrockAccess,
) -> Result<(), Error> {
for file in provisioning_files(specs, access)? {
session
.upload_file(&file.path, &file.contents, Some(file.mode))
.await?;
}
let request = crate::protocol::exec::StartRequest::new(
session.mint_exec_id(),
vec![format!("chown -R {AGENT_UID}:{AGENT_GID} {WORKDIR}")],
)
.with_shell(true)
.with_user(Some(crate::session::ROOT_UID.into()))
.with_timeout_sec(Some(CHOWN_TIMEOUT.as_secs_f64()));
let result = session.run_sync(request, CHOWN_TIMEOUT).await?;
if !result.succeeded() {
return Err(Error::new(
ErrorKind::ExecFailed,
format!(
"handing {WORKDIR} to uid {AGENT_UID} failed (exit {:?}): {}",
result.exit_code(),
result.stderr().trim()
),
));
}
Ok(())
}
pub async fn installed_agents(session: &Session) -> Result<Vec<AgentSpec>, Error> {
let bytes = match session.download_file(MARKER_FILE).await {
Ok(bytes) => bytes,
Err(error) if error.wire_kind() == Some(crate::error::WireKind::NotFound) => {
return Err(Error::new(
ErrorKind::Precondition,
format!(
"{MARKER_FILE} is not in this VM, so it was not provisioned as an agent \
VM. `microvm agent-up --vm-name <NAME>` installs an agent and writes \
the marker; `--agent` names one explicitly when the marker is absent."
),
));
}
Err(error) => return Err(error),
};
let marker: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| {
Error::new(
ErrorKind::Protocol,
format!("{MARKER_FILE} is not the JSON agent-up wrote: {error}"),
)
})?;
let rows = marker["agents"].as_array().ok_or_else(|| {
Error::new(
ErrorKind::Protocol,
format!("{MARKER_FILE} carries no `agents` array"),
)
})?;
let mut specs = Vec::with_capacity(rows.len());
for row in rows {
let name = row["agent"].as_str().unwrap_or_default();
let agent = Agent::parse(name).ok_or_else(|| {
Error::new(
ErrorKind::Protocol,
format!("{MARKER_FILE} names an agent this client has no profile for: {name:?}"),
)
})?;
let mut spec = AgentSpec::new(agent);
if let Some(model) = row["model"].as_str() {
spec.model = Some(model.to_string());
}
if let Some(version) = row["cliVersion"].as_str() {
spec.cli_version = Some(version.to_string());
}
specs.push(spec);
}
require_specs(&specs)?;
Ok(specs)
}
pub const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(60);
pub const VERSION_PROBE_WAIT: Duration = Duration::from_secs(90);
pub async fn installed_version(session: &Session, agent: Agent) -> Result<String, Error> {
let request = crate::protocol::exec::StartRequest::new(
session.mint_exec_id(),
profile::version_command(agent),
)
.with_cwd(Some(WORKDIR.to_string()))
.with_env(std::collections::HashMap::from([(
"PATH".into(),
"/usr/local/bin:/usr/bin:/bin".into(),
)]))
.with_user(Some(AGENT_UID.into()))
.with_group(Some(AGENT_GID.into()))
.with_timeout_sec(Some(VERSION_PROBE_TIMEOUT.as_secs_f64()))
.with_reap_group_on_exit(true);
let result = session.run_sync(request, VERSION_PROBE_WAIT).await?;
if result.succeeded()
&& let Some(version) = reported_version(result.stdout())
{
return Ok(version.to_string());
}
if result
.outcome
.as_ref()
.is_some_and(|outcome| outcome.timed_out)
{
return Err(Error::new(
ErrorKind::Timeout,
format!(
"{agent} --version did not answer within {}s; the first run on a fresh VM \
reads the executable from a demand-paged disk, so retry once before \
suspecting the image",
VERSION_PROBE_TIMEOUT.as_secs(),
),
));
}
Err(Error::new(
ErrorKind::Precondition,
format!(
"could not read {agent}'s installed version; verify the pinned agent executable in the image",
),
))
}
fn reported_version(output: &str) -> Option<&str> {
output.split_ascii_whitespace().find(|word| {
word.len() <= 32
&& word.split('.').count() == 3
&& word
.split('.')
.all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
})
}
#[derive(Clone, Debug, Default)]
pub struct PromptOptions {
pub exec_id: Option<String>,
pub timeout: Option<Duration>,
pub permission_mode: AgentPermissionMode,
pub reap_group_on_exit: bool,
}
pub fn prompt_request_with(
spec: &AgentSpec,
task: &str,
options: &PromptOptions,
mint: impl FnOnce() -> String,
) -> Result<crate::protocol::exec::StartRequest, Error> {
if task.trim().is_empty() {
return Err(Error::invalid_arg(
"the task is empty. An agent prompted with nothing answers with nothing, at the \
price of a model call.",
));
}
if options.timeout.is_some_and(|timeout| timeout.is_zero()) {
return Err(Error::invalid_arg("execution timeout must be positive"));
}
let command = format!(
". {ENV_FILE} && {}",
profile::headless_command(spec.agent, &sh_single_quote(task), options.permission_mode)
);
Ok(crate::protocol::exec::StartRequest::new(
options.exec_id.clone().unwrap_or_else(mint),
vec![command],
)
.with_shell(true)
.with_cwd(Some(WORKDIR.to_string()))
.with_user(Some(AGENT_UID.into()))
.with_group(Some(AGENT_GID.into()))
.with_timeout_sec(options.timeout.map(|timeout| timeout.as_secs_f64()))
.with_reap_group_on_exit(options.reap_group_on_exit))
}
pub fn headless_command_template(agent: Agent) -> String {
format!(
". {ENV_FILE} && {}",
profile::headless_command(agent, "'<TASK>'", AgentPermissionMode::AgentDefault)
)
}
pub async fn prompt(
session: &Session,
spec: &AgentSpec,
task: &str,
options: &PromptOptions,
) -> Result<ExecHandle, Error> {
session
.run(prompt_request_with(spec, task, options, || {
session.mint_exec_id()
})?)
.await
}
pub async fn prompt_sync(
session: &Session,
spec: &AgentSpec,
task: &str,
options: &PromptOptions,
) -> Result<ExecResult, Error> {
let timeout = options.timeout.unwrap_or(DEFAULT_PROMPT_TIMEOUT);
let bounded = PromptOptions {
timeout: Some(timeout),
..options.clone()
};
let request = prompt_request_with(spec, task, &bounded, || session.mint_exec_id())?;
session.run_sync(request, timeout).await
}
pub struct AgentVm {
sandbox: Sandbox,
specs: Vec<AgentSpec>,
}
impl std::fmt::Debug for AgentVm {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AgentVm")
.field("agents", &self.specs)
.field("sandbox", &self.sandbox)
.finish()
}
}
impl AgentVm {
pub fn new(sandbox: Sandbox, specs: Vec<AgentSpec>) -> Result<Self, Error> {
require_specs(&specs)?;
Ok(Self { sandbox, specs })
}
pub async fn adopt(
control: crate::control::ControlPlane,
specs: Vec<AgentSpec>,
microvm_id: impl Into<String>,
endpoint: impl Into<String>,
agent_token: impl Into<String>,
) -> Result<Self, Error> {
require_specs(&specs)?;
let sandbox = Sandbox::adopt(control, microvm_id, endpoint, agent_token).await?;
Ok(Self { sandbox, specs })
}
pub fn detach(&mut self) -> Result<crate::sandbox::Detached, Error> {
self.sandbox.detach()
}
pub fn into_parts(self) -> (Sandbox, Vec<AgentSpec>) {
(self.sandbox, self.specs)
}
pub fn claude_code(sandbox: Sandbox) -> Self {
Self {
sandbox,
specs: vec![AgentSpec::new(Agent::ClaudeCode)],
}
}
pub fn codex(sandbox: Sandbox) -> Self {
Self {
sandbox,
specs: vec![AgentSpec::new(Agent::Codex)],
}
}
pub fn specs(&self) -> Vec<AgentSpec> {
ordered(&self.specs).into_iter().cloned().collect()
}
pub fn sandbox(&self) -> &Sandbox {
&self.sandbox
}
pub fn sandbox_mut(&mut self) -> &mut Sandbox {
&mut self.sandbox
}
pub fn session(&self) -> Option<&Session> {
self.sandbox.session()
}
pub fn image_name(&self, request: &CreateImageRequest) -> String {
image_name_for(&self.sandbox, &self.specs, request)
}
pub fn image_request(
&self,
binary: Vec<u8>,
build_role_arn: impl Into<String>,
size: SizeClass,
) -> Result<CreateImageRequest, Error> {
image_request_for(&self.sandbox, &self.specs, binary, build_role_arn, size)
}
pub async fn build(&mut self, request: CreateImageRequest) -> Result<&Image, Error> {
self.sandbox.build_image(request).await
}
pub async fn ensure_image(
&mut self,
binary: Vec<u8>,
build_role_arn: impl Into<String>,
size: SizeClass,
s3_bucket: impl Into<String>,
s3_key_prefix: Option<String>,
) -> Result<crate::control::EnsuredImage, Error> {
let request = ensure_request_for(
&self.sandbox,
&self.specs,
binary,
build_role_arn,
size,
s3_bucket,
s3_key_prefix,
)?;
self.sandbox.ensure_image(request).await
}
pub fn launch_request(
&self,
image_identifier: impl Into<String>,
execution_role_arn: Option<String>,
) -> RunRequest {
launch_request_for(&self.specs, image_identifier, execution_role_arn)
}
pub async fn launch(&mut self, request: RunRequest) -> Result<&Session, Error> {
self.sandbox.run(request).await.map(|session| &*session)
}
fn require_session(&self) -> Result<&Session, Error> {
launched_session(&self.sandbox)
}
pub async fn install_access(&self, access: &BedrockAccess) -> Result<(), Error> {
install_access(self.require_session()?, &self.specs, access).await
}
pub async fn prompt(
&self,
agent: Agent,
task: &str,
options: &PromptOptions,
) -> Result<ExecHandle, Error> {
let spec = spec_for(&self.specs, agent)?;
prompt(self.require_session()?, spec, task, options).await
}
pub async fn prompt_sync(
&self,
agent: Agent,
task: &str,
options: &PromptOptions,
) -> Result<ExecResult, Error> {
let spec = spec_for(&self.specs, agent)?;
prompt_sync(self.require_session()?, spec, task, options).await
}
pub async fn terminate(&mut self, opts: TeardownOpts) -> TeardownReport {
self.sandbox.terminate(opts).await
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::control::artifact::{
require_daemon_cmd, require_matching_agentd_port, require_matching_from, require_workdir,
};
use crate::session::testing::{Recorder, Reply, session_with};
fn prompt_request(
spec: &AgentSpec,
task: &str,
options: &PromptOptions,
) -> Result<crate::protocol::exec::StartRequest, Error> {
prompt_request_with(spec, task, options, || {
crate::session::exec_id_at(Duration::from_secs(1_767_225_600))
})
}
fn both() -> Vec<AgentSpec> {
vec![
AgentSpec::new(Agent::Codex),
AgentSpec::new(Agent::ClaudeCode),
]
}
fn access() -> BedrockAccess {
BedrockAccess {
region: Region::US_EAST_1,
token: bedrock::BearerToken::new("bedrock-api-key-test".to_string()),
}
}
#[test]
fn the_dockerfile_passes_the_guards_and_carries_the_recipe() {
let base = BaseImage::al2023();
let text = dockerfile(&both(), &base, 9000).expect("derives");
require_matching_from(&base, &text).expect("FROM pairs with the base");
require_workdir(&base, Some(&text)).expect("a WORKDIR is declared");
require_daemon_cmd(&text).expect("ENTRYPOINT [] + CMD agentd survive");
require_matching_agentd_port(9000, &text).expect("the port agrees");
assert_eq!(
text.matches("RUN dnf install").count(),
1,
"the system layer is emitted once for two profiles:\n{text}"
);
assert!(
text.contains("RUN dnf install -y --setopt=install_weak_deps=0 nodejs22 nodejs22-npm "),
"with weak deps off, bare `npm` is Node 18's and the npm layer exits 127:\n{text}"
);
assert!(
text.contains("RUN npm install -g @anthropic-ai/claude-code @openai/codex && npm"),
"both CLIs in profile order, in one layer:\n{text}"
);
assert!(
text.contains("agent:x:1000:1000::/workspace:/bin/bash"),
"{text}"
);
assert!(
text.contains("RUN mkdir -p /workspace && chown 1000:1000 /workspace"),
"{text}"
);
let layers_at = text.find("RUN npm install").expect("layers");
let workdir_at = text.find("WORKDIR /workspace").expect("workdir");
let cmd_at = text.find("CMD [\"/agentd\"]").expect("cmd");
assert!(layers_at < workdir_at && workdir_at < cmd_at, "{text}");
}
#[test]
fn a_cli_version_pin_reaches_the_install_line() {
let base = BaseImage::al2023();
let pinned = vec![AgentSpec::new(Agent::ClaudeCode).with_cli_version("2.1.0")];
let text = dockerfile(&pinned, &base, 9000).expect("derives");
assert!(text.contains("@anthropic-ai/claude-code@2.1.0"), "{text}");
let plain = dockerfile(&[AgentSpec::new(Agent::ClaudeCode)], &base, 9000).expect("d");
assert_ne!(text, plain);
}
#[test]
fn an_empty_or_repeated_spec_set_is_refused_before_any_text_is_built() {
let base = BaseImage::al2023();
assert_eq!(
dockerfile(&[], &base, 9000).expect_err("empty").kind(),
ErrorKind::InvalidArg
);
let twice = vec![AgentSpec::new(Agent::Codex), AgentSpec::new(Agent::Codex)];
let error = dockerfile(&twice, &base, 9000).expect_err("repeated");
assert!(
error.to_string().contains("codex is named twice"),
"{error}"
);
}
#[test]
fn the_image_stem_is_stable_under_spec_order() {
assert_eq!(image_stem(&both()), "agent-vm-claude-code-codex");
assert_eq!(
image_stem(&[AgentSpec::new(Agent::Codex)]),
"agent-vm-codex"
);
}
#[test]
fn a_codex_only_vm_still_gets_the_variable_codex_reads() {
let files = provisioning_files(&[AgentSpec::new(Agent::Codex)], &access()).expect("files");
let env = String::from_utf8(files[0].contents.clone()).expect("utf-8");
assert!(
env.contains("export AWS_BEARER_TOKEN_BEDROCK="),
"no Claude Code profile is present to export it, and Codex 401s without it:\n{env}"
);
}
#[test]
fn the_file_set_follows_the_profiles_and_the_env_file_is_quoted() {
let access = access();
let claude_only =
provisioning_files(&[AgentSpec::new(Agent::ClaudeCode)], &access).expect("files");
assert_eq!(
claude_only
.iter()
.map(|f| f.path.as_str())
.collect::<Vec<_>>(),
vec![ENV_FILE, MARKER_FILE]
);
let files = provisioning_files(&both(), &access).expect("files");
assert_eq!(
files.iter().map(|f| f.path.as_str()).collect::<Vec<_>>(),
vec![ENV_FILE, CODEX_CONFIG_FILE, MARKER_FILE]
);
let env = String::from_utf8(files[0].contents.clone()).expect("utf-8");
assert_eq!(files[0].mode, "0600");
assert!(
env.contains("export PATH=\"/usr/local/bin:/usr/bin:/bin\"\n"),
"{env}"
);
assert!(env.contains("export HOME=\"/workspace\"\n"), "{env}");
assert!(env.contains("export AWS_REGION=\"us-east-1\"\n"), "{env}");
assert!(
env.contains("export CLAUDE_CODE_USE_BEDROCK=\"1\"\n"),
"{env}"
);
assert!(
env.contains("export ANTHROPIC_MODEL=\"global.anthropic.claude-opus-5\"\n"),
"{env}"
);
let token = access.token.expose();
assert!(env.contains(&format!("export AWS_BEARER_TOKEN_BEDROCK=\"{token}\"\n")));
assert!(env.contains(&format!("export OPENAI_API_KEY=\"{token}\"\n")));
let config = String::from_utf8(files[1].contents.clone()).expect("utf-8");
assert!(
config.contains("bedrock-runtime.us-east-1.amazonaws.com/openai/v1"),
"{config}"
);
assert!(
config.contains("model = \"global.openai.gpt-5.6-sol\""),
"{config}"
);
let marker: serde_json::Value = serde_json::from_slice(&files[2].contents).expect("json");
assert_eq!(files[2].mode, "0644");
assert_eq!(marker["agents"][0]["agent"], "claude-code");
assert_eq!(marker["agents"][1]["agent"], "codex");
assert_eq!(marker["agents"][1]["model"], "global.openai.gpt-5.6-sol");
assert!(
!files[2]
.contents
.windows(token.len())
.any(|w| w == token.as_bytes()),
"the marker is world-readable and must never carry the token"
);
}
#[test]
fn shell_quoting_escapes_what_each_quote_style_cannot_hold() {
assert_eq!(sh_double_quote("a$b\"c`d\\e"), "\"a\\$b\\\"c\\`d\\\\e\"");
assert_eq!(sh_single_quote("it's"), "'it'\\''s'");
assert_eq!(sh_single_quote("plain"), "'plain'");
}
#[tokio::test(start_paused = true)]
async fn install_access_uploads_then_chowns_as_root() {
let recorder = Recorder::with([
Reply::Body(200, Vec::new()),
Reply::Body(200, Vec::new()),
Reply::Body(200, Vec::new()),
Reply::ok(serde_json::json!({"exec_id":"c1","phase":"running"})),
Reply::ok(serde_json::json!({"exec_id":"c1","phase":"exited"})),
Reply::ok(serde_json::json!({
"exec_id":"c1","phase":"exited","exit_code":0,"signal":null,
"stdout":"","stderr":"","truncated":false,"writers_may_be_alive":false
})),
]);
let (session, _, _) = session_with(Arc::clone(&recorder));
install_access(&session, &both(), &access())
.await
.expect("installs");
let seen = recorder.requests();
assert_eq!(seen.len(), 6, "{seen:#?}");
for (request, (encoded, mode)) in seen.iter().zip([
("%2Fworkspace%2F.agent-env", "0600"),
("%2Fworkspace%2F.codex%2Fconfig.toml", "0600"),
("%2Fworkspace%2F.agent-vm.json", "0644"),
]) {
assert_eq!(request.method, "PUT");
assert_eq!(
request.path,
format!("/v1/fs/file?path={encoded}&mode={mode}"),
"each file goes to the file route with its own mode"
);
}
assert_eq!(seen[3].path, "/v1/exec/start");
let start: serde_json::Value = serde_json::from_slice(&seen[3].body).expect("json");
assert_eq!(start["command"][0], "chown -R 1000:1000 /workspace");
assert_eq!(start["shell"], true);
assert_eq!(start["user"], 0, "the chown asks for root by uid");
assert_eq!(seen[5].path, "/v1/exec/c1/ack");
}
#[tokio::test(start_paused = true)]
async fn a_failed_chown_fails_the_install() {
let recorder = Recorder::with([
Reply::Body(200, Vec::new()),
Reply::Body(200, Vec::new()),
Reply::ok(serde_json::json!({"exec_id":"c1","phase":"running"})),
Reply::ok(serde_json::json!({"exec_id":"c1","phase":"exited"})),
Reply::ok(serde_json::json!({
"exec_id":"c1","phase":"exited","exit_code":1,"signal":null,
"stdout":"","stderr":"chown: cannot access","truncated":false,
"writers_may_be_alive":false
})),
]);
let (session, _, _) = session_with(recorder);
let error = install_access(&session, &[AgentSpec::new(Agent::ClaudeCode)], &access())
.await
.expect_err("fails");
assert_eq!(error.kind(), ErrorKind::ExecFailed);
assert!(
error.to_string().contains("chown: cannot access"),
"{error}"
);
}
#[tokio::test]
async fn installed_agents_reads_the_marker_back() {
let specs = vec![
AgentSpec::new(Agent::ClaudeCode).with_model("global.anthropic.claude-sonnet-5"),
AgentSpec::new(Agent::Codex).with_cli_version("0.50.0"),
];
let files = provisioning_files(&specs, &access()).expect("files");
let marker = files.last().expect("marker").contents.clone();
let recorder = Recorder::with([Reply::Body(200, marker)]);
let (session, _, _) = session_with(recorder);
let read = installed_agents(&session).await.expect("reads");
assert_eq!(read.len(), 2);
assert_eq!(read[0].agent, Agent::ClaudeCode);
assert_eq!(read[0].model(), "global.anthropic.claude-sonnet-5");
assert_eq!(read[1].agent, Agent::Codex);
assert_eq!(read[1].cli_version.as_deref(), Some("0.50.0"));
assert_eq!(read[1].model(), "global.openai.gpt-5.6-sol");
}
#[tokio::test]
async fn a_missing_marker_names_agent_up_and_other_failures_propagate() {
let recorder = Recorder::with([
Reply::Body(404, b"not found".to_vec()),
Reply::Body(503, b"not bootstrapped".to_vec()),
]);
let (session, _, _) = session_with(recorder);
let error = installed_agents(&session).await.expect_err("absent");
assert_eq!(error.kind(), ErrorKind::Precondition);
assert!(error.to_string().contains("agent-up"), "{error}");
let other = installed_agents(&session).await.expect_err("503");
assert_ne!(other.kind(), ErrorKind::Precondition);
}
#[test]
fn the_prompt_request_runs_the_agent_demoted_in_the_workspace() {
let spec = AgentSpec::new(Agent::ClaudeCode);
let request = prompt_request(
&spec,
"count the files; it's quick",
&PromptOptions {
exec_id: Some("p1".into()),
timeout: Some(Duration::from_secs(120)),
..PromptOptions::default()
},
)
.expect("builds");
assert_eq!(request.exec_id, "p1");
assert_eq!(request.user, Some(1000.into()));
assert_eq!(request.group, Some(1000.into()));
assert_eq!(request.cwd.as_deref(), Some("/workspace"));
assert!(request.shell.is_shell());
assert!(
request.env.is_empty(),
"the token never rides in the request"
);
assert_eq!(request.timeout_sec, Some(120.0));
assert!(!request.stdin);
assert_eq!(
request.command,
vec![
". /workspace/.agent-env && claude -p 'count the files; it'\\''s quick' \
--allowedTools Bash,Read,Edit,Write,Grep,Glob"
.to_string()
]
);
let codex = prompt_request(
&AgentSpec::new(Agent::Codex),
"hi",
&PromptOptions::default(),
)
.expect("builds");
assert!(
codex.command[0].contains("codex exec --skip-git-repo-check -s workspace-write 'hi'")
);
assert!(codex.timeout_sec.is_none());
assert!(!codex.exec_id.is_empty(), "an id was minted");
}
#[test]
fn an_empty_task_is_refused_locally() {
let error = prompt_request(
&AgentSpec::new(Agent::Codex),
" \n",
&PromptOptions::default(),
)
.expect_err("empty");
assert_eq!(error.kind(), ErrorKind::InvalidArg);
}
fn sandbox() -> Sandbox {
Sandbox::with_control_plane(crate::testing::control_plane(
Arc::new(crate::control::fake::FakeControlPlane::new()),
Region::US_EAST_1,
Arc::new(crate::control::fake::TestClock::new()),
))
}
#[test]
fn the_default_agents_are_claude_code_alone() {
let specs = default_specs();
assert_eq!(
specs.iter().map(|spec| spec.agent).collect::<Vec<_>>(),
[Agent::ClaudeCode]
);
AgentVm::new(sandbox(), specs).expect("the default set is a valid one");
}
#[tokio::test]
async fn a_call_before_launch_is_refused_with_the_remedy() {
let fresh = sandbox();
let error = launched_session(&fresh).expect_err("nothing launched");
assert_eq!(error.kind(), ErrorKind::Precondition);
assert_eq!(
error.to_string(),
"this agent VM has not been launched; call `launch` first."
);
let vm = AgentVm::new(sandbox(), default_specs()).expect("a valid set");
let through_the_vm = vm
.install_access(&access())
.await
.expect_err("nothing launched");
assert_eq!(through_the_vm.to_string(), error.to_string());
}
#[tokio::test]
async fn an_agent_vm_adopts_with_checked_specs_and_the_service_lifecycle() {
use crate::control::fake::{Answer, FakeControlPlane, TestClock, microvm_response};
let plane = |recorder: &Arc<FakeControlPlane>| {
crate::testing::control_plane(
Arc::clone(recorder) as Arc<dyn crate::control::transport::Transport>,
Region::US_EAST_1,
Arc::new(TestClock::new()),
)
};
let endpoint = "https://mvm-abc123.microvm.us-east-1.amazonaws.com";
let recorder = Arc::new(FakeControlPlane::new());
let error = AgentVm::adopt(plane(&recorder), vec![], "mvm-abc123", endpoint, "t")
.await
.expect_err("an empty spec set");
assert_eq!(error.kind(), ErrorKind::InvalidArg);
assert_eq!(recorder.calls().len(), 0);
let recorder = Arc::new(FakeControlPlane::new());
recorder.answer(
"GetMicrovm",
Answer::ok(microvm_response("SUSPENDED", None)),
);
let vm = AgentVm::adopt(plane(&recorder), both(), "mvm-abc123", endpoint, "t")
.await
.expect("adopts");
assert!(vm.sandbox().adopted());
assert_eq!(
vm.sandbox().lifecycle(),
crate::sandbox::Lifecycle::Suspended
);
assert_eq!(vm.specs().len(), 2);
}
#[test]
fn the_two_step_name_is_the_name_ensure_gives_and_it_covers_the_size() {
let vm = AgentVm::new(sandbox(), both()).expect("specs");
let role = "arn:aws:iam::123456789012:role/b";
let daemon = crate::testing::aarch64_daemon(b"daemon");
let two_step = vm
.image_request(daemon.clone(), role, DEFAULT_SIZE)
.expect("request");
let ensure = ensure_request_for(
vm.sandbox(),
&vm.specs(),
daemon.clone(),
role,
DEFAULT_SIZE,
"bucket",
Some("agents".to_string()),
)
.expect("request");
let prepared = crate::control::ensure::prepare(
&crate::testing::control_plane(
Arc::new(crate::control::fake::FakeControlPlane::new()),
Region::US_EAST_1,
Arc::new(crate::control::fake::TestClock::new()),
),
ensure,
)
.expect("prepares");
assert_eq!(prepared.name, two_step.name, "one name for one agent image");
assert_eq!(
prepared.key,
format!("agents/{}/artifact.zip", two_step.name),
"the ensure's key"
);
let larger = SizeClass::from_baseline_mib(4096).expect("a class");
let other_size = vm.image_request(daemon, role, larger).expect("request");
assert_ne!(
other_size.name, two_step.name,
"another size is another image"
);
}
#[test]
fn the_image_request_is_content_named_and_egress_is_on_at_launch() {
let vm = AgentVm::new(sandbox(), both()).expect("specs");
let a = vm
.image_request(
b"daemon".to_vec(),
"arn:aws:iam::123456789012:role/b",
DEFAULT_SIZE,
)
.expect("request");
let b = vm
.image_request(
b"daemon".to_vec(),
"arn:aws:iam::123456789012:role/b",
DEFAULT_SIZE,
)
.expect("request");
let c = vm
.image_request(
b"other".to_vec(),
"arn:aws:iam::123456789012:role/b",
DEFAULT_SIZE,
)
.expect("request");
assert_eq!(a.name, b.name);
assert_ne!(a.name, c.name);
assert!(
a.name.starts_with("agent-vm-claude-code-codex-"),
"{}",
a.name
);
assert_eq!(a.name.len(), "agent-vm-claude-code-codex-".len() + 12);
assert_eq!(a.token_scope.as_deref(), Some(a.name.as_str()));
assert!(a.code_artifact_uri.is_empty(), "the upload is the caller's");
assert!(
a.dockerfile
.as_deref()
.is_some_and(|d| d.contains("@openai/codex"))
);
assert_eq!(a.size, SizeClass::Mib1024);
let launch = vm.launch_request("arn:image", Some("arn:role".into()));
assert!(
launch.egress,
"neither agent reaches Bedrock without egress"
);
assert_eq!(launch.image_identifier.as_deref(), Some("arn:image"));
assert_eq!(
launch.token_scope.as_deref(),
Some("agent-vm-claude-code-codex")
);
}
#[tokio::test]
async fn the_named_constructors_carry_one_agent_each() {
let claude = AgentVm::claude_code(sandbox());
assert_eq!(claude.specs(), vec![AgentSpec::new(Agent::ClaudeCode)]);
let codex = AgentVm::codex(sandbox());
assert_eq!(codex.specs(), vec![AgentSpec::new(Agent::Codex)]);
let error = codex
.prompt(Agent::ClaudeCode, "hi", &PromptOptions::default())
.await
.expect_err("wrong agent");
assert_eq!(error.kind(), ErrorKind::InvalidArg);
assert!(error.to_string().contains("carries codex"), "{error}");
let unlaunched = codex
.prompt(Agent::Codex, "hi", &PromptOptions::default())
.await
.expect_err("no session");
assert_eq!(unlaunched.kind(), ErrorKind::Precondition);
}
#[test]
fn unrestricted_permissions_deadline_and_reaping_are_explicit_and_demoted() {
for (agent, expected, forbidden) in [
(
Agent::ClaudeCode,
"--dangerously-skip-permissions",
"--allowedTools",
),
(
Agent::Codex,
"--dangerously-bypass-approvals-and-sandbox",
"workspace-write",
),
] {
let task = "a quote ' and $(touch /tmp/must-not-run); \"hello\"";
let request = prompt_request(
&AgentSpec::new(agent),
task,
&PromptOptions {
exec_id: Some("same-task".into()),
timeout: Some(Duration::from_secs(1200)),
permission_mode: AgentPermissionMode::Unrestricted,
reap_group_on_exit: true,
},
)
.expect("valid");
assert!(request.command[0].contains(expected));
assert!(!request.command[0].contains(forbidden));
assert!(request.command[0].contains(&sh_single_quote(task)));
assert_eq!(
(request.user, request.group),
(Some(1000.into()), Some(1000.into()))
);
assert_eq!(request.timeout_sec, Some(1200.0));
assert!(request.reap_group_on_exit);
assert_eq!(request.exec_id, "same-task");
let default =
prompt_request(&AgentSpec::new(agent), task, &PromptOptions::default()).unwrap();
assert!(default.command[0].contains(forbidden));
assert!(!default.command[0].contains(expected));
assert!(!default.reap_group_on_exit);
assert_eq!(default.timeout_sec, None);
}
assert!("auto".parse::<AgentPermissionMode>().is_err());
assert!(
prompt_request(
&AgentSpec::new(Agent::Codex),
"task",
&PromptOptions {
timeout: Some(Duration::ZERO),
..PromptOptions::default()
}
)
.is_err()
);
}
#[test]
fn version_reporting_only_accepts_numeric_version_words() {
assert_eq!(reported_version("2.1.7 (Claude Code)\n"), Some("2.1.7"));
assert_eq!(reported_version("codex-cli 0.154.0\n"), Some("0.154.0"));
assert_eq!(reported_version("SECRET=credential\n"), None);
assert_eq!(reported_version("1.2.3-secret"), None);
}
#[tokio::test]
async fn a_timed_out_version_probe_reports_a_timeout_naming_the_budget() {
let recorder = Recorder::with([
Reply::ok(serde_json::json!({"exec_id":"version","phase":"running"})),
Reply::ok(serde_json::json!({"exec_id":"version","phase":"exited"})),
Reply::ok(serde_json::json!({
"exec_id":"version","phase":"exited","exit_code":null,"signal":9,
"stdout":"","stderr":"","timed_out":true,
"truncated":false,"writers_may_be_alive":false
})),
]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let error = installed_version(&session, Agent::Codex)
.await
.expect_err("a killed probe has no version");
assert_eq!(error.kind(), ErrorKind::Timeout, "{error}");
assert!(error.to_string().contains("60s"), "{error}");
}
#[tokio::test]
async fn an_agent_exec_id_is_minted_from_the_sessions_clock() {
let recorder = Recorder::with([
Reply::ok(serde_json::json!({"exec_id":"version","phase":"running"})),
Reply::ok(serde_json::json!({"exec_id":"version","phase":"exited"})),
Reply::ok(serde_json::json!({
"exec_id":"version","phase":"exited","exit_code":0,"signal":null,
"stdout":"codex-cli 0.154.0","stderr":"",
"truncated":false,"writers_may_be_alive":false
})),
]);
let (session, _, clock) = session_with(Arc::clone(&recorder));
let pinned = Duration::from_secs(1_767_225_600) + Duration::from_nanos(123_456_789);
clock.set_unix_now(pinned);
installed_version(&session, Agent::Codex).await.unwrap();
let start: serde_json::Value =
serde_json::from_slice(&recorder.requests()[0].body).unwrap();
let id = start["exec_id"].as_str().expect("an exec id");
let value =
u64::from_str_radix(id.strip_prefix("x-").expect("the x- shape"), 16).expect("hex");
let low_bits = (1_u64 << 40) - 1;
assert_eq!(
value & low_bits,
(pinned.as_nanos() as u64) & low_bits,
"{id} wasn't minted from the session's clock"
);
}
#[tokio::test]
async fn installed_version_probe_is_demoted_and_never_sources_agent_credentials() {
let recorder = Recorder::with([
Reply::ok(serde_json::json!({"exec_id":"version","phase":"running"})),
Reply::ok(serde_json::json!({"exec_id":"version","phase":"exited"})),
Reply::ok(serde_json::json!({
"exec_id":"version","phase":"exited","exit_code":0,"signal":null,
"stdout":"codex-cli 0.154.0\nSECRET=credential","stderr":"private diagnostic",
"truncated":false,"writers_may_be_alive":false
})),
]);
let (session, _, _) = session_with(Arc::clone(&recorder));
assert_eq!(
installed_version(&session, Agent::Codex).await.unwrap(),
"0.154.0"
);
let requests = recorder.requests();
let start: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap();
assert_eq!(start["command"], serde_json::json!(["codex", "--version"]));
assert_eq!(start["shell"], false);
assert_eq!(start["user"], 1000);
assert_eq!(start["group"], 1000);
assert_eq!(
start["env"],
serde_json::json!({"PATH":"/usr/local/bin:/usr/bin:/bin"})
);
assert_eq!(start["timeout_sec"], VERSION_PROBE_TIMEOUT.as_secs_f64());
assert!(
VERSION_PROBE_TIMEOUT >= Duration::from_secs(30),
"must cover a cold first run"
);
assert!(VERSION_PROBE_WAIT > VERSION_PROBE_TIMEOUT);
assert_eq!(start["reap_group_on_exit"], true);
}
#[tokio::test(start_paused = true)]
async fn a_synchronous_prompt_bounds_the_daemon_by_the_wait_it_makes() {
for (timeout, sent) in [(None, 900.0), (Some(Duration::from_secs(60)), 60.0)] {
let recorder = Recorder::with([
Reply::ok(serde_json::json!({"exec_id":"p1","phase":"running"})),
Reply::ok(serde_json::json!({"exec_id":"p1","phase":"exited"})),
Reply::ok(serde_json::json!({
"exec_id":"p1","phase":"exited","exit_code":0,"signal":null,
"stdout":"done","stderr":"","truncated":false,
"writers_may_be_alive":false
})),
]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let options = PromptOptions {
timeout,
..PromptOptions::default()
};
let result = prompt_sync(
&session,
&AgentSpec::new(Agent::ClaudeCode),
"review the diff",
&options,
)
.await
.expect("a result");
assert_eq!(result.stdout(), "done");
let start: serde_json::Value =
serde_json::from_slice(&recorder.requests()[0].body).expect("a JSON start");
assert_eq!(start["timeout_sec"], sent, "timeout_sec for {timeout:?}");
}
}
}