Skip to main content

microvms_core/agents/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Agent VMs: the L3 helpers over the lifecycle (`docs/AGENT-VMS.md`).
3//!
4//! # What this module is
5//!
6//! One VM with a coding agent installed, model access wired, running as a non-root user,
7//! and one call that hands the agent a task. Every step is a composition of
8//! [`crate::sandbox`] and [`crate::session`] calls that
9//! `examples/coding-agents-on-bedrock/run.sh` performed by hand; this module is where
10//! that composition, and the four measured traps it closes, live once:
11//!
12//! 1. **Root silently breaks the agent.** Claude Code's `--dangerously-skip-permissions`
13//!    refuses to run as uid 0 and then denies its own tool calls while still reporting
14//!    (measured: zero shell calls as root, 147 as uid 1000). Every prompt runs as
15//!    [`AGENT_UID`]/[`AGENT_GID`], and the image creates that user.
16//! 2. **The daemon's execs start from an empty environment**, so the agent's subshells
17//!    find no `PATH` and every command exits 127. The environment file sets it.
18//! 3. **The daemon writes uploaded files as root, mode 0600**, which a demoted agent
19//!    cannot read. One root `chown` follows the uploads.
20//! 4. **Codex reaches Bedrock through `bedrock-runtime`'s `/openai/v1`** on the Responses
21//!    wire API, with an inference-profile model id and hosted web search disabled. Its
22//!    config file names that host for the launch region, and its credential comes from
23//!    `AWS_BEARER_TOKEN_BEDROCK` rather than the provider's `env_key`.
24//!
25//! # What it is not
26//!
27//! Not an orchestrator (`docs/STRATEGY.md`): no scheduling, no retries across VMs, no
28//! turn loop. Not a harness provider class: a Harbor environment or an eve backend
29//! imports its harness and lives there, calling this. And not a place agent detail
30//! sprawls: every vendor-shaped fact is a row in [`profile`], dated.
31//!
32//! # Two shapes over one recipe
33//!
34//! [`AgentVm`] owns a [`Sandbox`] and is the shape for a caller who builds, launches,
35//! provisions, prompts, and tears down in one process. The free functions
36//! ([`install_access`], [`prompt`], [`installed_agents`]) take a [`Session`], because the
37//! CLI's refresh and prompt paths hold an attached session for a VM some earlier process
38//! launched and no sandbox at all; `AgentVm`'s methods delegate to them so the two
39//! paths cannot drift.
40
41pub mod bedrock;
42pub mod profile;
43
44use std::time::Duration;
45
46use crate::control::artifact::{BaseImage, default_dockerfile};
47use crate::control::{CreateImageRequest, Image};
48use crate::error::{Error, ErrorKind};
49use crate::region::Region;
50use crate::sandbox::{RunRequest, Sandbox, TeardownOpts, TeardownReport};
51use crate::session::{ExecHandle, Session, mint_exec_id};
52use crate::sizing::SizeClass;
53
54pub use bedrock::{BearerToken, Minted};
55pub use profile::{Agent, CODEX_CONFIG_FILE, ENV_FILE, MARKER_FILE, Profile};
56
57/// The uid every agent runs as, and the owner of `/workspace`. Created by the image.
58pub const AGENT_UID: u32 = 1000;
59/// The gid to match.
60pub const AGENT_GID: u32 = 1000;
61/// The image `WORKDIR`, the agent's `HOME`, and where a synced project lands. The same
62/// path `microvms-cli/src/sync.rs` names as `REMOTE_WORKDIR`, on purpose: `run <DIR>`
63/// and `agent-up --project` put the tree in the same place.
64pub const WORKDIR: &str = "/workspace";
65/// The size class agent sessions default to: a 4 GiB always-present ceiling at half the
66/// floor cost of the 2048 default, because agent sessions are peaky (the example's
67/// measured choice, `examples/coding-agents-on-bedrock/microvm.toml`).
68pub const DEFAULT_SIZE: SizeClass = SizeClass::Mib1024;
69/// The default wait for one prompt. Agent tasks run minutes, not seconds.
70pub const DEFAULT_PROMPT_TIMEOUT: Duration = Duration::from_secs(900);
71/// How long the root `chown` after the uploads may take.
72const CHOWN_TIMEOUT: Duration = Duration::from_secs(60);
73
74/// One agent to install, with its two overridable defaults.
75#[derive(Clone, Debug, Eq, PartialEq)]
76pub struct AgentSpec {
77    pub agent: Agent,
78    /// The model id, or `None` for the profile's default.
79    pub model: Option<String>,
80    /// An npm version to pin the CLI to (`@<version>` on the install line), or `None`
81    /// for the registry's latest at build time. A pin changes the Dockerfile text and
82    /// therefore the reuse hash (AGENT-3).
83    pub cli_version: Option<String>,
84}
85
86impl AgentSpec {
87    /// The profile's defaults.
88    pub fn new(agent: Agent) -> Self {
89        Self {
90            agent,
91            model: None,
92            cli_version: None,
93        }
94    }
95
96    /// Override the model.
97    pub fn with_model(mut self, model: impl Into<String>) -> Self {
98        self.model = Some(model.into());
99        self
100    }
101
102    /// Pin the CLI version.
103    pub fn with_cli_version(mut self, version: impl Into<String>) -> Self {
104        self.cli_version = Some(version.into());
105        self
106    }
107
108    /// The model this spec resolves to.
109    pub fn model(&self) -> &str {
110        self.model
111            .as_deref()
112            .unwrap_or(self.agent.profile().default_model)
113    }
114
115    /// The `npm install -g` argument for this spec.
116    fn npm_spec(&self) -> String {
117        match &self.cli_version {
118            Some(version) => format!("{}@{version}", self.agent.profile().npm_package),
119            None => self.agent.profile().npm_package.to_string(),
120        }
121    }
122}
123
124/// Refuses an empty spec list and a repeated agent.
125///
126/// Both are caller mistakes with no sensible reading: an image with no agent is
127/// `microvm build`, and two rows for one agent would have two models for one CLI.
128///
129/// Public because the bindings hold a spec list beside a shared sandbox rather than an
130/// [`AgentVm`], and the refusal has to be this one rather than a copy of it.
131pub fn require_specs(specs: &[AgentSpec]) -> Result<(), Error> {
132    if specs.is_empty() {
133        return Err(Error::invalid_arg(
134            "no agent named. An agent VM installs at least one of: claude-code, codex.",
135        ));
136    }
137    for (index, spec) in specs.iter().enumerate() {
138        if specs[..index]
139            .iter()
140            .any(|earlier| earlier.agent == spec.agent)
141        {
142            return Err(Error::invalid_arg(format!(
143                "the agent {} is named twice; one row per agent, because a second row \
144                 would be a second model for the same CLI.",
145                spec.agent
146            )));
147        }
148    }
149    Ok(())
150}
151
152/// The specs in the fixed order the image layers, the marker, and the name use.
153fn ordered(specs: &[AgentSpec]) -> Vec<&AgentSpec> {
154    let mut sorted: Vec<&AgentSpec> = specs.iter().collect();
155    sorted.sort_by_key(|spec| spec.agent);
156    sorted
157}
158
159/// The spec in `specs` for `agent`, or the refusal [`AgentVm::prompt`] gives.
160pub fn spec_for(specs: &[AgentSpec], agent: Agent) -> Result<&AgentSpec, Error> {
161    specs
162        .iter()
163        .find(|spec| spec.agent == agent)
164        .ok_or_else(|| {
165            Error::invalid_arg(format!(
166                "this VM carries {}, not {agent}.",
167                image_stem(specs).trim_start_matches("agent-vm-")
168            ))
169        })
170}
171
172/// [`AgentVm::image_name`] for a caller holding the sandbox and the specs separately:
173/// the bindings, whose sandbox sits behind a lock shared with every session they hand out.
174pub fn image_name_for(
175    sandbox: &Sandbox,
176    specs: &[AgentSpec],
177    request: &CreateImageRequest,
178) -> String {
179    let hash = sandbox.artifact_content_hash_for(request);
180    format!("{}-{}", image_stem(specs), &hash[..12])
181}
182
183/// [`AgentVm::image_request`] for a caller holding the sandbox and the specs separately.
184pub fn image_request_for(
185    sandbox: &Sandbox,
186    specs: &[AgentSpec],
187    binary: Vec<u8>,
188    build_role_arn: impl Into<String>,
189    size: SizeClass,
190) -> Result<CreateImageRequest, Error> {
191    require_specs(specs)?;
192    let base = BaseImage::al2023();
193    let dockerfile = dockerfile(specs, &base, sandbox.port())?;
194    let mut request =
195        CreateImageRequest::new(image_stem(specs), binary, String::new(), build_role_arn);
196    request.base_image = base;
197    request.dockerfile = Some(dockerfile);
198    request.size = size;
199    let name = image_name_for(sandbox, specs, &request);
200    request.name = name.clone();
201    request.token_scope = Some(name);
202    Ok(request)
203}
204
205/// [`AgentVm::launch_request`] for a caller holding the specs separately.
206pub fn launch_request_for(
207    specs: &[AgentSpec],
208    image_identifier: impl Into<String>,
209    execution_role_arn: Option<String>,
210) -> RunRequest {
211    let identifier = image_identifier.into();
212    let mut request = RunRequest::new().with_image(&identifier).with_egress();
213    request.execution_role_arn = execution_role_arn;
214    request.token_scope = Some(image_stem(specs));
215    request
216}
217
218/// The stem of the image name: `agent-vm-<agents>` in profile order (AGENT-3). The
219/// caller appends the artifact content hash, exactly as `build --reuse` does.
220pub fn image_stem(specs: &[AgentSpec]) -> String {
221    let agents: Vec<&str> = ordered(specs)
222        .into_iter()
223        .map(|spec| spec.agent.as_str())
224        .collect();
225    format!("agent-vm-{}", agents.join("-"))
226}
227
228/// The Dockerfile for an image carrying `specs` (AGENT-2).
229///
230/// Built by splicing into [`default_dockerfile`]'s output rather than by re-listing its
231/// lines, so the stanza this produces *is* the default build's stanza plus layers: a
232/// change to the daemon lines there reaches here with no second copy to update. The
233/// splice points are the two lines the stanza is guaranteed to carry (`RUN chmod 0755
234/// /agentd` and `RUN mkdir -p /workspace`), and the test asserts both were found.
235pub fn dockerfile(specs: &[AgentSpec], base: &BaseImage, port: u16) -> Result<String, Error> {
236    require_specs(specs)?;
237    let stanza = default_dockerfile(port, Some(WORKDIR), base, None);
238    let mut lines: Vec<String> = stanza.lines().map(str::to_string).collect();
239
240    let chmod = lines
241        .iter()
242        .position(|line| line == "RUN chmod 0755 /agentd")
243        .ok_or_else(|| stanza_drift("RUN chmod 0755 /agentd"))?;
244    let mut layers = vec![
245        String::new(),
246        "# The coding agents. Node 22 runs both CLIs; the rest is what a coding agent".to_string(),
247        "# expects of a working shell. Unpinned unless a cli_version was given, so the".to_string(),
248        "# image carries the registry's latest at build time and a pin changes the reuse hash."
249            .to_string(),
250        format!(
251            "RUN dnf install -y --setopt=install_weak_deps=0 {} && dnf clean all",
252            profile::SYSTEM_PACKAGES
253        ),
254    ];
255    let packages: Vec<String> = ordered(specs)
256        .into_iter()
257        .map(AgentSpec::npm_spec)
258        .collect();
259    layers.push(format!(
260        "RUN npm install -g {} && npm cache clean --force",
261        packages.join(" ")
262    ));
263    layers.extend([
264        String::new(),
265        "# The non-root user the agents run as. Appended directly because `useradd` is not"
266            .to_string(),
267        "# in the minimal base. Not decoration: Claude Code refuses --dangerously-skip-permissions"
268            .to_string(),
269        "# as root and then denies its own tool calls while still reporting (measured: zero"
270            .to_string(),
271        "# shell calls as uid 0, 147 as uid 1000 on the same task).".to_string(),
272        format!(
273            "RUN echo \"agent:x:{AGENT_UID}:{AGENT_GID}::{WORKDIR}:/bin/bash\" >> /etc/passwd \\"
274        ),
275        format!("    && echo \"agent:x:{AGENT_GID}:\" >> /etc/group"),
276    ]);
277    lines.splice(chmod + 1..chmod + 1, layers);
278
279    let mkdir = lines
280        .iter()
281        .position(|line| line == &format!("RUN mkdir -p {WORKDIR}"))
282        .ok_or_else(|| stanza_drift("RUN mkdir -p /workspace"))?;
283    // Owned by the agent uid, because WORKDIR is where every exec lands and a root-owned
284    // one fails a demoted workload at its first write (docs/EMBEDDING.md).
285    lines[mkdir] = format!("RUN mkdir -p {WORKDIR} && chown {AGENT_UID}:{AGENT_GID} {WORKDIR}");
286
287    let mut text = lines.join("\n");
288    text.push('\n');
289    Ok(text)
290}
291
292fn stanza_drift(line: &str) -> Error {
293    Error::new(
294        ErrorKind::Unexpected,
295        format!(
296            "default_dockerfile no longer emits `{line}`, so the agent layers have no splice \
297             point. Update agents::dockerfile beside the stanza change."
298        ),
299    )
300}
301
302/// Model access for the agents: a Bedrock bearer token minted for a region.
303///
304/// A struct rather than an enum until a second access path exists to name
305/// (`docs/AGENT-VMS.md`, phase 2).
306#[derive(Clone, Debug)]
307pub struct BedrockAccess {
308    pub region: Region,
309    pub token: BearerToken,
310}
311
312/// One file to place in the guest.
313#[derive(Clone, Debug, Eq, PartialEq)]
314pub struct GuestFile {
315    pub path: String,
316    pub contents: Vec<u8>,
317    /// Octal, as the file route takes it.
318    pub mode: &'static str,
319}
320
321/// Wraps `value` in double quotes for `sh`, escaping the four characters that are
322/// special inside them. Used for the environment file, whose values are model ids, a
323/// region, and a token (base64 plus the prefix), none of which contain them today; the
324/// escaping is what keeps that "today" from mattering.
325pub fn sh_double_quote(value: &str) -> String {
326    let mut quoted = String::with_capacity(value.len() + 2);
327    quoted.push('"');
328    for ch in value.chars() {
329        if matches!(ch, '"' | '\\' | '$' | '`') {
330            quoted.push('\\');
331        }
332        quoted.push(ch);
333    }
334    quoted.push('"');
335    quoted
336}
337
338/// Wraps `value` in single quotes for `sh`. A single quote inside becomes `'\''`, the
339/// one sequence that is literal under every POSIX shell. Used for the task text, which
340/// is the caller's prose and may contain anything.
341pub fn sh_single_quote(value: &str) -> String {
342    let mut quoted = String::with_capacity(value.len() + 2);
343    quoted.push('\'');
344    for ch in value.chars() {
345        if ch == '\'' {
346            quoted.push_str("'\\''");
347        } else {
348            quoted.push(ch);
349        }
350    }
351    quoted.push('\'');
352    quoted
353}
354
355/// The files `install_access` places (AGENT-5, AGENT-6): the environment file, Codex's
356/// config when Codex is present, and the marker. Pure, so a test reads the exact bytes.
357pub fn provisioning_files(
358    specs: &[AgentSpec],
359    access: &BedrockAccess,
360) -> Result<Vec<GuestFile>, Error> {
361    require_specs(specs)?;
362    let specs = ordered(specs);
363
364    // PATH first and explicit: the daemon spawns execs from an empty environment, and
365    // Claude Code's Bash tool snapshots the shell it starts from, so without this line
366    // the agent's subshells find no `ls`, `wc`, or `python3`.
367    let mut env = vec![
368        ("HOME", WORKDIR.to_string()),
369        ("PATH", "/usr/local/bin:/usr/bin:/bin".to_string()),
370        ("AWS_REGION", access.region.as_str().to_string()),
371    ];
372    for spec in &specs {
373        env.extend(profile::env_pairs(
374            spec.agent,
375            spec.model(),
376            access.token.expose(),
377        ));
378    }
379    let mut env_file = String::new();
380    for (key, value) in env {
381        env_file.push_str(&format!("export {key}={}\n", sh_double_quote(&value)));
382    }
383
384    let mut files = vec![GuestFile {
385        path: ENV_FILE.to_string(),
386        contents: env_file.into_bytes(),
387        mode: "0600",
388    }];
389    if let Some(codex) = specs.iter().find(|spec| spec.agent == Agent::Codex) {
390        files.push(GuestFile {
391            path: CODEX_CONFIG_FILE.to_string(),
392            contents: profile::codex_config(codex.model(), &access.region).into_bytes(),
393            mode: "0600",
394        });
395    }
396    let marker = serde_json::json!({
397        "version": 1,
398        "region": access.region.as_str(),
399        "agents": specs.iter().map(|spec| serde_json::json!({
400            "agent": spec.agent.as_str(),
401            "model": spec.model(),
402            "cliVersion": spec.cli_version,
403        })).collect::<Vec<_>>(),
404    });
405    files.push(GuestFile {
406        path: MARKER_FILE.to_string(),
407        contents: serde_json::to_vec_pretty(&marker).map_err(|error| {
408            Error::new(
409                ErrorKind::Unexpected,
410                format!("the marker will not serialize: {error}"),
411            )
412        })?,
413        // World-readable is fine and useful: it names models, never a credential, and the
414        // agent itself may want to read it.
415        mode: "0644",
416    });
417    Ok(files)
418}
419
420/// Places the provisioning files and hands `/workspace` to the agent uid (AGENT-5).
421///
422/// The daemon writes every upload as root, so the `chown` is not optional and it runs
423/// as the daemon's own user, after the uploads, in one exec. Re-runnable: a second call
424/// with a fresh token overwrites the same files, which is how a 12-hour token is
425/// refreshed on a long-lived VM (AGENT-8).
426pub async fn install_access(
427    session: &Session,
428    specs: &[AgentSpec],
429    access: &BedrockAccess,
430) -> Result<(), Error> {
431    for file in provisioning_files(specs, access)? {
432        session
433            .upload_file(&file.path, &file.contents, Some(file.mode))
434            .await?;
435    }
436    let request = crate::protocol::exec::StartRequest {
437        exec_id: mint_exec_id(),
438        command: vec![format!("chown -R {AGENT_UID}:{AGENT_GID} {WORKDIR}")],
439        shell: true,
440        cwd: None,
441        env: std::collections::HashMap::new(),
442        user: None,
443        group: None,
444        timeout_sec: Some(CHOWN_TIMEOUT.as_secs_f64()),
445        stdin: false,
446        reap_group_on_exit: false,
447    };
448    let result = session.run_sync(request, CHOWN_TIMEOUT).await?;
449    if !result.succeeded() {
450        return Err(Error::new(
451            ErrorKind::ExecFailed,
452            format!(
453                "handing {WORKDIR} to uid {AGENT_UID} failed (exit {:?}): {}",
454                result.exit_code(),
455                result.stderr().trim()
456            ),
457        ));
458    }
459    Ok(())
460}
461
462/// The agents a running VM was provisioned with, read from the guest marker (AGENT-10).
463///
464/// Answers from the VM rather than from a local record so a process that holds only
465/// the identifier triple can prompt correctly. A VM with no marker is one `agent-up`
466/// never provisioned, and the error says which command fixes that.
467pub async fn installed_agents(session: &Session) -> Result<Vec<AgentSpec>, Error> {
468    let bytes = match session.download_file(MARKER_FILE).await {
469        Ok(bytes) => bytes,
470        Err(error) if error.wire_kind() == Some(crate::error::WireKind::NotFound) => {
471            return Err(Error::new(
472                ErrorKind::Precondition,
473                format!(
474                    "{MARKER_FILE} is not in this VM, so it was not provisioned as an agent \
475                     VM. `microvm agent-up --vm-name <NAME>` installs an agent and writes \
476                     the marker; `--agent` names one explicitly when the marker is absent."
477                ),
478            ));
479        }
480        Err(error) => return Err(error),
481    };
482    let marker: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| {
483        Error::new(
484            ErrorKind::Protocol,
485            format!("{MARKER_FILE} is not the JSON agent-up wrote: {error}"),
486        )
487    })?;
488    let rows = marker["agents"].as_array().ok_or_else(|| {
489        Error::new(
490            ErrorKind::Protocol,
491            format!("{MARKER_FILE} carries no `agents` array"),
492        )
493    })?;
494    let mut specs = Vec::with_capacity(rows.len());
495    for row in rows {
496        let name = row["agent"].as_str().unwrap_or_default();
497        let agent = Agent::parse(name).ok_or_else(|| {
498            Error::new(
499                ErrorKind::Protocol,
500                format!("{MARKER_FILE} names an agent this client has no profile for: {name:?}"),
501            )
502        })?;
503        let mut spec = AgentSpec::new(agent);
504        if let Some(model) = row["model"].as_str() {
505            spec.model = Some(model.to_string());
506        }
507        if let Some(version) = row["cliVersion"].as_str() {
508            spec.cli_version = Some(version.to_string());
509        }
510        specs.push(spec);
511    }
512    require_specs(&specs)?;
513    Ok(specs)
514}
515
516/// The knobs on one prompt.
517#[derive(Clone, Debug, Default)]
518pub struct PromptOptions {
519    /// A stable exec id, for a retry that must not spawn twice. `None` mints one.
520    pub exec_id: Option<String>,
521    /// A daemon-side wall-clock budget for the agent process. `None` leaves the exec
522    /// unbounded on the daemon's side; the caller's wait still has its own deadline.
523    pub timeout: Option<Duration>,
524}
525
526/// The start request for one task (AGENT-7). Pure; the test reads every field.
527pub fn prompt_request(
528    spec: &AgentSpec,
529    task: &str,
530    options: &PromptOptions,
531) -> Result<crate::protocol::exec::StartRequest, Error> {
532    if task.trim().is_empty() {
533        return Err(Error::invalid_arg(
534            "the task is empty. An agent prompted with nothing answers with nothing, at the \
535             price of a model call.",
536        ));
537    }
538    let command = format!(
539        ". {ENV_FILE} && {}",
540        profile::headless_command(spec.agent, &sh_single_quote(task))
541    );
542    Ok(crate::protocol::exec::StartRequest {
543        exec_id: options.exec_id.clone().unwrap_or_else(mint_exec_id),
544        command: vec![command],
545        shell: true,
546        cwd: Some(WORKDIR.to_string()),
547        // Empty on the wire: the environment file is the environment, sourced by the
548        // command, so the token never rides in a request body.
549        env: std::collections::HashMap::new(),
550        user: Some(AGENT_UID),
551        group: Some(AGENT_GID),
552        timeout_sec: options.timeout.map(|timeout| timeout.as_secs_f64()),
553        stdin: false,
554        reap_group_on_exit: false,
555    })
556}
557
558/// The headless command with `<TASK>` where the quoted task goes, for a caller who wants
559/// to run it through `exec --stream` or read what `prompt` would do.
560pub fn headless_command_template(agent: Agent) -> String {
561    format!(
562        ". {ENV_FILE} && {}",
563        profile::headless_command(agent, "'<TASK>'")
564    )
565}
566
567/// Starts one task and returns its handle. Wait, stream, poll, or ack through the
568/// handle, exactly as for any other exec.
569pub async fn prompt(
570    session: &Session,
571    spec: &AgentSpec,
572    task: &str,
573    options: &PromptOptions,
574) -> Result<ExecHandle, Error> {
575    session.run(prompt_request(spec, task, options)?).await
576}
577
578/// One VM with coding agents in it: the [`Sandbox`] plus the specs it was built for.
579///
580/// Runtime-checked, like the sandbox it wraps, and for the same reason: one object is
581/// what the bindings can hold.
582pub struct AgentVm {
583    sandbox: Sandbox,
584    specs: Vec<AgentSpec>,
585}
586
587impl std::fmt::Debug for AgentVm {
588    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
589        f.debug_struct("AgentVm")
590            .field("agents", &self.specs)
591            .field("sandbox", &self.sandbox)
592            .finish()
593    }
594}
595
596impl AgentVm {
597    /// Wraps a sandbox for `specs`. Refuses an empty or repeated set.
598    pub fn new(sandbox: Sandbox, specs: Vec<AgentSpec>) -> Result<Self, Error> {
599        require_specs(&specs)?;
600        Ok(Self { sandbox, specs })
601    }
602
603    /// A VM for Claude Code with the profile's defaults.
604    pub fn claude_code(sandbox: Sandbox) -> Self {
605        Self {
606            sandbox,
607            specs: vec![AgentSpec::new(Agent::ClaudeCode)],
608        }
609    }
610
611    /// A VM for Codex with the profile's defaults.
612    pub fn codex(sandbox: Sandbox) -> Self {
613        Self {
614            sandbox,
615            specs: vec![AgentSpec::new(Agent::Codex)],
616        }
617    }
618
619    /// The specs this VM carries, in profile order.
620    pub fn specs(&self) -> Vec<AgentSpec> {
621        ordered(&self.specs).into_iter().cloned().collect()
622    }
623
624    pub fn sandbox(&self) -> &Sandbox {
625        &self.sandbox
626    }
627
628    pub fn sandbox_mut(&mut self) -> &mut Sandbox {
629        &mut self.sandbox
630    }
631
632    /// The session, once [`AgentVm::launch`] has run.
633    pub fn session(&self) -> Option<&Session> {
634        self.sandbox.session()
635    }
636
637    /// The image name for these specs and this binary: the stem plus the first twelve
638    /// hex characters of the artifact content hash, so an unchanged binary and an
639    /// unchanged spec set name the image `build --reuse` would find (AGENT-3).
640    pub fn image_name(&self, request: &CreateImageRequest) -> String {
641        image_name_for(&self.sandbox, &self.specs, request)
642    }
643
644    /// The create request for this VM's image, named per [`AgentVm::image_name`].
645    ///
646    /// `code_artifact_uri` is left **empty** for the caller to fill: the upload is theirs
647    /// (S3 is not in this crate's dependency set), and the key they choose is usually
648    /// derived from the name this method just computed. `Sandbox::preflight` refuses a
649    /// request whose URI is still blank.
650    pub fn image_request(
651        &self,
652        binary: Vec<u8>,
653        build_role_arn: impl Into<String>,
654        size: SizeClass,
655    ) -> Result<CreateImageRequest, Error> {
656        image_request_for(&self.sandbox, &self.specs, binary, build_role_arn, size)
657    }
658
659    /// Builds the image. The caller has uploaded the artifact to the request's URI.
660    pub async fn build(&mut self, request: CreateImageRequest) -> Result<&Image, Error> {
661        self.sandbox.build_image(request).await
662    }
663
664    /// The launch request: egress on (AGENT-9), because neither agent reaches Bedrock
665    /// without it, and the token label set to the image name.
666    pub fn launch_request(
667        &self,
668        image_identifier: impl Into<String>,
669        execution_role_arn: Option<String>,
670    ) -> RunRequest {
671        launch_request_for(&self.specs, image_identifier, execution_role_arn)
672    }
673
674    /// Launches and waits for the daemon to answer.
675    pub async fn launch(&mut self, request: RunRequest) -> Result<&Session, Error> {
676        let session = self.sandbox.run(request).await?;
677        session
678            .wait_until_ready(crate::session::DEFAULT_READY_TIMEOUT)
679            .await?;
680        Ok(session)
681    }
682
683    fn require_session(&self) -> Result<&Session, Error> {
684        self.sandbox.session().ok_or_else(|| {
685            Error::new(
686                ErrorKind::Precondition,
687                "this agent VM has not been launched; call `launch` first.",
688            )
689        })
690    }
691
692    /// [`install_access`] on this VM's session.
693    pub async fn install_access(&self, access: &BedrockAccess) -> Result<(), Error> {
694        install_access(self.require_session()?, &self.specs, access).await
695    }
696
697    /// [`prompt`] on this VM's session, for one of the agents it carries.
698    pub async fn prompt(
699        &self,
700        agent: Agent,
701        task: &str,
702        options: &PromptOptions,
703    ) -> Result<ExecHandle, Error> {
704        let spec = spec_for(&self.specs, agent)?;
705        prompt(self.require_session()?, spec, task, options).await
706    }
707
708    /// Tears the VM down; see [`Sandbox::terminate`].
709    pub async fn terminate(&mut self, opts: TeardownOpts) -> TeardownReport {
710        self.sandbox.terminate(opts).await
711    }
712}
713
714#[cfg(test)]
715mod tests {
716    use std::sync::Arc;
717
718    use super::*;
719    use crate::control::artifact::{
720        require_daemon_cmd, require_matching_agentd_port, require_matching_from, require_workdir,
721    };
722    use crate::session::testing::{Recorder, Reply, session_with};
723
724    fn both() -> Vec<AgentSpec> {
725        vec![
726            AgentSpec::new(Agent::Codex),
727            AgentSpec::new(Agent::ClaudeCode),
728        ]
729    }
730
731    fn access() -> BedrockAccess {
732        BedrockAccess {
733            region: Region::UsEast1,
734            token: bedrock::mint_with(
735                &aws_credential_types::Credentials::new("AKID", "secret", None, None, "t"),
736                &Region::UsEast1,
737                Duration::from_secs(60),
738                std::time::SystemTime::UNIX_EPOCH,
739            )
740            .expect("mints")
741            .token,
742        }
743    }
744
745    // ── the Dockerfile (AGENT-2) ─────────────────────────────────────────────
746
747    /// **The derived Dockerfile passes every guard preflight runs**, and carries the four
748    /// things the recipe needs: the layers, the user, the owned WORKDIR, the daemon lines.
749    ///
750    /// **Falsification** — drop the `chown` from the mkdir line and the ownership assert
751    /// goes red; emit the `dnf` line per profile and the count assert goes red; move
752    /// `WORKDIR` above the layers and the order assert goes red.
753    #[test]
754    fn the_dockerfile_passes_the_guards_and_carries_the_recipe() {
755        let base = BaseImage::al2023();
756        let text = dockerfile(&both(), &base, 9000).expect("derives");
757
758        require_matching_from(&base, &text).expect("FROM pairs with the base");
759        require_workdir(&base, Some(&text)).expect("a WORKDIR is declared");
760        require_daemon_cmd(&text).expect("ENTRYPOINT [] + CMD agentd survive");
761        require_matching_agentd_port(9000, &text).expect("the port agrees");
762
763        assert_eq!(
764            text.matches("RUN dnf install").count(),
765            1,
766            "the system layer is emitted once for two profiles:\n{text}"
767        );
768        assert!(
769            text.contains("RUN dnf install -y --setopt=install_weak_deps=0 nodejs22 nodejs22-npm "),
770            "with weak deps off, bare `npm` is Node 18's and the npm layer exits 127:\n{text}"
771        );
772        assert!(
773            text.contains("RUN npm install -g @anthropic-ai/claude-code @openai/codex && npm"),
774            "both CLIs in profile order, in one layer:\n{text}"
775        );
776        assert!(
777            text.contains("agent:x:1000:1000::/workspace:/bin/bash"),
778            "{text}"
779        );
780        assert!(
781            text.contains("RUN mkdir -p /workspace && chown 1000:1000 /workspace"),
782            "{text}"
783        );
784        let layers_at = text.find("RUN npm install").expect("layers");
785        let workdir_at = text.find("WORKDIR /workspace").expect("workdir");
786        let cmd_at = text.find("CMD [\"/agentd\"]").expect("cmd");
787        assert!(layers_at < workdir_at && workdir_at < cmd_at, "{text}");
788    }
789
790    /// A version pin appears on the install line and nowhere else, so it changes the
791    /// Dockerfile text (and therefore the reuse hash) and nothing about the recipe.
792    #[test]
793    fn a_cli_version_pin_reaches_the_install_line() {
794        let base = BaseImage::al2023();
795        let pinned = vec![AgentSpec::new(Agent::ClaudeCode).with_cli_version("2.1.0")];
796        let text = dockerfile(&pinned, &base, 9000).expect("derives");
797        assert!(text.contains("@anthropic-ai/claude-code@2.1.0"), "{text}");
798        let plain = dockerfile(&[AgentSpec::new(Agent::ClaudeCode)], &base, 9000).expect("d");
799        assert_ne!(text, plain);
800    }
801
802    #[test]
803    fn an_empty_or_repeated_spec_set_is_refused_before_any_text_is_built() {
804        let base = BaseImage::al2023();
805        assert_eq!(
806            dockerfile(&[], &base, 9000).expect_err("empty").kind(),
807            ErrorKind::InvalidArg
808        );
809        let twice = vec![AgentSpec::new(Agent::Codex), AgentSpec::new(Agent::Codex)];
810        let error = dockerfile(&twice, &base, 9000).expect_err("repeated");
811        assert!(
812            error.to_string().contains("codex is named twice"),
813            "{error}"
814        );
815    }
816
817    /// The name stem is order-independent and spells the agents in profile order.
818    #[test]
819    fn the_image_stem_is_stable_under_spec_order() {
820        assert_eq!(image_stem(&both()), "agent-vm-claude-code-codex");
821        assert_eq!(
822            image_stem(&[AgentSpec::new(Agent::Codex)]),
823            "agent-vm-codex"
824        );
825    }
826
827    // ── the provisioning files (AGENT-5, AGENT-6) ────────────────────────────
828
829    /// **Claude alone gets two files; Codex adds its config.** The environment file sets
830    /// PATH and HOME, carries the token under each agent's variable, and every value is
831    /// double-quoted. The marker names the agents and their resolved models.
832    #[test]
833    fn a_codex_only_vm_still_gets_the_variable_codex_reads() {
834        let files = provisioning_files(&[AgentSpec::new(Agent::Codex)], &access()).expect("files");
835        let env = String::from_utf8(files[0].contents.clone()).expect("utf-8");
836        assert!(
837            env.contains("export AWS_BEARER_TOKEN_BEDROCK="),
838            "no Claude Code profile is present to export it, and Codex 401s without it:\n{env}"
839        );
840    }
841
842    #[test]
843    fn the_file_set_follows_the_profiles_and_the_env_file_is_quoted() {
844        let access = access();
845        let claude_only =
846            provisioning_files(&[AgentSpec::new(Agent::ClaudeCode)], &access).expect("files");
847        assert_eq!(
848            claude_only
849                .iter()
850                .map(|f| f.path.as_str())
851                .collect::<Vec<_>>(),
852            vec![ENV_FILE, MARKER_FILE]
853        );
854
855        let files = provisioning_files(&both(), &access).expect("files");
856        assert_eq!(
857            files.iter().map(|f| f.path.as_str()).collect::<Vec<_>>(),
858            vec![ENV_FILE, CODEX_CONFIG_FILE, MARKER_FILE]
859        );
860        let env = String::from_utf8(files[0].contents.clone()).expect("utf-8");
861        assert_eq!(files[0].mode, "0600");
862        assert!(
863            env.contains("export PATH=\"/usr/local/bin:/usr/bin:/bin\"\n"),
864            "{env}"
865        );
866        assert!(env.contains("export HOME=\"/workspace\"\n"), "{env}");
867        assert!(env.contains("export AWS_REGION=\"us-east-1\"\n"), "{env}");
868        assert!(
869            env.contains("export CLAUDE_CODE_USE_BEDROCK=\"1\"\n"),
870            "{env}"
871        );
872        assert!(
873            env.contains("export ANTHROPIC_MODEL=\"global.anthropic.claude-opus-5\"\n"),
874            "{env}"
875        );
876        let token = access.token.expose();
877        assert!(env.contains(&format!("export AWS_BEARER_TOKEN_BEDROCK=\"{token}\"\n")));
878        assert!(env.contains(&format!("export OPENAI_API_KEY=\"{token}\"\n")));
879
880        let config = String::from_utf8(files[1].contents.clone()).expect("utf-8");
881        assert!(
882            config.contains("bedrock-runtime.us-east-1.amazonaws.com/openai/v1"),
883            "{config}"
884        );
885        assert!(
886            config.contains("model = \"global.openai.gpt-5.6-sol\""),
887            "{config}"
888        );
889
890        let marker: serde_json::Value = serde_json::from_slice(&files[2].contents).expect("json");
891        assert_eq!(files[2].mode, "0644");
892        assert_eq!(marker["agents"][0]["agent"], "claude-code");
893        assert_eq!(marker["agents"][1]["agent"], "codex");
894        assert_eq!(marker["agents"][1]["model"], "global.openai.gpt-5.6-sol");
895        assert!(
896            !files[2]
897                .contents
898                .windows(token.len())
899                .any(|w| w == token.as_bytes()),
900            "the marker is world-readable and must never carry the token"
901        );
902    }
903
904    #[test]
905    fn shell_quoting_escapes_what_each_quote_style_cannot_hold() {
906        assert_eq!(sh_double_quote("a$b\"c`d\\e"), "\"a\\$b\\\"c\\`d\\\\e\"");
907        assert_eq!(sh_single_quote("it's"), "'it'\\''s'");
908        assert_eq!(sh_single_quote("plain"), "'plain'");
909    }
910
911    // ── install and read-back through the recorder ───────────────────────────
912
913    /// **The uploads go out in file order, then exactly one root chown.** The exec that
914    /// hands the workspace over runs as the daemon's own user (no `user` on the wire),
915    /// after every PUT.
916    #[tokio::test(start_paused = true)]
917    async fn install_access_uploads_then_chowns_as_root() {
918        let recorder = Recorder::with([
919            Reply::Body(200, Vec::new()),
920            Reply::Body(200, Vec::new()),
921            Reply::Body(200, Vec::new()),
922            Reply::ok(serde_json::json!({"exec_id":"c1","phase":"running"})),
923            Reply::ok(serde_json::json!({"exec_id":"c1","phase":"exited"})),
924            Reply::ok(serde_json::json!({
925                "exec_id":"c1","phase":"exited","exit_code":0,"signal":null,
926                "stdout":"","stderr":"","truncated":false,"writers_may_be_alive":false
927            })),
928        ]);
929        let (session, _, _) = session_with(Arc::clone(&recorder));
930
931        install_access(&session, &both(), &access())
932            .await
933            .expect("installs");
934
935        let seen = recorder.requests();
936        assert_eq!(seen.len(), 6, "{seen:#?}");
937        for (request, (encoded, mode)) in seen.iter().zip([
938            ("%2Fworkspace%2F.agent-env", "0600"),
939            ("%2Fworkspace%2F.codex%2Fconfig.toml", "0600"),
940            ("%2Fworkspace%2F.agent-vm.json", "0644"),
941        ]) {
942            assert_eq!(request.method, "PUT");
943            assert_eq!(
944                request.path,
945                format!("/v1/fs/file?path={encoded}&mode={mode}"),
946                "each file goes to the file route with its own mode"
947            );
948        }
949        assert_eq!(seen[3].path, "/v1/exec/start");
950        let start: serde_json::Value = serde_json::from_slice(&seen[3].body).expect("json");
951        assert_eq!(start["command"][0], "chown -R 1000:1000 /workspace");
952        assert_eq!(start["shell"], true);
953        assert!(
954            start["user"].is_null(),
955            "the chown runs as the daemon's own user"
956        );
957        assert_eq!(seen[5].path, "/v1/exec/c1/ack");
958    }
959
960    /// A failed chown is a failure, not a warning: a workspace the agent cannot write is
961    /// a VM every prompt fails in.
962    #[tokio::test(start_paused = true)]
963    async fn a_failed_chown_fails_the_install() {
964        let recorder = Recorder::with([
965            Reply::Body(200, Vec::new()),
966            Reply::Body(200, Vec::new()),
967            Reply::ok(serde_json::json!({"exec_id":"c1","phase":"running"})),
968            Reply::ok(serde_json::json!({"exec_id":"c1","phase":"exited"})),
969            Reply::ok(serde_json::json!({
970                "exec_id":"c1","phase":"exited","exit_code":1,"signal":null,
971                "stdout":"","stderr":"chown: cannot access","truncated":false,
972                "writers_may_be_alive":false
973            })),
974        ]);
975        let (session, _, _) = session_with(recorder);
976        let error = install_access(&session, &[AgentSpec::new(Agent::ClaudeCode)], &access())
977            .await
978            .expect_err("fails");
979        assert_eq!(error.kind(), ErrorKind::ExecFailed);
980        assert!(
981            error.to_string().contains("chown: cannot access"),
982            "{error}"
983        );
984    }
985
986    /// **The marker round-trips**: what `provisioning_files` wrote is what
987    /// `installed_agents` reads back, models included.
988    #[tokio::test]
989    async fn installed_agents_reads_the_marker_back() {
990        let specs = vec![
991            AgentSpec::new(Agent::ClaudeCode).with_model("global.anthropic.claude-sonnet-5"),
992            AgentSpec::new(Agent::Codex).with_cli_version("0.50.0"),
993        ];
994        let files = provisioning_files(&specs, &access()).expect("files");
995        let marker = files.last().expect("marker").contents.clone();
996        let recorder = Recorder::with([Reply::Body(200, marker)]);
997        let (session, _, _) = session_with(recorder);
998
999        let read = installed_agents(&session).await.expect("reads");
1000        assert_eq!(read.len(), 2);
1001        assert_eq!(read[0].agent, Agent::ClaudeCode);
1002        assert_eq!(read[0].model(), "global.anthropic.claude-sonnet-5");
1003        assert_eq!(read[1].agent, Agent::Codex);
1004        assert_eq!(read[1].cli_version.as_deref(), Some("0.50.0"));
1005        assert_eq!(read[1].model(), "global.openai.gpt-5.6-sol");
1006    }
1007
1008    /// A VM with no marker is a precondition failure naming the command that writes one,
1009    /// and any other refusal propagates as itself.
1010    #[tokio::test]
1011    async fn a_missing_marker_names_agent_up_and_other_failures_propagate() {
1012        let recorder = Recorder::with([
1013            Reply::Body(404, b"not found".to_vec()),
1014            Reply::Body(503, b"not bootstrapped".to_vec()),
1015        ]);
1016        let (session, _, _) = session_with(recorder);
1017        let error = installed_agents(&session).await.expect_err("absent");
1018        assert_eq!(error.kind(), ErrorKind::Precondition);
1019        assert!(error.to_string().contains("agent-up"), "{error}");
1020        let other = installed_agents(&session).await.expect_err("503");
1021        assert_ne!(other.kind(), ErrorKind::Precondition);
1022    }
1023
1024    // ── the prompt (AGENT-7) ─────────────────────────────────────────────────
1025
1026    /// **Every field of the start request is the recipe's.** uid and gid 1000, the
1027    /// workspace as cwd, an empty wire env, the env file sourced first, the task
1028    /// single-quoted, the daemon-side timeout only when asked for.
1029    #[test]
1030    fn the_prompt_request_runs_the_agent_demoted_in_the_workspace() {
1031        let spec = AgentSpec::new(Agent::ClaudeCode);
1032        let request = prompt_request(
1033            &spec,
1034            "count the files; it's quick",
1035            &PromptOptions {
1036                exec_id: Some("p1".into()),
1037                timeout: Some(Duration::from_secs(120)),
1038            },
1039        )
1040        .expect("builds");
1041        assert_eq!(request.exec_id, "p1");
1042        assert_eq!(request.user, Some(1000));
1043        assert_eq!(request.group, Some(1000));
1044        assert_eq!(request.cwd.as_deref(), Some("/workspace"));
1045        assert!(request.shell);
1046        assert!(
1047            request.env.is_empty(),
1048            "the token never rides in the request"
1049        );
1050        assert_eq!(request.timeout_sec, Some(120.0));
1051        assert!(!request.stdin);
1052        assert_eq!(
1053            request.command,
1054            vec![
1055                ". /workspace/.agent-env && claude -p 'count the files; it'\\''s quick' \
1056                 --allowedTools Bash,Read,Edit,Write,Grep,Glob"
1057                    .to_string()
1058            ]
1059        );
1060
1061        let codex = prompt_request(
1062            &AgentSpec::new(Agent::Codex),
1063            "hi",
1064            &PromptOptions::default(),
1065        )
1066        .expect("builds");
1067        assert!(
1068            codex.command[0].contains("codex exec --skip-git-repo-check -s workspace-write 'hi'")
1069        );
1070        assert!(codex.timeout_sec.is_none());
1071        assert!(!codex.exec_id.is_empty(), "an id was minted");
1072    }
1073
1074    #[test]
1075    fn an_empty_task_is_refused_locally() {
1076        let error = prompt_request(
1077            &AgentSpec::new(Agent::Codex),
1078            "  \n",
1079            &PromptOptions::default(),
1080        )
1081        .expect_err("empty");
1082        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1083    }
1084
1085    // ── AgentVm ──────────────────────────────────────────────────────────────
1086
1087    fn sandbox() -> Sandbox {
1088        Sandbox::with_control_plane(crate::control::ControlPlane::with_transport(
1089            Arc::new(crate::control::fake::FakeControlPlane::new()),
1090            Region::UsEast1,
1091            Arc::new(crate::control::fake::TestClock::new()),
1092        ))
1093    }
1094
1095    /// **The image request is named by content and the URI is left to the caller.**
1096    /// Same binary and specs give the same name; a different binary gives a different one.
1097    #[test]
1098    fn the_image_request_is_content_named_and_egress_is_on_at_launch() {
1099        let vm = AgentVm::new(sandbox(), both()).expect("specs");
1100        let a = vm
1101            .image_request(
1102                b"daemon".to_vec(),
1103                "arn:aws:iam::123456789012:role/b",
1104                DEFAULT_SIZE,
1105            )
1106            .expect("request");
1107        let b = vm
1108            .image_request(
1109                b"daemon".to_vec(),
1110                "arn:aws:iam::123456789012:role/b",
1111                DEFAULT_SIZE,
1112            )
1113            .expect("request");
1114        let c = vm
1115            .image_request(
1116                b"other".to_vec(),
1117                "arn:aws:iam::123456789012:role/b",
1118                DEFAULT_SIZE,
1119            )
1120            .expect("request");
1121        assert_eq!(a.name, b.name);
1122        assert_ne!(a.name, c.name);
1123        assert!(
1124            a.name.starts_with("agent-vm-claude-code-codex-"),
1125            "{}",
1126            a.name
1127        );
1128        assert_eq!(a.name.len(), "agent-vm-claude-code-codex-".len() + 12);
1129        assert_eq!(a.token_scope.as_deref(), Some(a.name.as_str()));
1130        assert!(a.code_artifact_uri.is_empty(), "the upload is the caller's");
1131        assert!(
1132            a.dockerfile
1133                .as_deref()
1134                .is_some_and(|d| d.contains("@openai/codex"))
1135        );
1136        assert_eq!(a.size, SizeClass::Mib1024);
1137
1138        let launch = vm.launch_request("arn:image", Some("arn:role".into()));
1139        assert!(
1140            launch.egress,
1141            "neither agent reaches Bedrock without egress"
1142        );
1143        assert_eq!(launch.image_identifier.as_deref(), Some("arn:image"));
1144        assert_eq!(
1145            launch.token_scope.as_deref(),
1146            Some("agent-vm-claude-code-codex")
1147        );
1148    }
1149
1150    /// The named constructors carry the request's own names, and a prompt for an agent
1151    /// the VM does not carry is refused before any wire call.
1152    #[tokio::test]
1153    async fn the_named_constructors_carry_one_agent_each() {
1154        let claude = AgentVm::claude_code(sandbox());
1155        assert_eq!(claude.specs(), vec![AgentSpec::new(Agent::ClaudeCode)]);
1156        let codex = AgentVm::codex(sandbox());
1157        assert_eq!(codex.specs(), vec![AgentSpec::new(Agent::Codex)]);
1158
1159        let error = codex
1160            .prompt(Agent::ClaudeCode, "hi", &PromptOptions::default())
1161            .await
1162            .expect_err("wrong agent");
1163        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1164        assert!(error.to_string().contains("carries codex"), "{error}");
1165        let unlaunched = codex
1166            .prompt(Agent::Codex, "hi", &PromptOptions::default())
1167            .await
1168            .expect_err("no session");
1169        assert_eq!(unlaunched.kind(), ErrorKind::Precondition);
1170    }
1171}