microvms_core/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/// One agent's recipe. Every field is a default some caller-facing knob can override;
82/// see `docs/AGENT-VMS.md`, "The rule this changes".
83#[derive(Debug)]
84pub struct Profile {
85 /// The `npm` package that installs the CLI. Unpinned by default so an image built
86 /// today carries today's CLI; a `cli_version` on the spec appends `@<version>`.
87 pub npm_package: &'static str,
88 /// The Bedrock model id (an inference-profile id for Claude, a model id for Codex)
89 /// the agent uses when the caller names none.
90 pub default_model: &'static str,
91 /// The date and region the row was last verified live, and against which CLI build.
92 /// Prose, because it is for the reader auditing rot rather than for code.
93 pub verified: &'static str,
94}
95
96/// The shared system layer both agents need: Node 22 runs both CLIs, and the rest is
97/// what a coding agent expects of a working shell. Emitted once however many profiles
98/// share an image.
99///
100/// `nodejs22-npm`, spelled out, and not the bare `npm` the example Dockerfile installs.
101/// Measured 2026-09-10, us-east-1, on the first live build of this layer: with weak
102/// dependencies off (`--setopt=install_weak_deps=0`, the minimal-base convention) the bare
103/// `npm` resolves to Node 18's `npm-1:8.19.2`, its alternatives link fails against the
104/// `/usr/bin/node` that `nodejs22` installed ("exists and it is not a symlink"), and the
105/// next layer's `npm install -g` exits 127 with `npm: command not found`, three minutes
106/// and one wedged image name later. The example gets away with `npm` only because it
107/// leaves weak deps on, which pulls `nodejs22-npm` in beside it.
108pub const SYSTEM_PACKAGES: &str =
109 "nodejs22 nodejs22-npm python3 git tar gzip which findutils procps-ng";
110
111/// Claude Code. `--allowedTools` widened from the example's `Bash` alone to the set a
112/// coding task needs; each tool named is auto-approved in `-p` mode, and an unlisted one
113/// is refused silently (the zero-tool-call confident report `docs/AGENT-VMS.md` warns
114/// about), so the list is the permission surface rather than a convenience.
115pub const CLAUDE_CODE: Profile = Profile {
116 npm_package: "@anthropic-ai/claude-code",
117 default_model: "global.anthropic.claude-opus-5",
118 verified: "2026-09-10, us-east-1, @anthropic-ai/claude-code latest on that date",
119};
120
121/// Codex. `-s workspace-write` because the agent runs as uid 1000 in `/workspace` and
122/// nothing outside it is the agent's to write; `--skip-git-repo-check` because a synced
123/// tree arrives without `.git` (`microvms-cli/src/sync.rs`, `SKIPPED_DIRS`).
124pub const CODEX: Profile = Profile {
125 npm_package: "@openai/codex",
126 default_model: "global.openai.gpt-5.6-sol",
127 verified: "2026-09-10, us-east-1, @openai/codex 0.154.0, bedrock-runtime host",
128};
129
130/// The environment file both agents source, and where the credentials live.
131pub const ENV_FILE: &str = "/workspace/.agent-env";
132
133/// Codex's provider config. `HOME=/workspace` in the env file is what makes Codex read
134/// this path.
135pub const CODEX_CONFIG_FILE: &str = "/workspace/.codex/config.toml";
136
137/// The guest marker `install_access` writes and `installed_agents` reads (AGENT-6).
138pub const MARKER_FILE: &str = "/workspace/.agent-vm.json";
139
140/// The lines each agent adds to the environment file, given its model and the token.
141///
142/// Values are written through [`super::sh_double_quote`] by the caller; this returns bare
143/// pairs so a test can assert on names without parsing shell.
144pub fn env_pairs(agent: Agent, model: &str, token: &str) -> Vec<(&'static str, String)> {
145 match agent {
146 Agent::ClaudeCode => vec![
147 ("CLAUDE_CODE_USE_BEDROCK", "1".to_string()),
148 ("ANTHROPIC_MODEL", model.to_string()),
149 ("AWS_BEARER_TOKEN_BEDROCK", token.to_string()),
150 ],
151 // Codex reads `AWS_BEARER_TOKEN_BEDROCK` on a `bedrock-runtime` host and ignores
152 // the provider's `env_key` there. Measured 2026-09-10, us-east-1, Codex 0.154.0,
153 // in a VM carrying Codex alone: with `OPENAI_API_KEY` set and the bearer variable
154 // unset, ten of ten identical tasks failed with
155 // `401 Unauthorized: Credential should be scoped to correct service: 'bedrock'`
156 // after five reconnect attempts, while a `python3` POST to the same endpoint with
157 // the same variable returned 200 — so the token was good and the transport was
158 // fine, and only Codex's own request was unauthorized. Setting the bearer variable
159 // (with everything else unchanged) made the same task succeed, and setting it with
160 // `OPENAI_API_KEY` *unset* also succeeded, which is what proves `env_key` is not
161 // the variable Codex reads here.
162 //
163 // Both are exported: `AWS_BEARER_TOKEN_BEDROCK` is what Codex actually uses, and
164 // `OPENAI_API_KEY` stays because it is the `env_key` the config declares and a
165 // Codex release that honors it would find it. A VM that also carries Claude Code
166 // exported the bearer variable anyway, from that profile — which is why every
167 // earlier two-agent run passed and hid this.
168 Agent::Codex => vec![
169 ("AWS_BEARER_TOKEN_BEDROCK", token.to_string()),
170 ("OPENAI_API_KEY", token.to_string()),
171 ],
172 }
173}
174
175/// Codex's `config.toml`.
176///
177/// The provider is `bedrock-runtime`'s OpenAI-compatible surface,
178/// `https://bedrock-runtime.<region>.amazonaws.com/openai/v1`, which serves the Responses
179/// wire API Codex speaks (the InvokeModel path in CloudTrail). An earlier version of this
180/// file claimed that surface was chat-completions only and pointed Codex at the Mantle
181/// host (`bedrock-mantle.<region>.api.aws`); that claim was wrong by 2026-09-03 and the
182/// Mantle host is a separate service surface, so the config names `bedrock-runtime`.
183/// Verified 2026-09-10, us-east-1, Codex 0.154.0, on the host and inside the VM.
184///
185/// Two lines are required on this host and were not on Mantle. The model must be an
186/// inference-profile id (`global.openai.gpt-5.6-sol`; the bare `openai.gpt-5.6-sol` is
187/// refused with "on-demand throughput isn't supported"). `web_search = "disabled"`
188/// because Codex advertises its hosted web-search tool by default and Bedrock fails the
189/// turn with "web search is not supported for this request", so without it every task
190/// dies on turn one.
191///
192/// `model_reasoning_effort` is set because Codex has no metadata row for a Bedrock model
193/// id and its fallback sends none ("reasoning effort: none" in the banner). Measured
194/// 2026-09-10, us-east-1, Codex 0.154.0, `global.openai.gpt-5.6-sol`: one of five identical
195/// file-writing tasks came back as a plain refusal with zero tool calls and exit 0
196/// ("I can't create or run files in this environment"); the other four wrote the file.
197/// The host accepts `medium` for this model (verified the same day). The setting is
198/// the plausible mitigation, not a measured cure: the decline rate under it is unmeasured,
199/// and a caller who needs the effect verifies it the way the docs show, with an `exec`
200/// that reads the file back.
201pub fn codex_config(model: &str, region: &Region) -> String {
202 format!(
203 "model = \"{model}\"\n\
204 model_provider = \"bedrock\"\n\
205 model_reasoning_effort = \"medium\"\n\
206 web_search = \"disabled\"\n\
207 [model_providers.bedrock]\n\
208 name = \"Amazon Bedrock\"\n\
209 base_url = \"https://bedrock-runtime.{}.amazonaws.com/openai/v1\"\n\
210 env_key = \"OPENAI_API_KEY\"\n\
211 wire_api = \"responses\"\n",
212 region.as_str()
213 )
214}
215
216/// The headless command for one task, already shell-quoted.
217///
218/// `task` is single-quoted for `sh` by the caller through [`super::sh_single_quote`];
219/// this function receives the quoted form so the two agents' templates read as the
220/// literal command lines they are.
221pub fn headless_command(agent: Agent, quoted_task: &str) -> String {
222 match agent {
223 Agent::ClaudeCode => {
224 format!("claude -p {quoted_task} --allowedTools Bash,Read,Edit,Write,Grep,Glob")
225 }
226 Agent::Codex => {
227 format!("codex exec --skip-git-repo-check -s workspace-write {quoted_task}")
228 }
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[test]
237 fn the_two_ids_are_distinct_and_round_trip() {
238 for agent in Agent::ALL {
239 assert_eq!(Agent::parse(agent.as_str()), Some(agent));
240 }
241 assert_ne!(Agent::ClaudeCode.as_str(), Agent::Codex.as_str());
242 assert_eq!(Agent::parse("cursor"), None);
243 }
244
245 #[test]
246 fn every_row_names_a_package_a_model_and_a_verification_date() {
247 for agent in Agent::ALL {
248 let profile = agent.profile();
249 assert!(profile.npm_package.starts_with('@'), "{agent}");
250 assert!(!profile.default_model.is_empty(), "{agent}");
251 assert!(
252 profile.verified.starts_with("2026-"),
253 "{agent}: an undated row is a row nobody can audit for rot"
254 );
255 }
256 }
257
258 #[test]
259 fn the_codex_config_names_the_bedrock_runtime_host_for_the_region() {
260 let config = codex_config("global.openai.gpt-5.6-sol", &Region::UsWest2);
261 assert!(config.contains("https://bedrock-runtime.us-west-2.amazonaws.com/openai/v1"));
262 assert!(
263 !config.contains("mantle"),
264 "the Mantle host is a separate surface:\n{config}"
265 );
266 assert!(config.contains("wire_api = \"responses\""));
267 assert!(config.starts_with("model = \"global.openai.gpt-5.6-sol\"\n"));
268 assert!(
269 config.contains("\nweb_search = \"disabled\"\n"),
270 "bedrock-runtime fails the turn when Codex advertises hosted web search:\n{config}"
271 );
272 assert!(
273 config.contains("\nmodel_reasoning_effort = \"medium\"\n"),
274 "the fallback metadata sends no effort, and a no-effort run declined a task:\n{config}"
275 );
276 }
277
278 #[test]
279 fn the_token_reaches_each_agent_under_the_variable_it_reads() {
280 let claude = env_pairs(Agent::ClaudeCode, "m", "tok");
281 assert!(claude.contains(&("AWS_BEARER_TOKEN_BEDROCK", "tok".to_string())));
282 assert!(claude.contains(&("CLAUDE_CODE_USE_BEDROCK", "1".to_string())));
283 let codex = env_pairs(Agent::Codex, "m", "tok");
284 assert!(
285 codex.contains(&("AWS_BEARER_TOKEN_BEDROCK", "tok".to_string())),
286 "Codex reads the bearer variable on bedrock-runtime and ignores env_key; \
287 without it a Codex-only VM 401s on every task: {codex:?}"
288 );
289 assert!(codex.contains(&("OPENAI_API_KEY", "tok".to_string())));
290 }
291}