use crate::region::Region;
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum Agent {
ClaudeCode,
Codex,
}
impl Agent {
pub const ALL: [Agent; 2] = [Agent::ClaudeCode, Agent::Codex];
pub fn as_str(self) -> &'static str {
match self {
Agent::ClaudeCode => "claude-code",
Agent::Codex => "codex",
}
}
pub fn parse(text: &str) -> Option<Agent> {
Agent::ALL.into_iter().find(|agent| agent.as_str() == text)
}
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;
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())
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum AgentPermissionMode {
#[default]
AgentDefault,
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",
)),
}
}
}
#[derive(Debug)]
pub struct Profile {
pub npm_package: &'static str,
pub default_model: &'static str,
pub verified: &'static str,
}
pub const SYSTEM_PACKAGES: &str =
"nodejs22 nodejs22-npm python3 git tar gzip which findutils procps-ng";
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",
};
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",
};
pub const ENV_FILE: &str = "/workspace/.agent-env";
pub const CODEX_CONFIG_FILE: &str = "/workspace/.codex/config.toml";
pub const MARKER_FILE: &str = "/workspace/.agent-vm.json";
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()),
],
Agent::Codex => vec![
("AWS_BEARER_TOKEN_BEDROCK", token.to_string()),
("OPENAI_API_KEY", token.to_string()),
],
}
}
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()
)
}
pub fn version_command(agent: Agent) -> Vec<String> {
vec![
match agent {
Agent::ClaudeCode => "claude",
Agent::Codex => "codex",
}
.to_string(),
"--version".to_string(),
]
}
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())));
}
}