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