Skip to main content

microvms_app/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, ExecResult, Session};
52use crate::sizing::SizeClass;
53
54pub use bedrock::{BearerToken, Minted};
55pub use profile::{Agent, AgentPermissionMode, 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 `crates/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.
174///
175/// The name [`ensure_request_for`]'s ensure gives the image (#258): the stem, then ensure's
176/// identity over the artifact's inputs, the base, the size class and the create-only fields
177/// (#280), so the two-step path (`image_request`, the caller's upload, `build`) and the ensure
178/// name one image.
179pub fn image_name_for(
180    sandbox: &Sandbox,
181    specs: &[AgentSpec],
182    request: &CreateImageRequest,
183) -> String {
184    let identity = crate::control::ensure::create_identity_hash(
185        &sandbox.artifact_content_hash_for(request),
186        request,
187    );
188    let stem = image_stem(specs);
189    // The stem is `agent-vm-` and profile names, all of them characters the ImageName
190    // pattern admits, well inside its length; the fallback is that same name spelled out.
191    crate::control::ensure::ensured_image_name(&stem, &identity)
192        .unwrap_or_else(|_| format!("{stem}-{}", &identity[..12]))
193}
194
195/// [`AgentVm::ensure_image`]'s request, for a caller holding the sandbox and the specs
196/// separately: the stem as the name prefix, the agents' Dockerfile on the managed base, and
197/// the artifact under `s3://<s3_bucket>/<s3_key_prefix>/<name>/artifact.zip`.
198pub fn ensure_request_for(
199    sandbox: &Sandbox,
200    specs: &[AgentSpec],
201    binary: Vec<u8>,
202    build_role_arn: impl Into<String>,
203    size: SizeClass,
204    s3_bucket: impl Into<String>,
205    s3_key_prefix: Option<String>,
206) -> Result<crate::control::EnsureImageRequest, Error> {
207    require_specs(specs)?;
208    let base = BaseImage::al2023();
209    let dockerfile = dockerfile(specs, &base, sandbox.port())?;
210    let mut request = crate::control::EnsureImageRequest::new(
211        image_stem(specs),
212        binary,
213        dockerfile,
214        s3_bucket,
215        build_role_arn,
216    );
217    request.base_image = Some(base);
218    request.size = size;
219    request.s3_key_prefix = s3_key_prefix;
220    Ok(request)
221}
222
223/// [`AgentVm::image_request`] for a caller holding the sandbox and the specs separately.
224pub fn image_request_for(
225    sandbox: &Sandbox,
226    specs: &[AgentSpec],
227    binary: Vec<u8>,
228    build_role_arn: impl Into<String>,
229    size: SizeClass,
230) -> Result<CreateImageRequest, Error> {
231    require_specs(specs)?;
232    let base = BaseImage::al2023();
233    let dockerfile = dockerfile(specs, &base, sandbox.port())?;
234    let mut request =
235        CreateImageRequest::new(image_stem(specs), binary, String::new(), build_role_arn);
236    request.base_image = base;
237    request.dockerfile = Some(dockerfile);
238    request.size = size;
239    let name = image_name_for(sandbox, specs, &request);
240    request.name = name.clone();
241    request.token_scope = Some(name);
242    Ok(request)
243}
244
245/// [`AgentVm::launch_request`] for a caller holding the specs separately.
246pub fn launch_request_for(
247    specs: &[AgentSpec],
248    image_identifier: impl Into<String>,
249    execution_role_arn: Option<String>,
250) -> RunRequest {
251    let identifier = image_identifier.into();
252    let mut request = RunRequest::new().with_image(&identifier).with_egress();
253    request.execution_role_arn = execution_role_arn;
254    request.token_scope = Some(image_stem(specs));
255    request
256}
257
258/// The agents a VM carries when its caller names none: Claude Code alone, with its profile's
259/// defaults.
260///
261/// One list for every surface that takes an optional set (both bindings' constructors and
262/// adopters), so the default can't differ between them.
263pub fn default_specs() -> Vec<AgentSpec> {
264    vec![AgentSpec::new(Agent::ClaudeCode)]
265}
266
267/// The session of the launched VM `sandbox` addresses, or the refusal a call made before
268/// `launch` meets: [`AgentVm`]'s own, and a binding's that holds the sandbox apart from the
269/// specs.
270pub fn launched_session(sandbox: &Sandbox) -> Result<&Session, Error> {
271    sandbox.session().ok_or_else(|| {
272        Error::new(
273            ErrorKind::Precondition,
274            "this agent VM has not been launched; call `launch` first.",
275        )
276    })
277}
278
279/// The egress posture every agent VM launches with (AGENT-9).
280///
281/// Derived from the launch request rather than asserted, so the CLI's `agent-up` envelope
282/// reports what [`launch_request_for`] actually asks for. `open`, and deliberately so:
283/// neither coding agent reaches Bedrock without the connector. An operator who wants an
284/// agent VM that cannot reach anything else needs a network control this platform does not
285/// offer — see `docs/TRUST.md`, **Egress**.
286pub fn agent_vm_egress_posture() -> crate::control::EgressPosture {
287    launch_request_for(&[], "arn:image", None).egress_posture()
288}
289
290/// The stem of the image name: `agent-vm-<agents>` in profile order (AGENT-3). The
291/// caller appends the artifact content hash, exactly as `build --reuse` does.
292pub fn image_stem(specs: &[AgentSpec]) -> String {
293    let agents: Vec<&str> = ordered(specs)
294        .into_iter()
295        .map(|spec| spec.agent.as_str())
296        .collect();
297    format!("agent-vm-{}", agents.join("-"))
298}
299
300/// The Dockerfile for an image carrying `specs` (AGENT-2).
301///
302/// Built by splicing into [`default_dockerfile`]'s output rather than by re-listing its
303/// lines, so the stanza this produces *is* the default build's stanza plus layers: a
304/// change to the daemon lines there reaches here with no second copy to update. The
305/// splice points are the two lines the stanza is guaranteed to carry (`RUN chmod 0755
306/// /agentd` and `RUN mkdir -p /workspace`), and the test asserts both were found.
307pub fn dockerfile(specs: &[AgentSpec], base: &BaseImage, port: u16) -> Result<String, Error> {
308    require_specs(specs)?;
309    let stanza = default_dockerfile(port, Some(WORKDIR), base, None);
310    let mut lines: Vec<String> = stanza.lines().map(str::to_string).collect();
311
312    let chmod = lines
313        .iter()
314        .position(|line| line == "RUN chmod 0755 /agentd")
315        .ok_or_else(|| stanza_drift("RUN chmod 0755 /agentd"))?;
316    let mut layers = vec![
317        String::new(),
318        "# The coding agents. Node 22 runs both CLIs; the rest is what a coding agent".to_string(),
319        "# expects of a working shell. Unpinned unless a cli_version was given, so the".to_string(),
320        "# image carries the registry's latest at build time and a pin changes the reuse hash."
321            .to_string(),
322        format!(
323            "RUN dnf install -y --setopt=install_weak_deps=0 {} && dnf clean all",
324            profile::SYSTEM_PACKAGES
325        ),
326    ];
327    let packages: Vec<String> = ordered(specs)
328        .into_iter()
329        .map(AgentSpec::npm_spec)
330        .collect();
331    layers.push(format!(
332        "RUN npm install -g {} && npm cache clean --force",
333        packages.join(" ")
334    ));
335    layers.extend([
336        String::new(),
337        "# The non-root user the agents run as. Appended directly because `useradd` is not"
338            .to_string(),
339        "# in the minimal base. Not decoration: Claude Code refuses --dangerously-skip-permissions"
340            .to_string(),
341        "# as root and then denies its own tool calls while still reporting (measured: zero"
342            .to_string(),
343        "# shell calls as uid 0, 147 as uid 1000 on the same task).".to_string(),
344        format!(
345            "RUN echo \"agent:x:{AGENT_UID}:{AGENT_GID}::{WORKDIR}:/bin/bash\" >> /etc/passwd \\"
346        ),
347        format!("    && echo \"agent:x:{AGENT_GID}:\" >> /etc/group"),
348    ]);
349    lines.splice(chmod + 1..chmod + 1, layers);
350
351    let mkdir = lines
352        .iter()
353        .position(|line| line == &format!("RUN mkdir -p {WORKDIR}"))
354        .ok_or_else(|| stanza_drift("RUN mkdir -p /workspace"))?;
355    // Owned by the agent uid, because WORKDIR is where every exec lands and a root-owned
356    // one fails a demoted workload at its first write (docs/EMBEDDING.md).
357    lines[mkdir] = format!("RUN mkdir -p {WORKDIR} && chown {AGENT_UID}:{AGENT_GID} {WORKDIR}");
358
359    let mut text = lines.join("\n");
360    text.push('\n');
361    Ok(text)
362}
363
364fn stanza_drift(line: &str) -> Error {
365    Error::new(
366        ErrorKind::Unexpected,
367        format!(
368            "default_dockerfile no longer emits `{line}`, so the agent layers have no splice \
369             point. Update agents::dockerfile beside the stanza change."
370        ),
371    )
372}
373
374/// Model access for the agents: a Bedrock bearer token minted for a region.
375///
376/// A struct rather than an enum until a second access path exists to name
377/// (`docs/AGENT-VMS.md`, phase 2).
378#[derive(Clone, Debug)]
379pub struct BedrockAccess {
380    pub region: Region,
381    pub token: BearerToken,
382}
383
384/// One file to place in the guest.
385#[derive(Clone, Debug, Eq, PartialEq)]
386pub struct GuestFile {
387    pub path: String,
388    pub contents: Vec<u8>,
389    /// Octal, as the file route takes it.
390    pub mode: &'static str,
391}
392
393/// Wraps `value` in double quotes for `sh`, escaping the four characters that are
394/// special inside them. Used for the environment file, whose values are model ids, a
395/// region, and a token (base64 plus the prefix), none of which contain them today; the
396/// escaping is what keeps that "today" from mattering.
397pub fn sh_double_quote(value: &str) -> String {
398    let mut quoted = String::with_capacity(value.len() + 2);
399    quoted.push('"');
400    for ch in value.chars() {
401        if matches!(ch, '"' | '\\' | '$' | '`') {
402            quoted.push('\\');
403        }
404        quoted.push(ch);
405    }
406    quoted.push('"');
407    quoted
408}
409
410/// Wraps `value` in single quotes for `sh`. A single quote inside becomes `'\''`, the
411/// one sequence that is literal under every POSIX shell. Used for the task text, which
412/// is the caller's prose and may contain anything.
413pub fn sh_single_quote(value: &str) -> String {
414    let mut quoted = String::with_capacity(value.len() + 2);
415    quoted.push('\'');
416    for ch in value.chars() {
417        if ch == '\'' {
418            quoted.push_str("'\\''");
419        } else {
420            quoted.push(ch);
421        }
422    }
423    quoted.push('\'');
424    quoted
425}
426
427/// The files `install_access` places (AGENT-5, AGENT-6): the environment file, Codex's
428/// config when Codex is present, and the marker. Pure, so a test reads the exact bytes.
429pub fn provisioning_files(
430    specs: &[AgentSpec],
431    access: &BedrockAccess,
432) -> Result<Vec<GuestFile>, Error> {
433    require_specs(specs)?;
434    let specs = ordered(specs);
435
436    // PATH first and explicit: the daemon spawns execs from an empty environment, and
437    // Claude Code's Bash tool snapshots the shell it starts from, so without this line
438    // the agent's subshells find no `ls`, `wc`, or `python3`.
439    let mut env = vec![
440        ("HOME", WORKDIR.to_string()),
441        ("PATH", "/usr/local/bin:/usr/bin:/bin".to_string()),
442        ("AWS_REGION", access.region.as_str().to_string()),
443    ];
444    for spec in &specs {
445        env.extend(profile::env_pairs(
446            spec.agent,
447            spec.model(),
448            access.token.expose(),
449        ));
450    }
451    let mut env_file = String::new();
452    for (key, value) in env {
453        env_file.push_str(&format!("export {key}={}\n", sh_double_quote(&value)));
454    }
455
456    let mut files = vec![GuestFile {
457        path: ENV_FILE.to_string(),
458        contents: env_file.into_bytes(),
459        mode: "0600",
460    }];
461    if let Some(codex) = specs.iter().find(|spec| spec.agent == Agent::Codex) {
462        files.push(GuestFile {
463            path: CODEX_CONFIG_FILE.to_string(),
464            contents: profile::codex_config(codex.model(), &access.region).into_bytes(),
465            mode: "0600",
466        });
467    }
468    let marker = serde_json::json!({
469        "version": 1,
470        "region": access.region.as_str(),
471        "agents": specs.iter().map(|spec| serde_json::json!({
472            "agent": spec.agent.as_str(),
473            "model": spec.model(),
474            "cliVersion": spec.cli_version,
475        })).collect::<Vec<_>>(),
476    });
477    files.push(GuestFile {
478        path: MARKER_FILE.to_string(),
479        contents: serde_json::to_vec_pretty(&marker).map_err(|error| {
480            Error::new(
481                ErrorKind::Unexpected,
482                format!("the marker will not serialize: {error}"),
483            )
484        })?,
485        // World-readable is fine and useful: it names models, never a credential, and the
486        // agent itself may want to read it.
487        mode: "0644",
488    });
489    Ok(files)
490}
491
492/// Places the provisioning files and hands `/workspace` to the agent uid (AGENT-5).
493///
494/// The daemon writes every upload as root, so the `chown` is not optional and it runs
495/// as root, after the uploads, in one exec. Root is asked for as uid 0 rather than by naming
496/// no user, because an image that sets `AGENTD_DEFAULT_USER` runs a start that names no user
497/// as that user, and the `chown` would fail as anyone else. Re-runnable: a second call
498/// with a fresh token overwrites the same files, which is how a 12-hour token is
499/// refreshed on a long-lived VM (AGENT-8).
500pub async fn install_access(
501    session: &Session,
502    specs: &[AgentSpec],
503    access: &BedrockAccess,
504) -> Result<(), Error> {
505    for file in provisioning_files(specs, access)? {
506        session
507            .upload_file(&file.path, &file.contents, Some(file.mode))
508            .await?;
509    }
510    let request = crate::protocol::exec::StartRequest::new(
511        session.mint_exec_id(),
512        vec![format!("chown -R {AGENT_UID}:{AGENT_GID} {WORKDIR}")],
513    )
514    .with_shell(true)
515    .with_user(Some(crate::session::ROOT_UID.into()))
516    .with_timeout_sec(Some(CHOWN_TIMEOUT.as_secs_f64()));
517    let result = session.run_sync(request, CHOWN_TIMEOUT).await?;
518    if !result.succeeded() {
519        return Err(Error::new(
520            ErrorKind::ExecFailed,
521            format!(
522                "handing {WORKDIR} to uid {AGENT_UID} failed (exit {:?}): {}",
523                result.exit_code(),
524                result.stderr().trim()
525            ),
526        ));
527    }
528    Ok(())
529}
530
531/// The agents a running VM was provisioned with, read from the guest marker (AGENT-10).
532///
533/// Answers from the VM rather than from a local record so a process that holds only
534/// the identifier triple can prompt correctly. A VM with no marker is one `agent-up`
535/// never provisioned, and the error says which command fixes that.
536pub async fn installed_agents(session: &Session) -> Result<Vec<AgentSpec>, Error> {
537    let bytes = match session.download_file(MARKER_FILE).await {
538        Ok(bytes) => bytes,
539        Err(error) if error.wire_kind() == Some(crate::error::WireKind::NotFound) => {
540            return Err(Error::new(
541                ErrorKind::Precondition,
542                format!(
543                    "{MARKER_FILE} is not in this VM, so it was not provisioned as an agent \
544                     VM. `microvm agent-up --vm-name <NAME>` installs an agent and writes \
545                     the marker; `--agent` names one explicitly when the marker is absent."
546                ),
547            ));
548        }
549        Err(error) => return Err(error),
550    };
551    let marker: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| {
552        Error::new(
553            ErrorKind::Protocol,
554            format!("{MARKER_FILE} is not the JSON agent-up wrote: {error}"),
555        )
556    })?;
557    let rows = marker["agents"].as_array().ok_or_else(|| {
558        Error::new(
559            ErrorKind::Protocol,
560            format!("{MARKER_FILE} carries no `agents` array"),
561        )
562    })?;
563    let mut specs = Vec::with_capacity(rows.len());
564    for row in rows {
565        let name = row["agent"].as_str().unwrap_or_default();
566        let agent = Agent::parse(name).ok_or_else(|| {
567            Error::new(
568                ErrorKind::Protocol,
569                format!("{MARKER_FILE} names an agent this client has no profile for: {name:?}"),
570            )
571        })?;
572        let mut spec = AgentSpec::new(agent);
573        if let Some(model) = row["model"].as_str() {
574            spec.model = Some(model.to_string());
575        }
576        if let Some(version) = row["cliVersion"].as_str() {
577            spec.cli_version = Some(version.to_string());
578        }
579        specs.push(spec);
580    }
581    require_specs(&specs)?;
582    Ok(specs)
583}
584
585/// The daemon-side budget for one `--version` probe.
586///
587/// Sized for the first run on a fresh VM, not a warm one. A launched VM reads its disk on
588/// demand, so the first exec of an agent pages the whole executable in: measured
589/// 2026-09-24 in us-east-1, Codex 0.156.1's first `--version` took 13.0 s and the second
590/// 0.1 s (Claude Code's took 7 ms). The earlier 10 s budget killed that first probe and
591/// `agent-prompt --agent codex` failed as a precondition on a healthy VM.
592pub const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(60);
593
594/// The client's wait for the probe: the daemon budget plus room for the round trips.
595pub const VERSION_PROBE_WAIT: Duration = Duration::from_secs(90);
596
597/// Probe the installed executable as the guest agent UID, without sourcing credentials.
598/// Only a numeric version is returned; arbitrary guest stdout/stderr never enters reports.
599pub async fn installed_version(session: &Session, agent: Agent) -> Result<String, Error> {
600    let request = crate::protocol::exec::StartRequest::new(
601        session.mint_exec_id(),
602        profile::version_command(agent),
603    )
604    .with_cwd(Some(WORKDIR.to_string()))
605    .with_env(std::collections::HashMap::from([(
606        "PATH".into(),
607        "/usr/local/bin:/usr/bin:/bin".into(),
608    )]))
609    .with_user(Some(AGENT_UID.into()))
610    .with_group(Some(AGENT_GID.into()))
611    .with_timeout_sec(Some(VERSION_PROBE_TIMEOUT.as_secs_f64()))
612    .with_reap_group_on_exit(true);
613    let result = session.run_sync(request, VERSION_PROBE_WAIT).await?;
614    if result.succeeded()
615        && let Some(version) = reported_version(result.stdout())
616    {
617        return Ok(version.to_string());
618    }
619    if result
620        .outcome
621        .as_ref()
622        .is_some_and(|outcome| outcome.timed_out)
623    {
624        return Err(Error::new(
625            ErrorKind::Timeout,
626            format!(
627                "{agent} --version did not answer within {}s; the first run on a fresh VM \
628                 reads the executable from a demand-paged disk, so retry once before \
629                 suspecting the image",
630                VERSION_PROBE_TIMEOUT.as_secs(),
631            ),
632        ));
633    }
634    Err(Error::new(
635        ErrorKind::Precondition,
636        format!(
637            "could not read {agent}'s installed version; verify the pinned agent executable in the image",
638        ),
639    ))
640}
641
642fn reported_version(output: &str) -> Option<&str> {
643    output.split_ascii_whitespace().find(|word| {
644        word.len() <= 32
645            && word.split('.').count() == 3
646            && word
647                .split('.')
648                .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
649    })
650}
651
652/// The knobs on one prompt.
653#[derive(Clone, Debug, Default)]
654pub struct PromptOptions {
655    /// A stable exec id, for a retry that must not spawn twice. `None` mints one.
656    pub exec_id: Option<String>,
657    /// A daemon-side wall-clock budget for the agent process. `None` leaves the exec
658    /// unbounded on the daemon's side; the caller's wait still has its own deadline.
659    pub timeout: Option<Duration>,
660    /// Guest agent permissions, independent of VM privileges. Defaults to agent-default.
661    pub permission_mode: AgentPermissionMode,
662    /// Terminate residual test servers/fuzzers when the main agent exits. Opt-in.
663    pub reap_group_on_exit: bool,
664}
665
666/// The start request for one task (AGENT-7). Pure; the test reads every field.
667///
668/// `mint` supplies the exec id when `options` carries none. [`prompt`] mints from its
669/// session's clock; `microvms_core::agents::prompt_request` is this with the production one.
670pub fn prompt_request_with(
671    spec: &AgentSpec,
672    task: &str,
673    options: &PromptOptions,
674    mint: impl FnOnce() -> String,
675) -> Result<crate::protocol::exec::StartRequest, Error> {
676    if task.trim().is_empty() {
677        return Err(Error::invalid_arg(
678            "the task is empty. An agent prompted with nothing answers with nothing, at the \
679             price of a model call.",
680        ));
681    }
682    if options.timeout.is_some_and(|timeout| timeout.is_zero()) {
683        return Err(Error::invalid_arg("execution timeout must be positive"));
684    }
685    let command = format!(
686        ". {ENV_FILE} && {}",
687        profile::headless_command(spec.agent, &sh_single_quote(task), options.permission_mode)
688    );
689    // No `with_env`: the environment file is the environment, sourced by the command, so
690    // the token never rides in a request body and the wire's map stays empty.
691    Ok(crate::protocol::exec::StartRequest::new(
692        options.exec_id.clone().unwrap_or_else(mint),
693        vec![command],
694    )
695    .with_shell(true)
696    .with_cwd(Some(WORKDIR.to_string()))
697    .with_user(Some(AGENT_UID.into()))
698    .with_group(Some(AGENT_GID.into()))
699    .with_timeout_sec(options.timeout.map(|timeout| timeout.as_secs_f64()))
700    .with_reap_group_on_exit(options.reap_group_on_exit))
701}
702
703/// The headless command with `<TASK>` where the quoted task goes, for a caller who wants
704/// to run it through `exec --stream` or read what `prompt` would do.
705pub fn headless_command_template(agent: Agent) -> String {
706    format!(
707        ". {ENV_FILE} && {}",
708        profile::headless_command(agent, "'<TASK>'", AgentPermissionMode::AgentDefault)
709    )
710}
711
712/// Starts one task and returns its handle. Wait, stream, poll, or ack through the
713/// handle, exactly as for any other exec.
714pub async fn prompt(
715    session: &Session,
716    spec: &AgentSpec,
717    task: &str,
718    options: &PromptOptions,
719) -> Result<ExecHandle, Error> {
720    session
721        .run(prompt_request_with(spec, task, options, || {
722            session.mint_exec_id()
723        })?)
724        .await
725}
726
727/// Starts one task, waits for it and acks it: the task's whole result (#266).
728///
729/// `options.timeout` is the daemon's budget for the agent process and the caller's wait at
730/// once, [`DEFAULT_PROMPT_TIMEOUT`] when it's `None`. One figure for both, so the daemon stops
731/// the agent when the caller stops waiting for it, rather than leaving it to run on behind a
732/// wait that gave up.
733pub async fn prompt_sync(
734    session: &Session,
735    spec: &AgentSpec,
736    task: &str,
737    options: &PromptOptions,
738) -> Result<ExecResult, Error> {
739    let timeout = options.timeout.unwrap_or(DEFAULT_PROMPT_TIMEOUT);
740    let bounded = PromptOptions {
741        timeout: Some(timeout),
742        ..options.clone()
743    };
744    let request = prompt_request_with(spec, task, &bounded, || session.mint_exec_id())?;
745    session.run_sync(request, timeout).await
746}
747
748/// One VM with coding agents in it: the [`Sandbox`] plus the specs it was built for.
749///
750/// Runtime-checked, like the sandbox it wraps, and for the same reason: one object is
751/// what the bindings can hold.
752pub struct AgentVm {
753    sandbox: Sandbox,
754    specs: Vec<AgentSpec>,
755}
756
757impl std::fmt::Debug for AgentVm {
758    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
759        f.debug_struct("AgentVm")
760            .field("agents", &self.specs)
761            .field("sandbox", &self.sandbox)
762            .finish()
763    }
764}
765
766impl AgentVm {
767    /// Wraps a sandbox for `specs`. Refuses an empty or repeated set.
768    pub fn new(sandbox: Sandbox, specs: Vec<AgentSpec>) -> Result<Self, Error> {
769        require_specs(&specs)?;
770        Ok(Self { sandbox, specs })
771    }
772
773    /// An agent VM for a VM another process launched; see [`Sandbox::adopt`].
774    ///
775    /// `AgentVm::from_name` and `AgentVm::adopt_in`, which resolve credentials for a region,
776    /// are in `microvms_core::prelude`.
777    ///
778    /// The specs are the caller's statement of what the VM carries, checked like
779    /// [`AgentVm::new`] checks them. Read [`installed_agents`] first when the adopting
780    /// process does not know.
781    pub async fn adopt(
782        control: crate::control::ControlPlane,
783        specs: Vec<AgentSpec>,
784        microvm_id: impl Into<String>,
785        endpoint: impl Into<String>,
786        agent_token: impl Into<String>,
787    ) -> Result<Self, Error> {
788        require_specs(&specs)?;
789        let sandbox = Sandbox::adopt(control, microvm_id, endpoint, agent_token).await?;
790        Ok(Self { sandbox, specs })
791    }
792
793    /// Hands the VM off to another process; see [`Sandbox::detach`]. The adopter passes
794    /// the same specs to [`AgentVm::adopt`].
795    pub fn detach(&mut self) -> Result<crate::sandbox::Detached, Error> {
796        self.sandbox.detach()
797    }
798
799    /// The sandbox and specs, for a binding that holds them separately.
800    pub fn into_parts(self) -> (Sandbox, Vec<AgentSpec>) {
801        (self.sandbox, self.specs)
802    }
803
804    /// A VM for Claude Code with the profile's defaults.
805    pub fn claude_code(sandbox: Sandbox) -> Self {
806        Self {
807            sandbox,
808            specs: vec![AgentSpec::new(Agent::ClaudeCode)],
809        }
810    }
811
812    /// A VM for Codex with the profile's defaults.
813    pub fn codex(sandbox: Sandbox) -> Self {
814        Self {
815            sandbox,
816            specs: vec![AgentSpec::new(Agent::Codex)],
817        }
818    }
819
820    /// The specs this VM carries, in profile order.
821    pub fn specs(&self) -> Vec<AgentSpec> {
822        ordered(&self.specs).into_iter().cloned().collect()
823    }
824
825    pub fn sandbox(&self) -> &Sandbox {
826        &self.sandbox
827    }
828
829    pub fn sandbox_mut(&mut self) -> &mut Sandbox {
830        &mut self.sandbox
831    }
832
833    /// The session, once [`AgentVm::launch`] has run.
834    pub fn session(&self) -> Option<&Session> {
835        self.sandbox.session()
836    }
837
838    /// The image name for these specs and this binary: the stem plus the first twelve hex
839    /// characters of ensure's identity over the artifact, the base and the size, so an
840    /// unchanged binary, spec set and size name the image [`AgentVm::ensure_image`] would find
841    /// (AGENT-3).
842    pub fn image_name(&self, request: &CreateImageRequest) -> String {
843        image_name_for(&self.sandbox, &self.specs, request)
844    }
845
846    /// The create request for this VM's image, named per [`AgentVm::image_name`].
847    ///
848    /// `code_artifact_uri` is left **empty** for the caller to fill: the upload is theirs
849    /// (this path does not upload), and the key they choose is usually
850    /// derived from the name this method just computed. `Sandbox::preflight` refuses a
851    /// request whose URI is still blank.
852    pub fn image_request(
853        &self,
854        binary: Vec<u8>,
855        build_role_arn: impl Into<String>,
856        size: SizeClass,
857    ) -> Result<CreateImageRequest, Error> {
858        image_request_for(&self.sandbox, &self.specs, binary, build_role_arn, size)
859    }
860
861    /// Builds the image. The caller has uploaded the artifact to the request's URI.
862    pub async fn build(&mut self, request: CreateImageRequest) -> Result<&Image, Error> {
863        self.sandbox.build_image(request).await
864    }
865
866    /// Builds or reuses this VM's image through [`Sandbox::ensure_image`] (#258): named per
867    /// [`AgentVm::image_name`], reused when ready, waited on while building, rebuilt when
868    /// failed, and uploaded to `s3://<s3_bucket>/<s3_key_prefix>/<name>/artifact.zip` only
869    /// when a build is needed.
870    pub async fn ensure_image(
871        &mut self,
872        binary: Vec<u8>,
873        build_role_arn: impl Into<String>,
874        size: SizeClass,
875        s3_bucket: impl Into<String>,
876        s3_key_prefix: Option<String>,
877    ) -> Result<crate::control::EnsuredImage, Error> {
878        let request = ensure_request_for(
879            &self.sandbox,
880            &self.specs,
881            binary,
882            build_role_arn,
883            size,
884            s3_bucket,
885            s3_key_prefix,
886        )?;
887        self.sandbox.ensure_image(request).await
888    }
889
890    /// The launch request: egress on (AGENT-9), because neither agent reaches Bedrock
891    /// without it, and the token label set to the image name.
892    pub fn launch_request(
893        &self,
894        image_identifier: impl Into<String>,
895        execution_role_arn: Option<String>,
896    ) -> RunRequest {
897        launch_request_for(&self.specs, image_identifier, execution_role_arn)
898    }
899
900    /// Launches and waits for the daemon to answer, which [`Sandbox::run`] does.
901    pub async fn launch(&mut self, request: RunRequest) -> Result<&Session, Error> {
902        self.sandbox.run(request).await.map(|session| &*session)
903    }
904
905    fn require_session(&self) -> Result<&Session, Error> {
906        launched_session(&self.sandbox)
907    }
908
909    /// [`install_access`] on this VM's session.
910    pub async fn install_access(&self, access: &BedrockAccess) -> Result<(), Error> {
911        install_access(self.require_session()?, &self.specs, access).await
912    }
913
914    /// [`prompt`] on this VM's session, for one of the agents it carries.
915    pub async fn prompt(
916        &self,
917        agent: Agent,
918        task: &str,
919        options: &PromptOptions,
920    ) -> Result<ExecHandle, Error> {
921        let spec = spec_for(&self.specs, agent)?;
922        prompt(self.require_session()?, spec, task, options).await
923    }
924
925    /// [`prompt_sync`] on this VM's session, for one of the agents it carries.
926    pub async fn prompt_sync(
927        &self,
928        agent: Agent,
929        task: &str,
930        options: &PromptOptions,
931    ) -> Result<ExecResult, Error> {
932        let spec = spec_for(&self.specs, agent)?;
933        prompt_sync(self.require_session()?, spec, task, options).await
934    }
935
936    /// Tears the VM down; see [`Sandbox::terminate`].
937    pub async fn terminate(&mut self, opts: TeardownOpts) -> TeardownReport {
938        self.sandbox.terminate(opts).await
939    }
940}
941
942#[cfg(test)]
943mod tests {
944    use std::sync::Arc;
945
946    use super::*;
947    use crate::control::artifact::{
948        require_daemon_cmd, require_matching_agentd_port, require_matching_from, require_workdir,
949    };
950    use crate::session::testing::{Recorder, Reply, session_with};
951
952    /// [`prompt_request_with`] minting from a fixed instant, the shape the bindings' default
953    /// has without the production clock.
954    fn prompt_request(
955        spec: &AgentSpec,
956        task: &str,
957        options: &PromptOptions,
958    ) -> Result<crate::protocol::exec::StartRequest, Error> {
959        prompt_request_with(spec, task, options, || {
960            crate::session::exec_id_at(Duration::from_secs(1_767_225_600))
961        })
962    }
963
964    fn both() -> Vec<AgentSpec> {
965        vec![
966            AgentSpec::new(Agent::Codex),
967            AgentSpec::new(Agent::ClaudeCode),
968        ]
969    }
970
971    fn access() -> BedrockAccess {
972        BedrockAccess {
973            region: Region::UsEast1,
974            // Token-shaped and never signed: nothing here sends it anywhere.
975            token: bedrock::BearerToken::new("bedrock-api-key-test".to_string()),
976        }
977    }
978
979    // ── the Dockerfile (AGENT-2) ─────────────────────────────────────────────
980
981    /// **The derived Dockerfile passes every guard preflight runs**, and carries the four
982    /// things the recipe needs: the layers, the user, the owned WORKDIR, the daemon lines.
983    ///
984    /// **Falsification** — drop the `chown` from the mkdir line and the ownership assert
985    /// goes red; emit the `dnf` line per profile and the count assert goes red; move
986    /// `WORKDIR` above the layers and the order assert goes red.
987    #[test]
988    fn the_dockerfile_passes_the_guards_and_carries_the_recipe() {
989        let base = BaseImage::al2023();
990        let text = dockerfile(&both(), &base, 9000).expect("derives");
991
992        require_matching_from(&base, &text).expect("FROM pairs with the base");
993        require_workdir(&base, Some(&text)).expect("a WORKDIR is declared");
994        require_daemon_cmd(&text).expect("ENTRYPOINT [] + CMD agentd survive");
995        require_matching_agentd_port(9000, &text).expect("the port agrees");
996
997        assert_eq!(
998            text.matches("RUN dnf install").count(),
999            1,
1000            "the system layer is emitted once for two profiles:\n{text}"
1001        );
1002        assert!(
1003            text.contains("RUN dnf install -y --setopt=install_weak_deps=0 nodejs22 nodejs22-npm "),
1004            "with weak deps off, bare `npm` is Node 18's and the npm layer exits 127:\n{text}"
1005        );
1006        assert!(
1007            text.contains("RUN npm install -g @anthropic-ai/claude-code @openai/codex && npm"),
1008            "both CLIs in profile order, in one layer:\n{text}"
1009        );
1010        assert!(
1011            text.contains("agent:x:1000:1000::/workspace:/bin/bash"),
1012            "{text}"
1013        );
1014        assert!(
1015            text.contains("RUN mkdir -p /workspace && chown 1000:1000 /workspace"),
1016            "{text}"
1017        );
1018        let layers_at = text.find("RUN npm install").expect("layers");
1019        let workdir_at = text.find("WORKDIR /workspace").expect("workdir");
1020        let cmd_at = text.find("CMD [\"/agentd\"]").expect("cmd");
1021        assert!(layers_at < workdir_at && workdir_at < cmd_at, "{text}");
1022    }
1023
1024    /// A version pin appears on the install line and nowhere else, so it changes the
1025    /// Dockerfile text (and therefore the reuse hash) and nothing about the recipe.
1026    #[test]
1027    fn a_cli_version_pin_reaches_the_install_line() {
1028        let base = BaseImage::al2023();
1029        let pinned = vec![AgentSpec::new(Agent::ClaudeCode).with_cli_version("2.1.0")];
1030        let text = dockerfile(&pinned, &base, 9000).expect("derives");
1031        assert!(text.contains("@anthropic-ai/claude-code@2.1.0"), "{text}");
1032        let plain = dockerfile(&[AgentSpec::new(Agent::ClaudeCode)], &base, 9000).expect("d");
1033        assert_ne!(text, plain);
1034    }
1035
1036    #[test]
1037    fn an_empty_or_repeated_spec_set_is_refused_before_any_text_is_built() {
1038        let base = BaseImage::al2023();
1039        assert_eq!(
1040            dockerfile(&[], &base, 9000).expect_err("empty").kind(),
1041            ErrorKind::InvalidArg
1042        );
1043        let twice = vec![AgentSpec::new(Agent::Codex), AgentSpec::new(Agent::Codex)];
1044        let error = dockerfile(&twice, &base, 9000).expect_err("repeated");
1045        assert!(
1046            error.to_string().contains("codex is named twice"),
1047            "{error}"
1048        );
1049    }
1050
1051    /// The name stem is order-independent and spells the agents in profile order.
1052    #[test]
1053    fn the_image_stem_is_stable_under_spec_order() {
1054        assert_eq!(image_stem(&both()), "agent-vm-claude-code-codex");
1055        assert_eq!(
1056            image_stem(&[AgentSpec::new(Agent::Codex)]),
1057            "agent-vm-codex"
1058        );
1059    }
1060
1061    // ── the provisioning files (AGENT-5, AGENT-6) ────────────────────────────
1062
1063    /// **Claude alone gets two files; Codex adds its config.** The environment file sets
1064    /// PATH and HOME, carries the token under each agent's variable, and every value is
1065    /// double-quoted. The marker names the agents and their resolved models.
1066    #[test]
1067    fn a_codex_only_vm_still_gets_the_variable_codex_reads() {
1068        let files = provisioning_files(&[AgentSpec::new(Agent::Codex)], &access()).expect("files");
1069        let env = String::from_utf8(files[0].contents.clone()).expect("utf-8");
1070        assert!(
1071            env.contains("export AWS_BEARER_TOKEN_BEDROCK="),
1072            "no Claude Code profile is present to export it, and Codex 401s without it:\n{env}"
1073        );
1074    }
1075
1076    #[test]
1077    fn the_file_set_follows_the_profiles_and_the_env_file_is_quoted() {
1078        let access = access();
1079        let claude_only =
1080            provisioning_files(&[AgentSpec::new(Agent::ClaudeCode)], &access).expect("files");
1081        assert_eq!(
1082            claude_only
1083                .iter()
1084                .map(|f| f.path.as_str())
1085                .collect::<Vec<_>>(),
1086            vec![ENV_FILE, MARKER_FILE]
1087        );
1088
1089        let files = provisioning_files(&both(), &access).expect("files");
1090        assert_eq!(
1091            files.iter().map(|f| f.path.as_str()).collect::<Vec<_>>(),
1092            vec![ENV_FILE, CODEX_CONFIG_FILE, MARKER_FILE]
1093        );
1094        let env = String::from_utf8(files[0].contents.clone()).expect("utf-8");
1095        assert_eq!(files[0].mode, "0600");
1096        assert!(
1097            env.contains("export PATH=\"/usr/local/bin:/usr/bin:/bin\"\n"),
1098            "{env}"
1099        );
1100        assert!(env.contains("export HOME=\"/workspace\"\n"), "{env}");
1101        assert!(env.contains("export AWS_REGION=\"us-east-1\"\n"), "{env}");
1102        assert!(
1103            env.contains("export CLAUDE_CODE_USE_BEDROCK=\"1\"\n"),
1104            "{env}"
1105        );
1106        assert!(
1107            env.contains("export ANTHROPIC_MODEL=\"global.anthropic.claude-opus-5\"\n"),
1108            "{env}"
1109        );
1110        let token = access.token.expose();
1111        assert!(env.contains(&format!("export AWS_BEARER_TOKEN_BEDROCK=\"{token}\"\n")));
1112        assert!(env.contains(&format!("export OPENAI_API_KEY=\"{token}\"\n")));
1113
1114        let config = String::from_utf8(files[1].contents.clone()).expect("utf-8");
1115        assert!(
1116            config.contains("bedrock-runtime.us-east-1.amazonaws.com/openai/v1"),
1117            "{config}"
1118        );
1119        assert!(
1120            config.contains("model = \"global.openai.gpt-5.6-sol\""),
1121            "{config}"
1122        );
1123
1124        let marker: serde_json::Value = serde_json::from_slice(&files[2].contents).expect("json");
1125        assert_eq!(files[2].mode, "0644");
1126        assert_eq!(marker["agents"][0]["agent"], "claude-code");
1127        assert_eq!(marker["agents"][1]["agent"], "codex");
1128        assert_eq!(marker["agents"][1]["model"], "global.openai.gpt-5.6-sol");
1129        assert!(
1130            !files[2]
1131                .contents
1132                .windows(token.len())
1133                .any(|w| w == token.as_bytes()),
1134            "the marker is world-readable and must never carry the token"
1135        );
1136    }
1137
1138    #[test]
1139    fn shell_quoting_escapes_what_each_quote_style_cannot_hold() {
1140        assert_eq!(sh_double_quote("a$b\"c`d\\e"), "\"a\\$b\\\"c\\`d\\\\e\"");
1141        assert_eq!(sh_single_quote("it's"), "'it'\\''s'");
1142        assert_eq!(sh_single_quote("plain"), "'plain'");
1143    }
1144
1145    // ── install and read-back through the recorder ───────────────────────────
1146
1147    /// **The uploads go out in file order, then exactly one root chown.** The exec that
1148    /// hands the workspace over asks for uid 0 on the wire, after every PUT: naming no user
1149    /// would run it as the image's default user when the image sets one.
1150    #[tokio::test(start_paused = true)]
1151    async fn install_access_uploads_then_chowns_as_root() {
1152        let recorder = Recorder::with([
1153            Reply::Body(200, Vec::new()),
1154            Reply::Body(200, Vec::new()),
1155            Reply::Body(200, Vec::new()),
1156            Reply::ok(serde_json::json!({"exec_id":"c1","phase":"running"})),
1157            Reply::ok(serde_json::json!({"exec_id":"c1","phase":"exited"})),
1158            Reply::ok(serde_json::json!({
1159                "exec_id":"c1","phase":"exited","exit_code":0,"signal":null,
1160                "stdout":"","stderr":"","truncated":false,"writers_may_be_alive":false
1161            })),
1162        ]);
1163        let (session, _, _) = session_with(Arc::clone(&recorder));
1164
1165        install_access(&session, &both(), &access())
1166            .await
1167            .expect("installs");
1168
1169        let seen = recorder.requests();
1170        assert_eq!(seen.len(), 6, "{seen:#?}");
1171        for (request, (encoded, mode)) in seen.iter().zip([
1172            ("%2Fworkspace%2F.agent-env", "0600"),
1173            ("%2Fworkspace%2F.codex%2Fconfig.toml", "0600"),
1174            ("%2Fworkspace%2F.agent-vm.json", "0644"),
1175        ]) {
1176            assert_eq!(request.method, "PUT");
1177            assert_eq!(
1178                request.path,
1179                format!("/v1/fs/file?path={encoded}&mode={mode}"),
1180                "each file goes to the file route with its own mode"
1181            );
1182        }
1183        assert_eq!(seen[3].path, "/v1/exec/start");
1184        let start: serde_json::Value = serde_json::from_slice(&seen[3].body).expect("json");
1185        assert_eq!(start["command"][0], "chown -R 1000:1000 /workspace");
1186        assert_eq!(start["shell"], true);
1187        assert_eq!(start["user"], 0, "the chown asks for root by uid");
1188        assert_eq!(seen[5].path, "/v1/exec/c1/ack");
1189    }
1190
1191    /// A failed chown is a failure, not a warning: a workspace the agent cannot write is
1192    /// a VM every prompt fails in.
1193    #[tokio::test(start_paused = true)]
1194    async fn a_failed_chown_fails_the_install() {
1195        let recorder = Recorder::with([
1196            Reply::Body(200, Vec::new()),
1197            Reply::Body(200, Vec::new()),
1198            Reply::ok(serde_json::json!({"exec_id":"c1","phase":"running"})),
1199            Reply::ok(serde_json::json!({"exec_id":"c1","phase":"exited"})),
1200            Reply::ok(serde_json::json!({
1201                "exec_id":"c1","phase":"exited","exit_code":1,"signal":null,
1202                "stdout":"","stderr":"chown: cannot access","truncated":false,
1203                "writers_may_be_alive":false
1204            })),
1205        ]);
1206        let (session, _, _) = session_with(recorder);
1207        let error = install_access(&session, &[AgentSpec::new(Agent::ClaudeCode)], &access())
1208            .await
1209            .expect_err("fails");
1210        assert_eq!(error.kind(), ErrorKind::ExecFailed);
1211        assert!(
1212            error.to_string().contains("chown: cannot access"),
1213            "{error}"
1214        );
1215    }
1216
1217    /// **The marker round-trips**: what `provisioning_files` wrote is what
1218    /// `installed_agents` reads back, models included.
1219    #[tokio::test]
1220    async fn installed_agents_reads_the_marker_back() {
1221        let specs = vec![
1222            AgentSpec::new(Agent::ClaudeCode).with_model("global.anthropic.claude-sonnet-5"),
1223            AgentSpec::new(Agent::Codex).with_cli_version("0.50.0"),
1224        ];
1225        let files = provisioning_files(&specs, &access()).expect("files");
1226        let marker = files.last().expect("marker").contents.clone();
1227        let recorder = Recorder::with([Reply::Body(200, marker)]);
1228        let (session, _, _) = session_with(recorder);
1229
1230        let read = installed_agents(&session).await.expect("reads");
1231        assert_eq!(read.len(), 2);
1232        assert_eq!(read[0].agent, Agent::ClaudeCode);
1233        assert_eq!(read[0].model(), "global.anthropic.claude-sonnet-5");
1234        assert_eq!(read[1].agent, Agent::Codex);
1235        assert_eq!(read[1].cli_version.as_deref(), Some("0.50.0"));
1236        assert_eq!(read[1].model(), "global.openai.gpt-5.6-sol");
1237    }
1238
1239    /// A VM with no marker is a precondition failure naming the command that writes one,
1240    /// and any other refusal propagates as itself.
1241    #[tokio::test]
1242    async fn a_missing_marker_names_agent_up_and_other_failures_propagate() {
1243        let recorder = Recorder::with([
1244            Reply::Body(404, b"not found".to_vec()),
1245            Reply::Body(503, b"not bootstrapped".to_vec()),
1246        ]);
1247        let (session, _, _) = session_with(recorder);
1248        let error = installed_agents(&session).await.expect_err("absent");
1249        assert_eq!(error.kind(), ErrorKind::Precondition);
1250        assert!(error.to_string().contains("agent-up"), "{error}");
1251        let other = installed_agents(&session).await.expect_err("503");
1252        assert_ne!(other.kind(), ErrorKind::Precondition);
1253    }
1254
1255    // ── the prompt (AGENT-7) ─────────────────────────────────────────────────
1256
1257    /// **Every field of the start request is the recipe's.** uid and gid 1000, the
1258    /// workspace as cwd, an empty wire env, the env file sourced first, the task
1259    /// single-quoted, the daemon-side timeout only when asked for.
1260    #[test]
1261    fn the_prompt_request_runs_the_agent_demoted_in_the_workspace() {
1262        let spec = AgentSpec::new(Agent::ClaudeCode);
1263        let request = prompt_request(
1264            &spec,
1265            "count the files; it's quick",
1266            &PromptOptions {
1267                exec_id: Some("p1".into()),
1268                timeout: Some(Duration::from_secs(120)),
1269                ..PromptOptions::default()
1270            },
1271        )
1272        .expect("builds");
1273        assert_eq!(request.exec_id, "p1");
1274        assert_eq!(request.user, Some(1000.into()));
1275        assert_eq!(request.group, Some(1000.into()));
1276        assert_eq!(request.cwd.as_deref(), Some("/workspace"));
1277        assert!(request.shell.is_shell());
1278        assert!(
1279            request.env.is_empty(),
1280            "the token never rides in the request"
1281        );
1282        assert_eq!(request.timeout_sec, Some(120.0));
1283        assert!(!request.stdin);
1284        assert_eq!(
1285            request.command,
1286            vec![
1287                ". /workspace/.agent-env && claude -p 'count the files; it'\\''s quick' \
1288                 --allowedTools Bash,Read,Edit,Write,Grep,Glob"
1289                    .to_string()
1290            ]
1291        );
1292
1293        let codex = prompt_request(
1294            &AgentSpec::new(Agent::Codex),
1295            "hi",
1296            &PromptOptions::default(),
1297        )
1298        .expect("builds");
1299        assert!(
1300            codex.command[0].contains("codex exec --skip-git-repo-check -s workspace-write 'hi'")
1301        );
1302        assert!(codex.timeout_sec.is_none());
1303        assert!(!codex.exec_id.is_empty(), "an id was minted");
1304    }
1305
1306    #[test]
1307    fn an_empty_task_is_refused_locally() {
1308        let error = prompt_request(
1309            &AgentSpec::new(Agent::Codex),
1310            "  \n",
1311            &PromptOptions::default(),
1312        )
1313        .expect_err("empty");
1314        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1315    }
1316
1317    // ── AgentVm ──────────────────────────────────────────────────────────────
1318
1319    fn sandbox() -> Sandbox {
1320        Sandbox::with_control_plane(crate::testing::control_plane(
1321            Arc::new(crate::control::fake::FakeControlPlane::new()),
1322            Region::UsEast1,
1323            Arc::new(crate::control::fake::TestClock::new()),
1324        ))
1325    }
1326
1327    /// The default agents are Claude Code alone, a set `AgentVm::new` accepts (#266).
1328    #[test]
1329    fn the_default_agents_are_claude_code_alone() {
1330        let specs = default_specs();
1331        assert_eq!(
1332            specs.iter().map(|spec| spec.agent).collect::<Vec<_>>(),
1333            [Agent::ClaudeCode]
1334        );
1335        AgentVm::new(sandbox(), specs).expect("the default set is a valid one");
1336    }
1337
1338    /// A call before `launch` is `ERR_PRECONDITION` naming the remedy, from the one place both
1339    /// `AgentVm` and the bindings read it (#266).
1340    #[tokio::test]
1341    async fn a_call_before_launch_is_refused_with_the_remedy() {
1342        let fresh = sandbox();
1343        let error = launched_session(&fresh).expect_err("nothing launched");
1344        assert_eq!(error.kind(), ErrorKind::Precondition);
1345        assert_eq!(
1346            error.to_string(),
1347            "this agent VM has not been launched; call `launch` first."
1348        );
1349        let vm = AgentVm::new(sandbox(), default_specs()).expect("a valid set");
1350        let through_the_vm = vm
1351            .install_access(&access())
1352            .await
1353            .expect_err("nothing launched");
1354        assert_eq!(through_the_vm.to_string(), error.to_string());
1355    }
1356
1357    /// `AgentVm::adopt` checks the specs before any call, and adopts with the lifecycle the
1358    /// service reports.
1359    #[tokio::test]
1360    async fn an_agent_vm_adopts_with_checked_specs_and_the_service_lifecycle() {
1361        use crate::control::fake::{Answer, FakeControlPlane, TestClock, microvm_response};
1362        let plane = |recorder: &Arc<FakeControlPlane>| {
1363            crate::testing::control_plane(
1364                Arc::clone(recorder) as Arc<dyn crate::control::transport::Transport>,
1365                Region::UsEast1,
1366                Arc::new(TestClock::new()),
1367            )
1368        };
1369        let endpoint = "https://mvm-abc123.microvm.us-east-1.amazonaws.com";
1370
1371        let recorder = Arc::new(FakeControlPlane::new());
1372        let error = AgentVm::adopt(plane(&recorder), vec![], "mvm-abc123", endpoint, "t")
1373            .await
1374            .expect_err("an empty spec set");
1375        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1376        assert_eq!(recorder.calls().len(), 0);
1377
1378        let recorder = Arc::new(FakeControlPlane::new());
1379        recorder.answer(
1380            "GetMicrovm",
1381            Answer::ok(microvm_response("SUSPENDED", None)),
1382        );
1383        let vm = AgentVm::adopt(plane(&recorder), both(), "mvm-abc123", endpoint, "t")
1384            .await
1385            .expect("adopts");
1386        assert!(vm.sandbox().adopted());
1387        assert_eq!(
1388            vm.sandbox().lifecycle(),
1389            crate::sandbox::Lifecycle::Suspended
1390        );
1391        assert_eq!(vm.specs().len(), 2);
1392    }
1393
1394    /// **AGENT-3 through ensure (#258).** The two-step path's name (`image_request`, the
1395    /// caller's upload, `build`) is the name `ensure_image` gives, and the size class is part
1396    /// of it: an image is created at one size, so another size is another image.
1397    ///
1398    /// **Falsification**: go back to the stem and the artifact hash alone in `image_name_for`
1399    /// and the two names part, and the two sizes share one.
1400    #[test]
1401    fn the_two_step_name_is_the_name_ensure_gives_and_it_covers_the_size() {
1402        let vm = AgentVm::new(sandbox(), both()).expect("specs");
1403        let role = "arn:aws:iam::123456789012:role/b";
1404        let daemon = crate::testing::aarch64_daemon(b"daemon");
1405        let two_step = vm
1406            .image_request(daemon.clone(), role, DEFAULT_SIZE)
1407            .expect("request");
1408        let ensure = ensure_request_for(
1409            vm.sandbox(),
1410            &vm.specs(),
1411            daemon.clone(),
1412            role,
1413            DEFAULT_SIZE,
1414            "bucket",
1415            Some("agents".to_string()),
1416        )
1417        .expect("request");
1418        let prepared = crate::control::ensure::prepare(
1419            &crate::testing::control_plane(
1420                Arc::new(crate::control::fake::FakeControlPlane::new()),
1421                Region::UsEast1,
1422                Arc::new(crate::control::fake::TestClock::new()),
1423            ),
1424            ensure,
1425        )
1426        .expect("prepares");
1427        assert_eq!(prepared.name, two_step.name, "one name for one agent image");
1428        assert_eq!(
1429            prepared.key,
1430            format!("agents/{}/artifact.zip", two_step.name),
1431            "the ensure's key"
1432        );
1433
1434        let larger = SizeClass::from_baseline_mib(4096).expect("a class");
1435        let other_size = vm.image_request(daemon, role, larger).expect("request");
1436        assert_ne!(
1437            other_size.name, two_step.name,
1438            "another size is another image"
1439        );
1440    }
1441
1442    /// **The image request is named by content and the URI is left to the caller.**
1443    /// Same binary and specs give the same name; a different binary gives a different one.
1444    #[test]
1445    fn the_image_request_is_content_named_and_egress_is_on_at_launch() {
1446        let vm = AgentVm::new(sandbox(), both()).expect("specs");
1447        let a = vm
1448            .image_request(
1449                b"daemon".to_vec(),
1450                "arn:aws:iam::123456789012:role/b",
1451                DEFAULT_SIZE,
1452            )
1453            .expect("request");
1454        let b = vm
1455            .image_request(
1456                b"daemon".to_vec(),
1457                "arn:aws:iam::123456789012:role/b",
1458                DEFAULT_SIZE,
1459            )
1460            .expect("request");
1461        let c = vm
1462            .image_request(
1463                b"other".to_vec(),
1464                "arn:aws:iam::123456789012:role/b",
1465                DEFAULT_SIZE,
1466            )
1467            .expect("request");
1468        assert_eq!(a.name, b.name);
1469        assert_ne!(a.name, c.name);
1470        assert!(
1471            a.name.starts_with("agent-vm-claude-code-codex-"),
1472            "{}",
1473            a.name
1474        );
1475        assert_eq!(a.name.len(), "agent-vm-claude-code-codex-".len() + 12);
1476        assert_eq!(a.token_scope.as_deref(), Some(a.name.as_str()));
1477        assert!(a.code_artifact_uri.is_empty(), "the upload is the caller's");
1478        assert!(
1479            a.dockerfile
1480                .as_deref()
1481                .is_some_and(|d| d.contains("@openai/codex"))
1482        );
1483        assert_eq!(a.size, SizeClass::Mib1024);
1484
1485        let launch = vm.launch_request("arn:image", Some("arn:role".into()));
1486        assert!(
1487            launch.egress,
1488            "neither agent reaches Bedrock without egress"
1489        );
1490        assert_eq!(launch.image_identifier.as_deref(), Some("arn:image"));
1491        assert_eq!(
1492            launch.token_scope.as_deref(),
1493            Some("agent-vm-claude-code-codex")
1494        );
1495    }
1496
1497    /// The named constructors carry the request's own names, and a prompt for an agent
1498    /// the VM does not carry is refused before any wire call.
1499    #[tokio::test]
1500    async fn the_named_constructors_carry_one_agent_each() {
1501        let claude = AgentVm::claude_code(sandbox());
1502        assert_eq!(claude.specs(), vec![AgentSpec::new(Agent::ClaudeCode)]);
1503        let codex = AgentVm::codex(sandbox());
1504        assert_eq!(codex.specs(), vec![AgentSpec::new(Agent::Codex)]);
1505
1506        let error = codex
1507            .prompt(Agent::ClaudeCode, "hi", &PromptOptions::default())
1508            .await
1509            .expect_err("wrong agent");
1510        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1511        assert!(error.to_string().contains("carries codex"), "{error}");
1512        let unlaunched = codex
1513            .prompt(Agent::Codex, "hi", &PromptOptions::default())
1514            .await
1515            .expect_err("no session");
1516        assert_eq!(unlaunched.kind(), ErrorKind::Precondition);
1517    }
1518
1519    #[test]
1520    fn unrestricted_permissions_deadline_and_reaping_are_explicit_and_demoted() {
1521        for (agent, expected, forbidden) in [
1522            (
1523                Agent::ClaudeCode,
1524                "--dangerously-skip-permissions",
1525                "--allowedTools",
1526            ),
1527            (
1528                Agent::Codex,
1529                "--dangerously-bypass-approvals-and-sandbox",
1530                "workspace-write",
1531            ),
1532        ] {
1533            let task = "a quote ' and $(touch /tmp/must-not-run); \"hello\"";
1534            let request = prompt_request(
1535                &AgentSpec::new(agent),
1536                task,
1537                &PromptOptions {
1538                    exec_id: Some("same-task".into()),
1539                    timeout: Some(Duration::from_secs(1200)),
1540                    permission_mode: AgentPermissionMode::Unrestricted,
1541                    reap_group_on_exit: true,
1542                },
1543            )
1544            .expect("valid");
1545            assert!(request.command[0].contains(expected));
1546            assert!(!request.command[0].contains(forbidden));
1547            assert!(request.command[0].contains(&sh_single_quote(task)));
1548            assert_eq!(
1549                (request.user, request.group),
1550                (Some(1000.into()), Some(1000.into()))
1551            );
1552            assert_eq!(request.timeout_sec, Some(1200.0));
1553            assert!(request.reap_group_on_exit);
1554            assert_eq!(request.exec_id, "same-task");
1555            let default =
1556                prompt_request(&AgentSpec::new(agent), task, &PromptOptions::default()).unwrap();
1557            assert!(default.command[0].contains(forbidden));
1558            assert!(!default.command[0].contains(expected));
1559            assert!(!default.reap_group_on_exit);
1560            assert_eq!(default.timeout_sec, None);
1561        }
1562        assert!("auto".parse::<AgentPermissionMode>().is_err());
1563        assert!(
1564            prompt_request(
1565                &AgentSpec::new(Agent::Codex),
1566                "task",
1567                &PromptOptions {
1568                    timeout: Some(Duration::ZERO),
1569                    ..PromptOptions::default()
1570                }
1571            )
1572            .is_err()
1573        );
1574    }
1575
1576    #[test]
1577    fn version_reporting_only_accepts_numeric_version_words() {
1578        assert_eq!(reported_version("2.1.7 (Claude Code)\n"), Some("2.1.7"));
1579        assert_eq!(reported_version("codex-cli 0.154.0\n"), Some("0.154.0"));
1580        assert_eq!(reported_version("SECRET=credential\n"), None);
1581        assert_eq!(reported_version("1.2.3-secret"), None);
1582    }
1583
1584    /// **A cold first run is a timeout, not a broken image.** Measured 2026-09-24: Codex's
1585    /// first `--version` on a fresh VM took 13 s and the old 10 s budget killed it, which
1586    /// failed the live suite's Codex prompt as `ERR_PRECONDITION` on a healthy VM.
1587    ///
1588    /// **Falsification** — 2026-09-24. Drop the `timed_out` branch and this reports
1589    /// `Precondition` ("verify the pinned agent executable"); restored after.
1590    #[tokio::test]
1591    async fn a_timed_out_version_probe_reports_a_timeout_naming_the_budget() {
1592        let recorder = Recorder::with([
1593            Reply::ok(serde_json::json!({"exec_id":"version","phase":"running"})),
1594            Reply::ok(serde_json::json!({"exec_id":"version","phase":"exited"})),
1595            Reply::ok(serde_json::json!({
1596                "exec_id":"version","phase":"exited","exit_code":null,"signal":9,
1597                "stdout":"","stderr":"","timed_out":true,
1598                "truncated":false,"writers_may_be_alive":false
1599            })),
1600        ]);
1601        let (session, _, _) = session_with(Arc::clone(&recorder));
1602        let error = installed_version(&session, Agent::Codex)
1603            .await
1604            .expect_err("a killed probe has no version");
1605        assert_eq!(error.kind(), ErrorKind::Timeout, "{error}");
1606        assert!(error.to_string().contains("60s"), "{error}");
1607    }
1608
1609    /// An agent helper's exec id is minted from the session's clock, not the host's.
1610    ///
1611    /// The id's low 40 bits are the wall time's nanoseconds (the counter folds into the high
1612    /// bits), so they pin which clock the id was read from.
1613    ///
1614    /// **Falsification**: mint the probe's id with the free `mint_exec_id()` and the low bits
1615    /// are the host's nanoseconds instead of the pinned instant's.
1616    #[tokio::test]
1617    async fn an_agent_exec_id_is_minted_from_the_sessions_clock() {
1618        let recorder = Recorder::with([
1619            Reply::ok(serde_json::json!({"exec_id":"version","phase":"running"})),
1620            Reply::ok(serde_json::json!({"exec_id":"version","phase":"exited"})),
1621            Reply::ok(serde_json::json!({
1622                "exec_id":"version","phase":"exited","exit_code":0,"signal":null,
1623                "stdout":"codex-cli 0.154.0","stderr":"",
1624                "truncated":false,"writers_may_be_alive":false
1625            })),
1626        ]);
1627        let (session, _, clock) = session_with(Arc::clone(&recorder));
1628        let pinned = Duration::from_secs(1_767_225_600) + Duration::from_nanos(123_456_789);
1629        clock.set_unix_now(pinned);
1630        installed_version(&session, Agent::Codex).await.unwrap();
1631
1632        let start: serde_json::Value =
1633            serde_json::from_slice(&recorder.requests()[0].body).unwrap();
1634        let id = start["exec_id"].as_str().expect("an exec id");
1635        let value =
1636            u64::from_str_radix(id.strip_prefix("x-").expect("the x- shape"), 16).expect("hex");
1637        let low_bits = (1_u64 << 40) - 1;
1638        assert_eq!(
1639            value & low_bits,
1640            (pinned.as_nanos() as u64) & low_bits,
1641            "{id} wasn't minted from the session's clock"
1642        );
1643    }
1644
1645    #[tokio::test]
1646    async fn installed_version_probe_is_demoted_and_never_sources_agent_credentials() {
1647        let recorder = Recorder::with([
1648            Reply::ok(serde_json::json!({"exec_id":"version","phase":"running"})),
1649            Reply::ok(serde_json::json!({"exec_id":"version","phase":"exited"})),
1650            Reply::ok(serde_json::json!({
1651                "exec_id":"version","phase":"exited","exit_code":0,"signal":null,
1652                "stdout":"codex-cli 0.154.0\nSECRET=credential","stderr":"private diagnostic",
1653                "truncated":false,"writers_may_be_alive":false
1654            })),
1655        ]);
1656        let (session, _, _) = session_with(Arc::clone(&recorder));
1657        assert_eq!(
1658            installed_version(&session, Agent::Codex).await.unwrap(),
1659            "0.154.0"
1660        );
1661        let requests = recorder.requests();
1662        let start: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap();
1663        assert_eq!(start["command"], serde_json::json!(["codex", "--version"]));
1664        assert_eq!(start["shell"], false);
1665        assert_eq!(start["user"], 1000);
1666        assert_eq!(start["group"], 1000);
1667        assert_eq!(
1668            start["env"],
1669            serde_json::json!({"PATH":"/usr/local/bin:/usr/bin:/bin"})
1670        );
1671        assert_eq!(start["timeout_sec"], VERSION_PROBE_TIMEOUT.as_secs_f64());
1672        assert!(
1673            VERSION_PROBE_TIMEOUT >= Duration::from_secs(30),
1674            "must cover a cold first run"
1675        );
1676        assert!(VERSION_PROBE_WAIT > VERSION_PROBE_TIMEOUT);
1677        assert_eq!(start["reap_group_on_exit"], true);
1678    }
1679
1680    /// A synchronous prompt sends one figure as the daemon's budget and waits the same one,
1681    /// core's default when the caller names none, and answers the whole result (#266).
1682    ///
1683    /// **Falsification**: `verify/guards/faults/agent-recipes.toml` entry
1684    /// `app-prompt-sync-unbounded` starts the exec with the caller's `None` budget, and the
1685    /// default row goes red on `timeout_sec`.
1686    #[tokio::test(start_paused = true)]
1687    async fn a_synchronous_prompt_bounds_the_daemon_by_the_wait_it_makes() {
1688        for (timeout, sent) in [(None, 900.0), (Some(Duration::from_secs(60)), 60.0)] {
1689            let recorder = Recorder::with([
1690                Reply::ok(serde_json::json!({"exec_id":"p1","phase":"running"})),
1691                Reply::ok(serde_json::json!({"exec_id":"p1","phase":"exited"})),
1692                Reply::ok(serde_json::json!({
1693                    "exec_id":"p1","phase":"exited","exit_code":0,"signal":null,
1694                    "stdout":"done","stderr":"","truncated":false,
1695                    "writers_may_be_alive":false
1696                })),
1697            ]);
1698            let (session, _, _) = session_with(Arc::clone(&recorder));
1699            let options = PromptOptions {
1700                timeout,
1701                ..PromptOptions::default()
1702            };
1703            let result = prompt_sync(
1704                &session,
1705                &AgentSpec::new(Agent::ClaudeCode),
1706                "review the diff",
1707                &options,
1708            )
1709            .await
1710            .expect("a result");
1711            assert_eq!(result.stdout(), "done");
1712            let start: serde_json::Value =
1713                serde_json::from_slice(&recorder.requests()[0].body).expect("a JSON start");
1714            assert_eq!(start["timeout_sec"], sent, "timeout_sec for {timeout:?}");
1715        }
1716    }
1717}