1pub mod bedrock;
42pub mod profile;
43
44use std::time::Duration;
45
46use crate::control::artifact::{BaseImage, default_dockerfile};
47use crate::control::{CreateImageRequest, Image};
48use crate::error::{Error, ErrorKind};
49use crate::region::Region;
50use crate::sandbox::{RunRequest, Sandbox, TeardownOpts, TeardownReport};
51use crate::session::{ExecHandle, Session, mint_exec_id};
52use crate::sizing::SizeClass;
53
54pub use bedrock::{BearerToken, Minted};
55pub use profile::{Agent, CODEX_CONFIG_FILE, ENV_FILE, MARKER_FILE, Profile};
56
57pub const AGENT_UID: u32 = 1000;
59pub const AGENT_GID: u32 = 1000;
61pub const WORKDIR: &str = "/workspace";
65pub const DEFAULT_SIZE: SizeClass = SizeClass::Mib1024;
69pub const DEFAULT_PROMPT_TIMEOUT: Duration = Duration::from_secs(900);
71const CHOWN_TIMEOUT: Duration = Duration::from_secs(60);
73
74#[derive(Clone, Debug, Eq, PartialEq)]
76pub struct AgentSpec {
77 pub agent: Agent,
78 pub model: Option<String>,
80 pub cli_version: Option<String>,
84}
85
86impl AgentSpec {
87 pub fn new(agent: Agent) -> Self {
89 Self {
90 agent,
91 model: None,
92 cli_version: None,
93 }
94 }
95
96 pub fn with_model(mut self, model: impl Into<String>) -> Self {
98 self.model = Some(model.into());
99 self
100 }
101
102 pub fn with_cli_version(mut self, version: impl Into<String>) -> Self {
104 self.cli_version = Some(version.into());
105 self
106 }
107
108 pub fn model(&self) -> &str {
110 self.model
111 .as_deref()
112 .unwrap_or(self.agent.profile().default_model)
113 }
114
115 fn npm_spec(&self) -> String {
117 match &self.cli_version {
118 Some(version) => format!("{}@{version}", self.agent.profile().npm_package),
119 None => self.agent.profile().npm_package.to_string(),
120 }
121 }
122}
123
124pub fn require_specs(specs: &[AgentSpec]) -> Result<(), Error> {
132 if specs.is_empty() {
133 return Err(Error::invalid_arg(
134 "no agent named. An agent VM installs at least one of: claude-code, codex.",
135 ));
136 }
137 for (index, spec) in specs.iter().enumerate() {
138 if specs[..index]
139 .iter()
140 .any(|earlier| earlier.agent == spec.agent)
141 {
142 return Err(Error::invalid_arg(format!(
143 "the agent {} is named twice; one row per agent, because a second row \
144 would be a second model for the same CLI.",
145 spec.agent
146 )));
147 }
148 }
149 Ok(())
150}
151
152fn ordered(specs: &[AgentSpec]) -> Vec<&AgentSpec> {
154 let mut sorted: Vec<&AgentSpec> = specs.iter().collect();
155 sorted.sort_by_key(|spec| spec.agent);
156 sorted
157}
158
159pub fn spec_for(specs: &[AgentSpec], agent: Agent) -> Result<&AgentSpec, Error> {
161 specs
162 .iter()
163 .find(|spec| spec.agent == agent)
164 .ok_or_else(|| {
165 Error::invalid_arg(format!(
166 "this VM carries {}, not {agent}.",
167 image_stem(specs).trim_start_matches("agent-vm-")
168 ))
169 })
170}
171
172pub fn image_name_for(
175 sandbox: &Sandbox,
176 specs: &[AgentSpec],
177 request: &CreateImageRequest,
178) -> String {
179 let hash = sandbox.artifact_content_hash_for(request);
180 format!("{}-{}", image_stem(specs), &hash[..12])
181}
182
183pub fn image_request_for(
185 sandbox: &Sandbox,
186 specs: &[AgentSpec],
187 binary: Vec<u8>,
188 build_role_arn: impl Into<String>,
189 size: SizeClass,
190) -> Result<CreateImageRequest, Error> {
191 require_specs(specs)?;
192 let base = BaseImage::al2023();
193 let dockerfile = dockerfile(specs, &base, sandbox.port())?;
194 let mut request =
195 CreateImageRequest::new(image_stem(specs), binary, String::new(), build_role_arn);
196 request.base_image = base;
197 request.dockerfile = Some(dockerfile);
198 request.size = size;
199 let name = image_name_for(sandbox, specs, &request);
200 request.name = name.clone();
201 request.token_scope = Some(name);
202 Ok(request)
203}
204
205pub fn launch_request_for(
207 specs: &[AgentSpec],
208 image_identifier: impl Into<String>,
209 execution_role_arn: Option<String>,
210) -> RunRequest {
211 let identifier = image_identifier.into();
212 let mut request = RunRequest::new().with_image(&identifier).with_egress();
213 request.execution_role_arn = execution_role_arn;
214 request.token_scope = Some(image_stem(specs));
215 request
216}
217
218pub fn image_stem(specs: &[AgentSpec]) -> String {
221 let agents: Vec<&str> = ordered(specs)
222 .into_iter()
223 .map(|spec| spec.agent.as_str())
224 .collect();
225 format!("agent-vm-{}", agents.join("-"))
226}
227
228pub fn dockerfile(specs: &[AgentSpec], base: &BaseImage, port: u16) -> Result<String, Error> {
236 require_specs(specs)?;
237 let stanza = default_dockerfile(port, Some(WORKDIR), base, None);
238 let mut lines: Vec<String> = stanza.lines().map(str::to_string).collect();
239
240 let chmod = lines
241 .iter()
242 .position(|line| line == "RUN chmod 0755 /agentd")
243 .ok_or_else(|| stanza_drift("RUN chmod 0755 /agentd"))?;
244 let mut layers = vec![
245 String::new(),
246 "# The coding agents. Node 22 runs both CLIs; the rest is what a coding agent".to_string(),
247 "# expects of a working shell. Unpinned unless a cli_version was given, so the".to_string(),
248 "# image carries the registry's latest at build time and a pin changes the reuse hash."
249 .to_string(),
250 format!(
251 "RUN dnf install -y --setopt=install_weak_deps=0 {} && dnf clean all",
252 profile::SYSTEM_PACKAGES
253 ),
254 ];
255 let packages: Vec<String> = ordered(specs)
256 .into_iter()
257 .map(AgentSpec::npm_spec)
258 .collect();
259 layers.push(format!(
260 "RUN npm install -g {} && npm cache clean --force",
261 packages.join(" ")
262 ));
263 layers.extend([
264 String::new(),
265 "# The non-root user the agents run as. Appended directly because `useradd` is not"
266 .to_string(),
267 "# in the minimal base. Not decoration: Claude Code refuses --dangerously-skip-permissions"
268 .to_string(),
269 "# as root and then denies its own tool calls while still reporting (measured: zero"
270 .to_string(),
271 "# shell calls as uid 0, 147 as uid 1000 on the same task).".to_string(),
272 format!(
273 "RUN echo \"agent:x:{AGENT_UID}:{AGENT_GID}::{WORKDIR}:/bin/bash\" >> /etc/passwd \\"
274 ),
275 format!(" && echo \"agent:x:{AGENT_GID}:\" >> /etc/group"),
276 ]);
277 lines.splice(chmod + 1..chmod + 1, layers);
278
279 let mkdir = lines
280 .iter()
281 .position(|line| line == &format!("RUN mkdir -p {WORKDIR}"))
282 .ok_or_else(|| stanza_drift("RUN mkdir -p /workspace"))?;
283 lines[mkdir] = format!("RUN mkdir -p {WORKDIR} && chown {AGENT_UID}:{AGENT_GID} {WORKDIR}");
286
287 let mut text = lines.join("\n");
288 text.push('\n');
289 Ok(text)
290}
291
292fn stanza_drift(line: &str) -> Error {
293 Error::new(
294 ErrorKind::Unexpected,
295 format!(
296 "default_dockerfile no longer emits `{line}`, so the agent layers have no splice \
297 point. Update agents::dockerfile beside the stanza change."
298 ),
299 )
300}
301
302#[derive(Clone, Debug)]
307pub struct BedrockAccess {
308 pub region: Region,
309 pub token: BearerToken,
310}
311
312#[derive(Clone, Debug, Eq, PartialEq)]
314pub struct GuestFile {
315 pub path: String,
316 pub contents: Vec<u8>,
317 pub mode: &'static str,
319}
320
321pub fn sh_double_quote(value: &str) -> String {
326 let mut quoted = String::with_capacity(value.len() + 2);
327 quoted.push('"');
328 for ch in value.chars() {
329 if matches!(ch, '"' | '\\' | '$' | '`') {
330 quoted.push('\\');
331 }
332 quoted.push(ch);
333 }
334 quoted.push('"');
335 quoted
336}
337
338pub fn sh_single_quote(value: &str) -> String {
342 let mut quoted = String::with_capacity(value.len() + 2);
343 quoted.push('\'');
344 for ch in value.chars() {
345 if ch == '\'' {
346 quoted.push_str("'\\''");
347 } else {
348 quoted.push(ch);
349 }
350 }
351 quoted.push('\'');
352 quoted
353}
354
355pub fn provisioning_files(
358 specs: &[AgentSpec],
359 access: &BedrockAccess,
360) -> Result<Vec<GuestFile>, Error> {
361 require_specs(specs)?;
362 let specs = ordered(specs);
363
364 let mut env = vec![
368 ("HOME", WORKDIR.to_string()),
369 ("PATH", "/usr/local/bin:/usr/bin:/bin".to_string()),
370 ("AWS_REGION", access.region.as_str().to_string()),
371 ];
372 for spec in &specs {
373 env.extend(profile::env_pairs(
374 spec.agent,
375 spec.model(),
376 access.token.expose(),
377 ));
378 }
379 let mut env_file = String::new();
380 for (key, value) in env {
381 env_file.push_str(&format!("export {key}={}\n", sh_double_quote(&value)));
382 }
383
384 let mut files = vec![GuestFile {
385 path: ENV_FILE.to_string(),
386 contents: env_file.into_bytes(),
387 mode: "0600",
388 }];
389 if let Some(codex) = specs.iter().find(|spec| spec.agent == Agent::Codex) {
390 files.push(GuestFile {
391 path: CODEX_CONFIG_FILE.to_string(),
392 contents: profile::codex_config(codex.model(), &access.region).into_bytes(),
393 mode: "0600",
394 });
395 }
396 let marker = serde_json::json!({
397 "version": 1,
398 "region": access.region.as_str(),
399 "agents": specs.iter().map(|spec| serde_json::json!({
400 "agent": spec.agent.as_str(),
401 "model": spec.model(),
402 "cliVersion": spec.cli_version,
403 })).collect::<Vec<_>>(),
404 });
405 files.push(GuestFile {
406 path: MARKER_FILE.to_string(),
407 contents: serde_json::to_vec_pretty(&marker).map_err(|error| {
408 Error::new(
409 ErrorKind::Unexpected,
410 format!("the marker will not serialize: {error}"),
411 )
412 })?,
413 mode: "0644",
416 });
417 Ok(files)
418}
419
420pub async fn install_access(
427 session: &Session,
428 specs: &[AgentSpec],
429 access: &BedrockAccess,
430) -> Result<(), Error> {
431 for file in provisioning_files(specs, access)? {
432 session
433 .upload_file(&file.path, &file.contents, Some(file.mode))
434 .await?;
435 }
436 let request = crate::protocol::exec::StartRequest {
437 exec_id: mint_exec_id(),
438 command: vec![format!("chown -R {AGENT_UID}:{AGENT_GID} {WORKDIR}")],
439 shell: true,
440 cwd: None,
441 env: std::collections::HashMap::new(),
442 user: None,
443 group: None,
444 timeout_sec: Some(CHOWN_TIMEOUT.as_secs_f64()),
445 stdin: false,
446 reap_group_on_exit: false,
447 };
448 let result = session.run_sync(request, CHOWN_TIMEOUT).await?;
449 if !result.succeeded() {
450 return Err(Error::new(
451 ErrorKind::ExecFailed,
452 format!(
453 "handing {WORKDIR} to uid {AGENT_UID} failed (exit {:?}): {}",
454 result.exit_code(),
455 result.stderr().trim()
456 ),
457 ));
458 }
459 Ok(())
460}
461
462pub async fn installed_agents(session: &Session) -> Result<Vec<AgentSpec>, Error> {
468 let bytes = match session.download_file(MARKER_FILE).await {
469 Ok(bytes) => bytes,
470 Err(error) if error.wire_kind() == Some(crate::error::WireKind::NotFound) => {
471 return Err(Error::new(
472 ErrorKind::Precondition,
473 format!(
474 "{MARKER_FILE} is not in this VM, so it was not provisioned as an agent \
475 VM. `microvm agent-up --vm-name <NAME>` installs an agent and writes \
476 the marker; `--agent` names one explicitly when the marker is absent."
477 ),
478 ));
479 }
480 Err(error) => return Err(error),
481 };
482 let marker: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| {
483 Error::new(
484 ErrorKind::Protocol,
485 format!("{MARKER_FILE} is not the JSON agent-up wrote: {error}"),
486 )
487 })?;
488 let rows = marker["agents"].as_array().ok_or_else(|| {
489 Error::new(
490 ErrorKind::Protocol,
491 format!("{MARKER_FILE} carries no `agents` array"),
492 )
493 })?;
494 let mut specs = Vec::with_capacity(rows.len());
495 for row in rows {
496 let name = row["agent"].as_str().unwrap_or_default();
497 let agent = Agent::parse(name).ok_or_else(|| {
498 Error::new(
499 ErrorKind::Protocol,
500 format!("{MARKER_FILE} names an agent this client has no profile for: {name:?}"),
501 )
502 })?;
503 let mut spec = AgentSpec::new(agent);
504 if let Some(model) = row["model"].as_str() {
505 spec.model = Some(model.to_string());
506 }
507 if let Some(version) = row["cliVersion"].as_str() {
508 spec.cli_version = Some(version.to_string());
509 }
510 specs.push(spec);
511 }
512 require_specs(&specs)?;
513 Ok(specs)
514}
515
516#[derive(Clone, Debug, Default)]
518pub struct PromptOptions {
519 pub exec_id: Option<String>,
521 pub timeout: Option<Duration>,
524}
525
526pub fn prompt_request(
528 spec: &AgentSpec,
529 task: &str,
530 options: &PromptOptions,
531) -> Result<crate::protocol::exec::StartRequest, Error> {
532 if task.trim().is_empty() {
533 return Err(Error::invalid_arg(
534 "the task is empty. An agent prompted with nothing answers with nothing, at the \
535 price of a model call.",
536 ));
537 }
538 let command = format!(
539 ". {ENV_FILE} && {}",
540 profile::headless_command(spec.agent, &sh_single_quote(task))
541 );
542 Ok(crate::protocol::exec::StartRequest {
543 exec_id: options.exec_id.clone().unwrap_or_else(mint_exec_id),
544 command: vec![command],
545 shell: true,
546 cwd: Some(WORKDIR.to_string()),
547 env: std::collections::HashMap::new(),
550 user: Some(AGENT_UID),
551 group: Some(AGENT_GID),
552 timeout_sec: options.timeout.map(|timeout| timeout.as_secs_f64()),
553 stdin: false,
554 reap_group_on_exit: false,
555 })
556}
557
558pub fn headless_command_template(agent: Agent) -> String {
561 format!(
562 ". {ENV_FILE} && {}",
563 profile::headless_command(agent, "'<TASK>'")
564 )
565}
566
567pub async fn prompt(
570 session: &Session,
571 spec: &AgentSpec,
572 task: &str,
573 options: &PromptOptions,
574) -> Result<ExecHandle, Error> {
575 session.run(prompt_request(spec, task, options)?).await
576}
577
578pub struct AgentVm {
583 sandbox: Sandbox,
584 specs: Vec<AgentSpec>,
585}
586
587impl std::fmt::Debug for AgentVm {
588 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
589 f.debug_struct("AgentVm")
590 .field("agents", &self.specs)
591 .field("sandbox", &self.sandbox)
592 .finish()
593 }
594}
595
596impl AgentVm {
597 pub fn new(sandbox: Sandbox, specs: Vec<AgentSpec>) -> Result<Self, Error> {
599 require_specs(&specs)?;
600 Ok(Self { sandbox, specs })
601 }
602
603 pub fn claude_code(sandbox: Sandbox) -> Self {
605 Self {
606 sandbox,
607 specs: vec![AgentSpec::new(Agent::ClaudeCode)],
608 }
609 }
610
611 pub fn codex(sandbox: Sandbox) -> Self {
613 Self {
614 sandbox,
615 specs: vec![AgentSpec::new(Agent::Codex)],
616 }
617 }
618
619 pub fn specs(&self) -> Vec<AgentSpec> {
621 ordered(&self.specs).into_iter().cloned().collect()
622 }
623
624 pub fn sandbox(&self) -> &Sandbox {
625 &self.sandbox
626 }
627
628 pub fn sandbox_mut(&mut self) -> &mut Sandbox {
629 &mut self.sandbox
630 }
631
632 pub fn session(&self) -> Option<&Session> {
634 self.sandbox.session()
635 }
636
637 pub fn image_name(&self, request: &CreateImageRequest) -> String {
641 image_name_for(&self.sandbox, &self.specs, request)
642 }
643
644 pub fn image_request(
651 &self,
652 binary: Vec<u8>,
653 build_role_arn: impl Into<String>,
654 size: SizeClass,
655 ) -> Result<CreateImageRequest, Error> {
656 image_request_for(&self.sandbox, &self.specs, binary, build_role_arn, size)
657 }
658
659 pub async fn build(&mut self, request: CreateImageRequest) -> Result<&Image, Error> {
661 self.sandbox.build_image(request).await
662 }
663
664 pub fn launch_request(
667 &self,
668 image_identifier: impl Into<String>,
669 execution_role_arn: Option<String>,
670 ) -> RunRequest {
671 launch_request_for(&self.specs, image_identifier, execution_role_arn)
672 }
673
674 pub async fn launch(&mut self, request: RunRequest) -> Result<&Session, Error> {
676 let session = self.sandbox.run(request).await?;
677 session
678 .wait_until_ready(crate::session::DEFAULT_READY_TIMEOUT)
679 .await?;
680 Ok(session)
681 }
682
683 fn require_session(&self) -> Result<&Session, Error> {
684 self.sandbox.session().ok_or_else(|| {
685 Error::new(
686 ErrorKind::Precondition,
687 "this agent VM has not been launched; call `launch` first.",
688 )
689 })
690 }
691
692 pub async fn install_access(&self, access: &BedrockAccess) -> Result<(), Error> {
694 install_access(self.require_session()?, &self.specs, access).await
695 }
696
697 pub async fn prompt(
699 &self,
700 agent: Agent,
701 task: &str,
702 options: &PromptOptions,
703 ) -> Result<ExecHandle, Error> {
704 let spec = spec_for(&self.specs, agent)?;
705 prompt(self.require_session()?, spec, task, options).await
706 }
707
708 pub async fn terminate(&mut self, opts: TeardownOpts) -> TeardownReport {
710 self.sandbox.terminate(opts).await
711 }
712}
713
714#[cfg(test)]
715mod tests {
716 use std::sync::Arc;
717
718 use super::*;
719 use crate::control::artifact::{
720 require_daemon_cmd, require_matching_agentd_port, require_matching_from, require_workdir,
721 };
722 use crate::session::testing::{Recorder, Reply, session_with};
723
724 fn both() -> Vec<AgentSpec> {
725 vec![
726 AgentSpec::new(Agent::Codex),
727 AgentSpec::new(Agent::ClaudeCode),
728 ]
729 }
730
731 fn access() -> BedrockAccess {
732 BedrockAccess {
733 region: Region::UsEast1,
734 token: bedrock::mint_with(
735 &aws_credential_types::Credentials::new("AKID", "secret", None, None, "t"),
736 &Region::UsEast1,
737 Duration::from_secs(60),
738 std::time::SystemTime::UNIX_EPOCH,
739 )
740 .expect("mints")
741 .token,
742 }
743 }
744
745 #[test]
754 fn the_dockerfile_passes_the_guards_and_carries_the_recipe() {
755 let base = BaseImage::al2023();
756 let text = dockerfile(&both(), &base, 9000).expect("derives");
757
758 require_matching_from(&base, &text).expect("FROM pairs with the base");
759 require_workdir(&base, Some(&text)).expect("a WORKDIR is declared");
760 require_daemon_cmd(&text).expect("ENTRYPOINT [] + CMD agentd survive");
761 require_matching_agentd_port(9000, &text).expect("the port agrees");
762
763 assert_eq!(
764 text.matches("RUN dnf install").count(),
765 1,
766 "the system layer is emitted once for two profiles:\n{text}"
767 );
768 assert!(
769 text.contains("RUN dnf install -y --setopt=install_weak_deps=0 nodejs22 nodejs22-npm "),
770 "with weak deps off, bare `npm` is Node 18's and the npm layer exits 127:\n{text}"
771 );
772 assert!(
773 text.contains("RUN npm install -g @anthropic-ai/claude-code @openai/codex && npm"),
774 "both CLIs in profile order, in one layer:\n{text}"
775 );
776 assert!(
777 text.contains("agent:x:1000:1000::/workspace:/bin/bash"),
778 "{text}"
779 );
780 assert!(
781 text.contains("RUN mkdir -p /workspace && chown 1000:1000 /workspace"),
782 "{text}"
783 );
784 let layers_at = text.find("RUN npm install").expect("layers");
785 let workdir_at = text.find("WORKDIR /workspace").expect("workdir");
786 let cmd_at = text.find("CMD [\"/agentd\"]").expect("cmd");
787 assert!(layers_at < workdir_at && workdir_at < cmd_at, "{text}");
788 }
789
790 #[test]
793 fn a_cli_version_pin_reaches_the_install_line() {
794 let base = BaseImage::al2023();
795 let pinned = vec![AgentSpec::new(Agent::ClaudeCode).with_cli_version("2.1.0")];
796 let text = dockerfile(&pinned, &base, 9000).expect("derives");
797 assert!(text.contains("@anthropic-ai/claude-code@2.1.0"), "{text}");
798 let plain = dockerfile(&[AgentSpec::new(Agent::ClaudeCode)], &base, 9000).expect("d");
799 assert_ne!(text, plain);
800 }
801
802 #[test]
803 fn an_empty_or_repeated_spec_set_is_refused_before_any_text_is_built() {
804 let base = BaseImage::al2023();
805 assert_eq!(
806 dockerfile(&[], &base, 9000).expect_err("empty").kind(),
807 ErrorKind::InvalidArg
808 );
809 let twice = vec![AgentSpec::new(Agent::Codex), AgentSpec::new(Agent::Codex)];
810 let error = dockerfile(&twice, &base, 9000).expect_err("repeated");
811 assert!(
812 error.to_string().contains("codex is named twice"),
813 "{error}"
814 );
815 }
816
817 #[test]
819 fn the_image_stem_is_stable_under_spec_order() {
820 assert_eq!(image_stem(&both()), "agent-vm-claude-code-codex");
821 assert_eq!(
822 image_stem(&[AgentSpec::new(Agent::Codex)]),
823 "agent-vm-codex"
824 );
825 }
826
827 #[test]
833 fn a_codex_only_vm_still_gets_the_variable_codex_reads() {
834 let files = provisioning_files(&[AgentSpec::new(Agent::Codex)], &access()).expect("files");
835 let env = String::from_utf8(files[0].contents.clone()).expect("utf-8");
836 assert!(
837 env.contains("export AWS_BEARER_TOKEN_BEDROCK="),
838 "no Claude Code profile is present to export it, and Codex 401s without it:\n{env}"
839 );
840 }
841
842 #[test]
843 fn the_file_set_follows_the_profiles_and_the_env_file_is_quoted() {
844 let access = access();
845 let claude_only =
846 provisioning_files(&[AgentSpec::new(Agent::ClaudeCode)], &access).expect("files");
847 assert_eq!(
848 claude_only
849 .iter()
850 .map(|f| f.path.as_str())
851 .collect::<Vec<_>>(),
852 vec![ENV_FILE, MARKER_FILE]
853 );
854
855 let files = provisioning_files(&both(), &access).expect("files");
856 assert_eq!(
857 files.iter().map(|f| f.path.as_str()).collect::<Vec<_>>(),
858 vec![ENV_FILE, CODEX_CONFIG_FILE, MARKER_FILE]
859 );
860 let env = String::from_utf8(files[0].contents.clone()).expect("utf-8");
861 assert_eq!(files[0].mode, "0600");
862 assert!(
863 env.contains("export PATH=\"/usr/local/bin:/usr/bin:/bin\"\n"),
864 "{env}"
865 );
866 assert!(env.contains("export HOME=\"/workspace\"\n"), "{env}");
867 assert!(env.contains("export AWS_REGION=\"us-east-1\"\n"), "{env}");
868 assert!(
869 env.contains("export CLAUDE_CODE_USE_BEDROCK=\"1\"\n"),
870 "{env}"
871 );
872 assert!(
873 env.contains("export ANTHROPIC_MODEL=\"global.anthropic.claude-opus-5\"\n"),
874 "{env}"
875 );
876 let token = access.token.expose();
877 assert!(env.contains(&format!("export AWS_BEARER_TOKEN_BEDROCK=\"{token}\"\n")));
878 assert!(env.contains(&format!("export OPENAI_API_KEY=\"{token}\"\n")));
879
880 let config = String::from_utf8(files[1].contents.clone()).expect("utf-8");
881 assert!(
882 config.contains("bedrock-runtime.us-east-1.amazonaws.com/openai/v1"),
883 "{config}"
884 );
885 assert!(
886 config.contains("model = \"global.openai.gpt-5.6-sol\""),
887 "{config}"
888 );
889
890 let marker: serde_json::Value = serde_json::from_slice(&files[2].contents).expect("json");
891 assert_eq!(files[2].mode, "0644");
892 assert_eq!(marker["agents"][0]["agent"], "claude-code");
893 assert_eq!(marker["agents"][1]["agent"], "codex");
894 assert_eq!(marker["agents"][1]["model"], "global.openai.gpt-5.6-sol");
895 assert!(
896 !files[2]
897 .contents
898 .windows(token.len())
899 .any(|w| w == token.as_bytes()),
900 "the marker is world-readable and must never carry the token"
901 );
902 }
903
904 #[test]
905 fn shell_quoting_escapes_what_each_quote_style_cannot_hold() {
906 assert_eq!(sh_double_quote("a$b\"c`d\\e"), "\"a\\$b\\\"c\\`d\\\\e\"");
907 assert_eq!(sh_single_quote("it's"), "'it'\\''s'");
908 assert_eq!(sh_single_quote("plain"), "'plain'");
909 }
910
911 #[tokio::test(start_paused = true)]
917 async fn install_access_uploads_then_chowns_as_root() {
918 let recorder = Recorder::with([
919 Reply::Body(200, Vec::new()),
920 Reply::Body(200, Vec::new()),
921 Reply::Body(200, Vec::new()),
922 Reply::ok(serde_json::json!({"exec_id":"c1","phase":"running"})),
923 Reply::ok(serde_json::json!({"exec_id":"c1","phase":"exited"})),
924 Reply::ok(serde_json::json!({
925 "exec_id":"c1","phase":"exited","exit_code":0,"signal":null,
926 "stdout":"","stderr":"","truncated":false,"writers_may_be_alive":false
927 })),
928 ]);
929 let (session, _, _) = session_with(Arc::clone(&recorder));
930
931 install_access(&session, &both(), &access())
932 .await
933 .expect("installs");
934
935 let seen = recorder.requests();
936 assert_eq!(seen.len(), 6, "{seen:#?}");
937 for (request, (encoded, mode)) in seen.iter().zip([
938 ("%2Fworkspace%2F.agent-env", "0600"),
939 ("%2Fworkspace%2F.codex%2Fconfig.toml", "0600"),
940 ("%2Fworkspace%2F.agent-vm.json", "0644"),
941 ]) {
942 assert_eq!(request.method, "PUT");
943 assert_eq!(
944 request.path,
945 format!("/v1/fs/file?path={encoded}&mode={mode}"),
946 "each file goes to the file route with its own mode"
947 );
948 }
949 assert_eq!(seen[3].path, "/v1/exec/start");
950 let start: serde_json::Value = serde_json::from_slice(&seen[3].body).expect("json");
951 assert_eq!(start["command"][0], "chown -R 1000:1000 /workspace");
952 assert_eq!(start["shell"], true);
953 assert!(
954 start["user"].is_null(),
955 "the chown runs as the daemon's own user"
956 );
957 assert_eq!(seen[5].path, "/v1/exec/c1/ack");
958 }
959
960 #[tokio::test(start_paused = true)]
963 async fn a_failed_chown_fails_the_install() {
964 let recorder = Recorder::with([
965 Reply::Body(200, Vec::new()),
966 Reply::Body(200, Vec::new()),
967 Reply::ok(serde_json::json!({"exec_id":"c1","phase":"running"})),
968 Reply::ok(serde_json::json!({"exec_id":"c1","phase":"exited"})),
969 Reply::ok(serde_json::json!({
970 "exec_id":"c1","phase":"exited","exit_code":1,"signal":null,
971 "stdout":"","stderr":"chown: cannot access","truncated":false,
972 "writers_may_be_alive":false
973 })),
974 ]);
975 let (session, _, _) = session_with(recorder);
976 let error = install_access(&session, &[AgentSpec::new(Agent::ClaudeCode)], &access())
977 .await
978 .expect_err("fails");
979 assert_eq!(error.kind(), ErrorKind::ExecFailed);
980 assert!(
981 error.to_string().contains("chown: cannot access"),
982 "{error}"
983 );
984 }
985
986 #[tokio::test]
989 async fn installed_agents_reads_the_marker_back() {
990 let specs = vec![
991 AgentSpec::new(Agent::ClaudeCode).with_model("global.anthropic.claude-sonnet-5"),
992 AgentSpec::new(Agent::Codex).with_cli_version("0.50.0"),
993 ];
994 let files = provisioning_files(&specs, &access()).expect("files");
995 let marker = files.last().expect("marker").contents.clone();
996 let recorder = Recorder::with([Reply::Body(200, marker)]);
997 let (session, _, _) = session_with(recorder);
998
999 let read = installed_agents(&session).await.expect("reads");
1000 assert_eq!(read.len(), 2);
1001 assert_eq!(read[0].agent, Agent::ClaudeCode);
1002 assert_eq!(read[0].model(), "global.anthropic.claude-sonnet-5");
1003 assert_eq!(read[1].agent, Agent::Codex);
1004 assert_eq!(read[1].cli_version.as_deref(), Some("0.50.0"));
1005 assert_eq!(read[1].model(), "global.openai.gpt-5.6-sol");
1006 }
1007
1008 #[tokio::test]
1011 async fn a_missing_marker_names_agent_up_and_other_failures_propagate() {
1012 let recorder = Recorder::with([
1013 Reply::Body(404, b"not found".to_vec()),
1014 Reply::Body(503, b"not bootstrapped".to_vec()),
1015 ]);
1016 let (session, _, _) = session_with(recorder);
1017 let error = installed_agents(&session).await.expect_err("absent");
1018 assert_eq!(error.kind(), ErrorKind::Precondition);
1019 assert!(error.to_string().contains("agent-up"), "{error}");
1020 let other = installed_agents(&session).await.expect_err("503");
1021 assert_ne!(other.kind(), ErrorKind::Precondition);
1022 }
1023
1024 #[test]
1030 fn the_prompt_request_runs_the_agent_demoted_in_the_workspace() {
1031 let spec = AgentSpec::new(Agent::ClaudeCode);
1032 let request = prompt_request(
1033 &spec,
1034 "count the files; it's quick",
1035 &PromptOptions {
1036 exec_id: Some("p1".into()),
1037 timeout: Some(Duration::from_secs(120)),
1038 },
1039 )
1040 .expect("builds");
1041 assert_eq!(request.exec_id, "p1");
1042 assert_eq!(request.user, Some(1000));
1043 assert_eq!(request.group, Some(1000));
1044 assert_eq!(request.cwd.as_deref(), Some("/workspace"));
1045 assert!(request.shell);
1046 assert!(
1047 request.env.is_empty(),
1048 "the token never rides in the request"
1049 );
1050 assert_eq!(request.timeout_sec, Some(120.0));
1051 assert!(!request.stdin);
1052 assert_eq!(
1053 request.command,
1054 vec![
1055 ". /workspace/.agent-env && claude -p 'count the files; it'\\''s quick' \
1056 --allowedTools Bash,Read,Edit,Write,Grep,Glob"
1057 .to_string()
1058 ]
1059 );
1060
1061 let codex = prompt_request(
1062 &AgentSpec::new(Agent::Codex),
1063 "hi",
1064 &PromptOptions::default(),
1065 )
1066 .expect("builds");
1067 assert!(
1068 codex.command[0].contains("codex exec --skip-git-repo-check -s workspace-write 'hi'")
1069 );
1070 assert!(codex.timeout_sec.is_none());
1071 assert!(!codex.exec_id.is_empty(), "an id was minted");
1072 }
1073
1074 #[test]
1075 fn an_empty_task_is_refused_locally() {
1076 let error = prompt_request(
1077 &AgentSpec::new(Agent::Codex),
1078 " \n",
1079 &PromptOptions::default(),
1080 )
1081 .expect_err("empty");
1082 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1083 }
1084
1085 fn sandbox() -> Sandbox {
1088 Sandbox::with_control_plane(crate::control::ControlPlane::with_transport(
1089 Arc::new(crate::control::fake::FakeControlPlane::new()),
1090 Region::UsEast1,
1091 Arc::new(crate::control::fake::TestClock::new()),
1092 ))
1093 }
1094
1095 #[test]
1098 fn the_image_request_is_content_named_and_egress_is_on_at_launch() {
1099 let vm = AgentVm::new(sandbox(), both()).expect("specs");
1100 let a = vm
1101 .image_request(
1102 b"daemon".to_vec(),
1103 "arn:aws:iam::123456789012:role/b",
1104 DEFAULT_SIZE,
1105 )
1106 .expect("request");
1107 let b = vm
1108 .image_request(
1109 b"daemon".to_vec(),
1110 "arn:aws:iam::123456789012:role/b",
1111 DEFAULT_SIZE,
1112 )
1113 .expect("request");
1114 let c = vm
1115 .image_request(
1116 b"other".to_vec(),
1117 "arn:aws:iam::123456789012:role/b",
1118 DEFAULT_SIZE,
1119 )
1120 .expect("request");
1121 assert_eq!(a.name, b.name);
1122 assert_ne!(a.name, c.name);
1123 assert!(
1124 a.name.starts_with("agent-vm-claude-code-codex-"),
1125 "{}",
1126 a.name
1127 );
1128 assert_eq!(a.name.len(), "agent-vm-claude-code-codex-".len() + 12);
1129 assert_eq!(a.token_scope.as_deref(), Some(a.name.as_str()));
1130 assert!(a.code_artifact_uri.is_empty(), "the upload is the caller's");
1131 assert!(
1132 a.dockerfile
1133 .as_deref()
1134 .is_some_and(|d| d.contains("@openai/codex"))
1135 );
1136 assert_eq!(a.size, SizeClass::Mib1024);
1137
1138 let launch = vm.launch_request("arn:image", Some("arn:role".into()));
1139 assert!(
1140 launch.egress,
1141 "neither agent reaches Bedrock without egress"
1142 );
1143 assert_eq!(launch.image_identifier.as_deref(), Some("arn:image"));
1144 assert_eq!(
1145 launch.token_scope.as_deref(),
1146 Some("agent-vm-claude-code-codex")
1147 );
1148 }
1149
1150 #[tokio::test]
1153 async fn the_named_constructors_carry_one_agent_each() {
1154 let claude = AgentVm::claude_code(sandbox());
1155 assert_eq!(claude.specs(), vec![AgentSpec::new(Agent::ClaudeCode)]);
1156 let codex = AgentVm::codex(sandbox());
1157 assert_eq!(codex.specs(), vec![AgentSpec::new(Agent::Codex)]);
1158
1159 let error = codex
1160 .prompt(Agent::ClaudeCode, "hi", &PromptOptions::default())
1161 .await
1162 .expect_err("wrong agent");
1163 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1164 assert!(error.to_string().contains("carries codex"), "{error}");
1165 let unlaunched = codex
1166 .prompt(Agent::Codex, "hi", &PromptOptions::default())
1167 .await
1168 .expect_err("no session");
1169 assert_eq!(unlaunched.kind(), ErrorKind::Precondition);
1170 }
1171}