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, Session, mint_exec_id};
use crate::sizing::SizeClass;
pub use bedrock::{BearerToken, Minted};
pub use profile::{Agent, 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 hash = sandbox.artifact_content_hash_for(request);
format!("{}-{}", image_stem(specs), &hash[..12])
}
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 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 {
exec_id: mint_exec_id(),
command: vec![format!("chown -R {AGENT_UID}:{AGENT_GID} {WORKDIR}")],
shell: true,
cwd: None,
env: std::collections::HashMap::new(),
user: None,
group: None,
timeout_sec: Some(CHOWN_TIMEOUT.as_secs_f64()),
stdin: false,
};
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)
}
#[derive(Clone, Debug, Default)]
pub struct PromptOptions {
pub exec_id: Option<String>,
pub timeout: Option<Duration>,
}
pub fn prompt_request(
spec: &AgentSpec,
task: &str,
options: &PromptOptions,
) -> 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.",
));
}
let command = format!(
". {ENV_FILE} && {}",
profile::headless_command(spec.agent, &sh_single_quote(task))
);
Ok(crate::protocol::exec::StartRequest {
exec_id: options.exec_id.clone().unwrap_or_else(mint_exec_id),
command: vec![command],
shell: true,
cwd: Some(WORKDIR.to_string()),
env: std::collections::HashMap::new(),
user: Some(AGENT_UID),
group: Some(AGENT_GID),
timeout_sec: options.timeout.map(|timeout| timeout.as_secs_f64()),
stdin: false,
})
}
pub fn headless_command_template(agent: Agent) -> String {
format!(
". {ENV_FILE} && {}",
profile::headless_command(agent, "'<TASK>'")
)
}
pub async fn prompt(
session: &Session,
spec: &AgentSpec,
task: &str,
options: &PromptOptions,
) -> Result<ExecHandle, Error> {
session.run(prompt_request(spec, task, options)?).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 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 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> {
let session = self.sandbox.run(request).await?;
session
.wait_until_ready(crate::session::DEFAULT_READY_TIMEOUT)
.await?;
Ok(session)
}
fn require_session(&self) -> Result<&Session, Error> {
self.sandbox.session().ok_or_else(|| {
Error::new(
ErrorKind::Precondition,
"this agent VM has not been launched; call `launch` first.",
)
})
}
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 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 both() -> Vec<AgentSpec> {
vec![
AgentSpec::new(Agent::Codex),
AgentSpec::new(Agent::ClaudeCode),
]
}
fn access() -> BedrockAccess {
BedrockAccess {
region: Region::UsEast1,
token: bedrock::mint_with(
&aws_credential_types::Credentials::new("AKID", "secret", None, None, "t"),
&Region::UsEast1,
Duration::from_secs(60),
std::time::SystemTime::UNIX_EPOCH,
)
.expect("mints")
.token,
}
}
#[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!(
start["user"].is_null(),
"the chown runs as the daemon's own user"
);
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)),
},
)
.expect("builds");
assert_eq!(request.exec_id, "p1");
assert_eq!(request.user, Some(1000));
assert_eq!(request.group, Some(1000));
assert_eq!(request.cwd.as_deref(), Some("/workspace"));
assert!(request.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::control::ControlPlane::with_transport(
Arc::new(crate::control::fake::FakeControlPlane::new()),
Region::UsEast1,
Arc::new(crate::control::fake::TestClock::new()),
))
}
#[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);
}
}