microvms-app 1.3.0

The MicroVMs client's use cases, written against ports: the control plane, sandboxes, sessions, agents
Documentation
// SPDX-License-Identifier: Apache-2.0
//! The profile table: every agent-specific fact L3 knows, in one place, dated.
//!
//! # Why a table and not code
//!
//! `docs/AGENT-VMS.md` records the rule this module changes: platform code used to carry
//! no agent-specific detail because a vendor's CLI flags, config format, and model ids
//! move on the vendor's cadence. The containment is that everything vendor-shaped lives
//! in the two `const` rows below and nowhere else in the crate. A profile edit is a
//! one-file change; a reader auditing "what does this library assume about Codex" reads
//! one row. Each row names the date and region it was verified against, the way
//! `docs/PLATFORM.md` dates a platform finding, because an undated vendor fact is a fact
//! with no way to tell whether it has rotted.
//!
//! The values are the ones `examples/coding-agents-on-bedrock/run.sh` measured working
//! on 2026-09-02 in us-east-1, with two changes named at their fields.

use crate::region::Region;

/// The two coding agents this library carries a recipe for. Closed on purpose
/// (AGENT-1): an agent with no row cannot be named, so there is no path that builds an
/// image for a CLI nobody knows how to invoke.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum Agent {
    /// Anthropic's Claude Code CLI, driven through its native Bedrock mode.
    ClaudeCode,
    /// OpenAI's Codex CLI, driven through Bedrock's OpenAI-compatible Responses endpoint.
    Codex,
}

impl Agent {
    /// Every profile, in the order the image layers and the marker list them.
    pub const ALL: [Agent; 2] = [Agent::ClaudeCode, Agent::Codex];

    /// The spelling the CLI flag, the guest marker, and the image name use.
    pub fn as_str(self) -> &'static str {
        match self {
            Agent::ClaudeCode => "claude-code",
            Agent::Codex => "codex",
        }
    }

    /// The inverse of [`Agent::as_str`], for the marker read-back.
    pub fn parse(text: &str) -> Option<Agent> {
        Agent::ALL.into_iter().find(|agent| agent.as_str() == text)
    }

    /// This agent's row.
    pub fn profile(self) -> &'static Profile {
        match self {
            Agent::ClaudeCode => &CLAUDE_CODE,
            Agent::Codex => &CODEX,
        }
    }
}

impl std::str::FromStr for Agent {
    type Err = crate::Error;

    /// The refusal the bindings surface for an unknown name, so the message is the core's.
    fn from_str(text: &str) -> Result<Self, Self::Err> {
        Agent::parse(text).ok_or_else(|| {
            crate::Error::invalid_arg(format!(
                "unknown agent {text:?}. An agent VM installs one or both of: {}.",
                Agent::ALL
                    .iter()
                    .map(|agent| agent.as_str())
                    .collect::<Vec<_>>()
                    .join(", ")
            ))
        })
    }
}

impl std::fmt::Display for Agent {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// The permissions the guest agent may use. Does not change the VM's UID, IAM or network.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum AgentPermissionMode {
    /// Preserve the agent profile's pre-approved tools or workspace sandbox.
    #[default]
    AgentDefault,
    /// Explicitly bypass the agent's approval prompts and software sandbox.
    Unrestricted,
}

impl AgentPermissionMode {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::AgentDefault => "agent-default",
            Self::Unrestricted => "unrestricted",
        }
    }
}

impl std::str::FromStr for AgentPermissionMode {
    type Err = crate::Error;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value {
            "agent-default" => Ok(Self::AgentDefault),
            "unrestricted" => Ok(Self::Unrestricted),
            _ => Err(crate::Error::invalid_arg(
                "permission mode must be agent-default or unrestricted",
            )),
        }
    }
}

/// One agent's recipe. Every field is a default some caller-facing knob can override;
/// see `docs/AGENT-VMS.md`, "The rule this changes".
#[derive(Debug)]
pub struct Profile {
    /// The `npm` package that installs the CLI. Unpinned by default so an image built
    /// today carries today's CLI; a `cli_version` on the spec appends `@<version>`.
    pub npm_package: &'static str,
    /// The Bedrock model id (an inference-profile id for Claude, a model id for Codex)
    /// the agent uses when the caller names none.
    pub default_model: &'static str,
    /// The date and region the row was last verified live, and against which CLI build.
    /// Prose, because it is for the reader auditing rot rather than for code.
    pub verified: &'static str,
}

/// The shared system layer both agents need: Node 22 runs both CLIs, and the rest is
/// what a coding agent expects of a working shell. Emitted once however many profiles
/// share an image.
///
/// `nodejs22-npm`, spelled out, and not the bare `npm` the example Dockerfile installs.
/// Measured 2026-09-10, us-east-1, on the first live build of this layer: with weak
/// dependencies off (`--setopt=install_weak_deps=0`, the minimal-base convention) the bare
/// `npm` resolves to Node 18's `npm-1:8.19.2`, its alternatives link fails against the
/// `/usr/bin/node` that `nodejs22` installed ("exists and it is not a symlink"), and the
/// next layer's `npm install -g` exits 127 with `npm: command not found`, three minutes
/// and one wedged image name later. The example gets away with `npm` only because it
/// leaves weak deps on, which pulls `nodejs22-npm` in beside it.
pub const SYSTEM_PACKAGES: &str =
    "nodejs22 nodejs22-npm python3 git tar gzip which findutils procps-ng";

/// Claude Code. `--allowedTools` widened from the example's `Bash` alone to the set a
/// coding task needs; each tool named is auto-approved in `-p` mode, and an unlisted one
/// is refused silently (the zero-tool-call confident report `docs/AGENT-VMS.md` warns
/// about), so the list is the permission surface rather than a convenience.
pub const CLAUDE_CODE: Profile = Profile {
    npm_package: "@anthropic-ai/claude-code",
    default_model: "global.anthropic.claude-opus-5",
    verified: "2026-09-10, us-east-1, @anthropic-ai/claude-code latest on that date",
};

/// Codex. `-s workspace-write` because the agent runs as uid 1000 in `/workspace` and
/// nothing outside it is the agent's to write; `--skip-git-repo-check` because a synced
/// tree arrives without `.git` (`crates/microvms-cli/src/sync.rs`, `SKIPPED_DIRS`).
pub const CODEX: Profile = Profile {
    npm_package: "@openai/codex",
    default_model: "global.openai.gpt-5.6-sol",
    verified: "2026-09-10, us-east-1, @openai/codex 0.154.0, bedrock-runtime host",
};

/// The environment file both agents source, and where the credentials live.
pub const ENV_FILE: &str = "/workspace/.agent-env";

/// Codex's provider config. `HOME=/workspace` in the env file is what makes Codex read
/// this path.
pub const CODEX_CONFIG_FILE: &str = "/workspace/.codex/config.toml";

/// The guest marker `install_access` writes and `installed_agents` reads (AGENT-6).
pub const MARKER_FILE: &str = "/workspace/.agent-vm.json";

/// The lines each agent adds to the environment file, given its model and the token.
///
/// Values are written through [`super::sh_double_quote`] by the caller; this returns bare
/// pairs so a test can assert on names without parsing shell.
pub fn env_pairs(agent: Agent, model: &str, token: &str) -> Vec<(&'static str, String)> {
    match agent {
        Agent::ClaudeCode => vec![
            ("CLAUDE_CODE_USE_BEDROCK", "1".to_string()),
            ("ANTHROPIC_MODEL", model.to_string()),
            ("AWS_BEARER_TOKEN_BEDROCK", token.to_string()),
        ],
        // Codex reads `AWS_BEARER_TOKEN_BEDROCK` on a `bedrock-runtime` host and ignores
        // the provider's `env_key` there. Measured 2026-09-10, us-east-1, Codex 0.154.0,
        // in a VM carrying Codex alone: with `OPENAI_API_KEY` set and the bearer variable
        // unset, ten of ten identical tasks failed with
        // `401 Unauthorized: Credential should be scoped to correct service: 'bedrock'`
        // after five reconnect attempts, while a `python3` POST to the same endpoint with
        // the same variable returned 200 — so the token was good and the transport was
        // fine, and only Codex's own request was unauthorized. Setting the bearer variable
        // (with everything else unchanged) made the same task succeed, and setting it with
        // `OPENAI_API_KEY` *unset* also succeeded, which is what proves `env_key` is not
        // the variable Codex reads here.
        //
        // Both are exported: `AWS_BEARER_TOKEN_BEDROCK` is what Codex actually uses, and
        // `OPENAI_API_KEY` stays because it is the `env_key` the config declares and a
        // Codex release that honors it would find it. A VM that also carries Claude Code
        // exported the bearer variable anyway, from that profile — which is why every
        // earlier two-agent run passed and hid this.
        Agent::Codex => vec![
            ("AWS_BEARER_TOKEN_BEDROCK", token.to_string()),
            ("OPENAI_API_KEY", token.to_string()),
        ],
    }
}

/// Codex's `config.toml`.
///
/// The provider is `bedrock-runtime`'s OpenAI-compatible surface,
/// `https://bedrock-runtime.<region>.amazonaws.com/openai/v1`, which serves the Responses
/// wire API Codex speaks (the InvokeModel path in CloudTrail). An earlier version of this
/// file claimed that surface was chat-completions only and pointed Codex at the Mantle
/// host (`bedrock-mantle.<region>.api.aws`); that claim was wrong by 2026-09-03 and the
/// Mantle host is a separate service surface, so the config names `bedrock-runtime`.
/// Verified 2026-09-10, us-east-1, Codex 0.154.0, on the host and inside the VM.
///
/// Two lines are required on this host and were not on Mantle. The model must be an
/// inference-profile id (`global.openai.gpt-5.6-sol`; the bare `openai.gpt-5.6-sol` is
/// refused with "on-demand throughput isn't supported"). `web_search = "disabled"`
/// because Codex advertises its hosted web-search tool by default and Bedrock fails the
/// turn with "web search is not supported for this request", so without it every task
/// dies on turn one.
///
/// `model_reasoning_effort` is set because Codex has no metadata row for a Bedrock model
/// id and its fallback sends none ("reasoning effort: none" in the banner). Measured
/// 2026-09-10, us-east-1, Codex 0.154.0, `global.openai.gpt-5.6-sol`: one of five identical
/// file-writing tasks came back as a plain refusal with zero tool calls and exit 0
/// ("I can't create or run files in this environment"); the other four wrote the file.
/// The host accepts `medium` for this model (verified the same day). The setting is
/// the plausible mitigation, not a measured cure: the decline rate under it is unmeasured,
/// and a caller who needs the effect verifies it the way the docs show, with an `exec`
/// that reads the file back.
pub fn codex_config(model: &str, region: &Region) -> String {
    format!(
        "model = \"{model}\"\n\
         model_provider = \"bedrock\"\n\
         model_reasoning_effort = \"medium\"\n\
         web_search = \"disabled\"\n\
         [model_providers.bedrock]\n\
         name = \"Amazon Bedrock\"\n\
         base_url = \"https://bedrock-runtime.{}.amazonaws.com/openai/v1\"\n\
         env_key = \"OPENAI_API_KEY\"\n\
         wire_api = \"responses\"\n",
        region.as_str()
    )
}

/// A credential-free version probe, separate from the task command.
pub fn version_command(agent: Agent) -> Vec<String> {
    vec![
        match agent {
            Agent::ClaudeCode => "claude",
            Agent::Codex => "codex",
        }
        .to_string(),
        "--version".to_string(),
    ]
}

/// The headless command for one task, already shell-quoted.
///
/// `task` is single-quoted for `sh` by the caller through [`super::sh_single_quote`];
/// this function receives the quoted form so the two agents' templates read as the
/// literal command lines they are.
pub fn headless_command(agent: Agent, quoted_task: &str, mode: AgentPermissionMode) -> String {
    match (agent, mode) {
        (Agent::ClaudeCode, AgentPermissionMode::AgentDefault) => {
            format!("claude -p {quoted_task} --allowedTools Bash,Read,Edit,Write,Grep,Glob")
        }
        (Agent::Codex, AgentPermissionMode::AgentDefault) => {
            format!("codex exec --skip-git-repo-check -s workspace-write {quoted_task}")
        }
        (Agent::ClaudeCode, AgentPermissionMode::Unrestricted) => {
            format!("claude -p {quoted_task} --dangerously-skip-permissions")
        }
        (Agent::Codex, AgentPermissionMode::Unrestricted) => {
            format!(
                "codex exec --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox {quoted_task}"
            )
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_two_ids_are_distinct_and_round_trip() {
        for agent in Agent::ALL {
            assert_eq!(Agent::parse(agent.as_str()), Some(agent));
        }
        assert_ne!(Agent::ClaudeCode.as_str(), Agent::Codex.as_str());
        assert_eq!(Agent::parse("cursor"), None);
    }

    #[test]
    fn every_row_names_a_package_a_model_and_a_verification_date() {
        for agent in Agent::ALL {
            let profile = agent.profile();
            assert!(profile.npm_package.starts_with('@'), "{agent}");
            assert!(!profile.default_model.is_empty(), "{agent}");
            assert!(
                profile.verified.starts_with("2026-"),
                "{agent}: an undated row is a row nobody can audit for rot"
            );
        }
    }

    #[test]
    fn the_codex_config_names_the_bedrock_runtime_host_for_the_region() {
        let config = codex_config("global.openai.gpt-5.6-sol", &Region::US_WEST_2);
        assert!(config.contains("https://bedrock-runtime.us-west-2.amazonaws.com/openai/v1"));
        assert!(
            !config.contains("mantle"),
            "the Mantle host is a separate surface:\n{config}"
        );
        assert!(config.contains("wire_api = \"responses\""));
        assert!(config.starts_with("model = \"global.openai.gpt-5.6-sol\"\n"));
        assert!(
            config.contains("\nweb_search = \"disabled\"\n"),
            "bedrock-runtime fails the turn when Codex advertises hosted web search:\n{config}"
        );
        assert!(
            config.contains("\nmodel_reasoning_effort = \"medium\"\n"),
            "the fallback metadata sends no effort, and a no-effort run declined a task:\n{config}"
        );
    }

    #[test]
    fn the_token_reaches_each_agent_under_the_variable_it_reads() {
        let claude = env_pairs(Agent::ClaudeCode, "m", "tok");
        assert!(claude.contains(&("AWS_BEARER_TOKEN_BEDROCK", "tok".to_string())));
        assert!(claude.contains(&("CLAUDE_CODE_USE_BEDROCK", "1".to_string())));
        let codex = env_pairs(Agent::Codex, "m", "tok");
        assert!(
            codex.contains(&("AWS_BEARER_TOKEN_BEDROCK", "tok".to_string())),
            "Codex reads the bearer variable on bedrock-runtime and ignores env_key; \
             without it a Codex-only VM 401s on every task: {codex:?}"
        );
        assert!(codex.contains(&("OPENAI_API_KEY", "tok".to_string())));
    }
}