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::UsWest2);
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}