Skip to main content

microvms_app/agents/
profile.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The profile table: every agent-specific fact L3 knows, in one place, dated.
3//!
4//! # Why a table and not code
5//!
6//! `docs/AGENT-VMS.md` records the rule this module changes: platform code used to carry
7//! no agent-specific detail because a vendor's CLI flags, config format, and model ids
8//! move on the vendor's cadence. The containment is that everything vendor-shaped lives
9//! in the two `const` rows below and nowhere else in the crate. A profile edit is a
10//! one-file change; a reader auditing "what does this library assume about Codex" reads
11//! one row. Each row names the date and region it was verified against, the way
12//! `docs/PLATFORM.md` dates a platform finding, because an undated vendor fact is a fact
13//! with no way to tell whether it has rotted.
14//!
15//! The values are the ones `examples/coding-agents-on-bedrock/run.sh` measured working
16//! on 2026-09-02 in us-east-1, with two changes named at their fields.
17
18use crate::region::Region;
19
20/// The two coding agents this library carries a recipe for. Closed on purpose
21/// (AGENT-1): an agent with no row cannot be named, so there is no path that builds an
22/// image for a CLI nobody knows how to invoke.
23#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
24pub enum Agent {
25    /// Anthropic's Claude Code CLI, driven through its native Bedrock mode.
26    ClaudeCode,
27    /// OpenAI's Codex CLI, driven through Bedrock's OpenAI-compatible Responses endpoint.
28    Codex,
29}
30
31impl Agent {
32    /// Every profile, in the order the image layers and the marker list them.
33    pub const ALL: [Agent; 2] = [Agent::ClaudeCode, Agent::Codex];
34
35    /// The spelling the CLI flag, the guest marker, and the image name use.
36    pub fn as_str(self) -> &'static str {
37        match self {
38            Agent::ClaudeCode => "claude-code",
39            Agent::Codex => "codex",
40        }
41    }
42
43    /// The inverse of [`Agent::as_str`], for the marker read-back.
44    pub fn parse(text: &str) -> Option<Agent> {
45        Agent::ALL.into_iter().find(|agent| agent.as_str() == text)
46    }
47
48    /// This agent's row.
49    pub fn profile(self) -> &'static Profile {
50        match self {
51            Agent::ClaudeCode => &CLAUDE_CODE,
52            Agent::Codex => &CODEX,
53        }
54    }
55}
56
57impl std::str::FromStr for Agent {
58    type Err = crate::Error;
59
60    /// The refusal the bindings surface for an unknown name, so the message is the core's.
61    fn from_str(text: &str) -> Result<Self, Self::Err> {
62        Agent::parse(text).ok_or_else(|| {
63            crate::Error::invalid_arg(format!(
64                "unknown agent {text:?}. An agent VM installs one or both of: {}.",
65                Agent::ALL
66                    .iter()
67                    .map(|agent| agent.as_str())
68                    .collect::<Vec<_>>()
69                    .join(", ")
70            ))
71        })
72    }
73}
74
75impl std::fmt::Display for Agent {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        f.write_str(self.as_str())
78    }
79}
80
81/// The permissions the guest agent may use. Does not change the VM's UID, IAM or network.
82#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
83pub enum AgentPermissionMode {
84    /// Preserve the agent profile's pre-approved tools or workspace sandbox.
85    #[default]
86    AgentDefault,
87    /// Explicitly bypass the agent's approval prompts and software sandbox.
88    Unrestricted,
89}
90
91impl AgentPermissionMode {
92    pub fn as_str(self) -> &'static str {
93        match self {
94            Self::AgentDefault => "agent-default",
95            Self::Unrestricted => "unrestricted",
96        }
97    }
98}
99
100impl std::str::FromStr for AgentPermissionMode {
101    type Err = crate::Error;
102
103    fn from_str(value: &str) -> Result<Self, Self::Err> {
104        match value {
105            "agent-default" => Ok(Self::AgentDefault),
106            "unrestricted" => Ok(Self::Unrestricted),
107            _ => Err(crate::Error::invalid_arg(
108                "permission mode must be agent-default or unrestricted",
109            )),
110        }
111    }
112}
113
114/// One agent's recipe. Every field is a default some caller-facing knob can override;
115/// see `docs/AGENT-VMS.md`, "The rule this changes".
116#[derive(Debug)]
117pub struct Profile {
118    /// The `npm` package that installs the CLI. Unpinned by default so an image built
119    /// today carries today's CLI; a `cli_version` on the spec appends `@<version>`.
120    pub npm_package: &'static str,
121    /// The Bedrock model id (an inference-profile id for Claude, a model id for Codex)
122    /// the agent uses when the caller names none.
123    pub default_model: &'static str,
124    /// The date and region the row was last verified live, and against which CLI build.
125    /// Prose, because it is for the reader auditing rot rather than for code.
126    pub verified: &'static str,
127}
128
129/// The shared system layer both agents need: Node 22 runs both CLIs, and the rest is
130/// what a coding agent expects of a working shell. Emitted once however many profiles
131/// share an image.
132///
133/// `nodejs22-npm`, spelled out, and not the bare `npm` the example Dockerfile installs.
134/// Measured 2026-09-10, us-east-1, on the first live build of this layer: with weak
135/// dependencies off (`--setopt=install_weak_deps=0`, the minimal-base convention) the bare
136/// `npm` resolves to Node 18's `npm-1:8.19.2`, its alternatives link fails against the
137/// `/usr/bin/node` that `nodejs22` installed ("exists and it is not a symlink"), and the
138/// next layer's `npm install -g` exits 127 with `npm: command not found`, three minutes
139/// and one wedged image name later. The example gets away with `npm` only because it
140/// leaves weak deps on, which pulls `nodejs22-npm` in beside it.
141pub const SYSTEM_PACKAGES: &str =
142    "nodejs22 nodejs22-npm python3 git tar gzip which findutils procps-ng";
143
144/// Claude Code. `--allowedTools` widened from the example's `Bash` alone to the set a
145/// coding task needs; each tool named is auto-approved in `-p` mode, and an unlisted one
146/// is refused silently (the zero-tool-call confident report `docs/AGENT-VMS.md` warns
147/// about), so the list is the permission surface rather than a convenience.
148pub const CLAUDE_CODE: Profile = Profile {
149    npm_package: "@anthropic-ai/claude-code",
150    default_model: "global.anthropic.claude-opus-5",
151    verified: "2026-09-10, us-east-1, @anthropic-ai/claude-code latest on that date",
152};
153
154/// Codex. `-s workspace-write` because the agent runs as uid 1000 in `/workspace` and
155/// nothing outside it is the agent's to write; `--skip-git-repo-check` because a synced
156/// tree arrives without `.git` (`crates/microvms-cli/src/sync.rs`, `SKIPPED_DIRS`).
157pub const CODEX: Profile = Profile {
158    npm_package: "@openai/codex",
159    default_model: "global.openai.gpt-5.6-sol",
160    verified: "2026-09-10, us-east-1, @openai/codex 0.154.0, bedrock-runtime host",
161};
162
163/// The environment file both agents source, and where the credentials live.
164pub const ENV_FILE: &str = "/workspace/.agent-env";
165
166/// Codex's provider config. `HOME=/workspace` in the env file is what makes Codex read
167/// this path.
168pub const CODEX_CONFIG_FILE: &str = "/workspace/.codex/config.toml";
169
170/// The guest marker `install_access` writes and `installed_agents` reads (AGENT-6).
171pub const MARKER_FILE: &str = "/workspace/.agent-vm.json";
172
173/// The lines each agent adds to the environment file, given its model and the token.
174///
175/// Values are written through [`super::sh_double_quote`] by the caller; this returns bare
176/// pairs so a test can assert on names without parsing shell.
177pub fn env_pairs(agent: Agent, model: &str, token: &str) -> Vec<(&'static str, String)> {
178    match agent {
179        Agent::ClaudeCode => vec![
180            ("CLAUDE_CODE_USE_BEDROCK", "1".to_string()),
181            ("ANTHROPIC_MODEL", model.to_string()),
182            ("AWS_BEARER_TOKEN_BEDROCK", token.to_string()),
183        ],
184        // Codex reads `AWS_BEARER_TOKEN_BEDROCK` on a `bedrock-runtime` host and ignores
185        // the provider's `env_key` there. Measured 2026-09-10, us-east-1, Codex 0.154.0,
186        // in a VM carrying Codex alone: with `OPENAI_API_KEY` set and the bearer variable
187        // unset, ten of ten identical tasks failed with
188        // `401 Unauthorized: Credential should be scoped to correct service: 'bedrock'`
189        // after five reconnect attempts, while a `python3` POST to the same endpoint with
190        // the same variable returned 200 — so the token was good and the transport was
191        // fine, and only Codex's own request was unauthorized. Setting the bearer variable
192        // (with everything else unchanged) made the same task succeed, and setting it with
193        // `OPENAI_API_KEY` *unset* also succeeded, which is what proves `env_key` is not
194        // the variable Codex reads here.
195        //
196        // Both are exported: `AWS_BEARER_TOKEN_BEDROCK` is what Codex actually uses, and
197        // `OPENAI_API_KEY` stays because it is the `env_key` the config declares and a
198        // Codex release that honors it would find it. A VM that also carries Claude Code
199        // exported the bearer variable anyway, from that profile — which is why every
200        // earlier two-agent run passed and hid this.
201        Agent::Codex => vec![
202            ("AWS_BEARER_TOKEN_BEDROCK", token.to_string()),
203            ("OPENAI_API_KEY", token.to_string()),
204        ],
205    }
206}
207
208/// Codex's `config.toml`.
209///
210/// The provider is `bedrock-runtime`'s OpenAI-compatible surface,
211/// `https://bedrock-runtime.<region>.amazonaws.com/openai/v1`, which serves the Responses
212/// wire API Codex speaks (the InvokeModel path in CloudTrail). An earlier version of this
213/// file claimed that surface was chat-completions only and pointed Codex at the Mantle
214/// host (`bedrock-mantle.<region>.api.aws`); that claim was wrong by 2026-09-03 and the
215/// Mantle host is a separate service surface, so the config names `bedrock-runtime`.
216/// Verified 2026-09-10, us-east-1, Codex 0.154.0, on the host and inside the VM.
217///
218/// Two lines are required on this host and were not on Mantle. The model must be an
219/// inference-profile id (`global.openai.gpt-5.6-sol`; the bare `openai.gpt-5.6-sol` is
220/// refused with "on-demand throughput isn't supported"). `web_search = "disabled"`
221/// because Codex advertises its hosted web-search tool by default and Bedrock fails the
222/// turn with "web search is not supported for this request", so without it every task
223/// dies on turn one.
224///
225/// `model_reasoning_effort` is set because Codex has no metadata row for a Bedrock model
226/// id and its fallback sends none ("reasoning effort: none" in the banner). Measured
227/// 2026-09-10, us-east-1, Codex 0.154.0, `global.openai.gpt-5.6-sol`: one of five identical
228/// file-writing tasks came back as a plain refusal with zero tool calls and exit 0
229/// ("I can't create or run files in this environment"); the other four wrote the file.
230/// The host accepts `medium` for this model (verified the same day). The setting is
231/// the plausible mitigation, not a measured cure: the decline rate under it is unmeasured,
232/// and a caller who needs the effect verifies it the way the docs show, with an `exec`
233/// that reads the file back.
234pub fn codex_config(model: &str, region: &Region) -> String {
235    format!(
236        "model = \"{model}\"\n\
237         model_provider = \"bedrock\"\n\
238         model_reasoning_effort = \"medium\"\n\
239         web_search = \"disabled\"\n\
240         [model_providers.bedrock]\n\
241         name = \"Amazon Bedrock\"\n\
242         base_url = \"https://bedrock-runtime.{}.amazonaws.com/openai/v1\"\n\
243         env_key = \"OPENAI_API_KEY\"\n\
244         wire_api = \"responses\"\n",
245        region.as_str()
246    )
247}
248
249/// A credential-free version probe, separate from the task command.
250pub fn version_command(agent: Agent) -> Vec<String> {
251    vec![
252        match agent {
253            Agent::ClaudeCode => "claude",
254            Agent::Codex => "codex",
255        }
256        .to_string(),
257        "--version".to_string(),
258    ]
259}
260
261/// The headless command for one task, already shell-quoted.
262///
263/// `task` is single-quoted for `sh` by the caller through [`super::sh_single_quote`];
264/// this function receives the quoted form so the two agents' templates read as the
265/// literal command lines they are.
266pub fn headless_command(agent: Agent, quoted_task: &str, mode: AgentPermissionMode) -> String {
267    match (agent, mode) {
268        (Agent::ClaudeCode, AgentPermissionMode::AgentDefault) => {
269            format!("claude -p {quoted_task} --allowedTools Bash,Read,Edit,Write,Grep,Glob")
270        }
271        (Agent::Codex, AgentPermissionMode::AgentDefault) => {
272            format!("codex exec --skip-git-repo-check -s workspace-write {quoted_task}")
273        }
274        (Agent::ClaudeCode, AgentPermissionMode::Unrestricted) => {
275            format!("claude -p {quoted_task} --dangerously-skip-permissions")
276        }
277        (Agent::Codex, AgentPermissionMode::Unrestricted) => {
278            format!(
279                "codex exec --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox {quoted_task}"
280            )
281        }
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn the_two_ids_are_distinct_and_round_trip() {
291        for agent in Agent::ALL {
292            assert_eq!(Agent::parse(agent.as_str()), Some(agent));
293        }
294        assert_ne!(Agent::ClaudeCode.as_str(), Agent::Codex.as_str());
295        assert_eq!(Agent::parse("cursor"), None);
296    }
297
298    #[test]
299    fn every_row_names_a_package_a_model_and_a_verification_date() {
300        for agent in Agent::ALL {
301            let profile = agent.profile();
302            assert!(profile.npm_package.starts_with('@'), "{agent}");
303            assert!(!profile.default_model.is_empty(), "{agent}");
304            assert!(
305                profile.verified.starts_with("2026-"),
306                "{agent}: an undated row is a row nobody can audit for rot"
307            );
308        }
309    }
310
311    #[test]
312    fn the_codex_config_names_the_bedrock_runtime_host_for_the_region() {
313        let config = codex_config("global.openai.gpt-5.6-sol", &Region::US_WEST_2);
314        assert!(config.contains("https://bedrock-runtime.us-west-2.amazonaws.com/openai/v1"));
315        assert!(
316            !config.contains("mantle"),
317            "the Mantle host is a separate surface:\n{config}"
318        );
319        assert!(config.contains("wire_api = \"responses\""));
320        assert!(config.starts_with("model = \"global.openai.gpt-5.6-sol\"\n"));
321        assert!(
322            config.contains("\nweb_search = \"disabled\"\n"),
323            "bedrock-runtime fails the turn when Codex advertises hosted web search:\n{config}"
324        );
325        assert!(
326            config.contains("\nmodel_reasoning_effort = \"medium\"\n"),
327            "the fallback metadata sends no effort, and a no-effort run declined a task:\n{config}"
328        );
329    }
330
331    #[test]
332    fn the_token_reaches_each_agent_under_the_variable_it_reads() {
333        let claude = env_pairs(Agent::ClaudeCode, "m", "tok");
334        assert!(claude.contains(&("AWS_BEARER_TOKEN_BEDROCK", "tok".to_string())));
335        assert!(claude.contains(&("CLAUDE_CODE_USE_BEDROCK", "1".to_string())));
336        let codex = env_pairs(Agent::Codex, "m", "tok");
337        assert!(
338            codex.contains(&("AWS_BEARER_TOKEN_BEDROCK", "tok".to_string())),
339            "Codex reads the bearer variable on bedrock-runtime and ignores env_key; \
340             without it a Codex-only VM 401s on every task: {codex:?}"
341        );
342        assert!(codex.contains(&("OPENAI_API_KEY", "tok".to_string())));
343    }
344}