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, AgentPermissionMode, 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 agent_vm_egress_posture() -> crate::control::EgressPosture {
226 launch_request_for(&[], "arn:image", None).egress_posture()
227}
228
229pub fn image_stem(specs: &[AgentSpec]) -> String {
232 let agents: Vec<&str> = ordered(specs)
233 .into_iter()
234 .map(|spec| spec.agent.as_str())
235 .collect();
236 format!("agent-vm-{}", agents.join("-"))
237}
238
239pub fn dockerfile(specs: &[AgentSpec], base: &BaseImage, port: u16) -> Result<String, Error> {
247 require_specs(specs)?;
248 let stanza = default_dockerfile(port, Some(WORKDIR), base, None);
249 let mut lines: Vec<String> = stanza.lines().map(str::to_string).collect();
250
251 let chmod = lines
252 .iter()
253 .position(|line| line == "RUN chmod 0755 /agentd")
254 .ok_or_else(|| stanza_drift("RUN chmod 0755 /agentd"))?;
255 let mut layers = vec![
256 String::new(),
257 "# The coding agents. Node 22 runs both CLIs; the rest is what a coding agent".to_string(),
258 "# expects of a working shell. Unpinned unless a cli_version was given, so the".to_string(),
259 "# image carries the registry's latest at build time and a pin changes the reuse hash."
260 .to_string(),
261 format!(
262 "RUN dnf install -y --setopt=install_weak_deps=0 {} && dnf clean all",
263 profile::SYSTEM_PACKAGES
264 ),
265 ];
266 let packages: Vec<String> = ordered(specs)
267 .into_iter()
268 .map(AgentSpec::npm_spec)
269 .collect();
270 layers.push(format!(
271 "RUN npm install -g {} && npm cache clean --force",
272 packages.join(" ")
273 ));
274 layers.extend([
275 String::new(),
276 "# The non-root user the agents run as. Appended directly because `useradd` is not"
277 .to_string(),
278 "# in the minimal base. Not decoration: Claude Code refuses --dangerously-skip-permissions"
279 .to_string(),
280 "# as root and then denies its own tool calls while still reporting (measured: zero"
281 .to_string(),
282 "# shell calls as uid 0, 147 as uid 1000 on the same task).".to_string(),
283 format!(
284 "RUN echo \"agent:x:{AGENT_UID}:{AGENT_GID}::{WORKDIR}:/bin/bash\" >> /etc/passwd \\"
285 ),
286 format!(" && echo \"agent:x:{AGENT_GID}:\" >> /etc/group"),
287 ]);
288 lines.splice(chmod + 1..chmod + 1, layers);
289
290 let mkdir = lines
291 .iter()
292 .position(|line| line == &format!("RUN mkdir -p {WORKDIR}"))
293 .ok_or_else(|| stanza_drift("RUN mkdir -p /workspace"))?;
294 lines[mkdir] = format!("RUN mkdir -p {WORKDIR} && chown {AGENT_UID}:{AGENT_GID} {WORKDIR}");
297
298 let mut text = lines.join("\n");
299 text.push('\n');
300 Ok(text)
301}
302
303fn stanza_drift(line: &str) -> Error {
304 Error::new(
305 ErrorKind::Unexpected,
306 format!(
307 "default_dockerfile no longer emits `{line}`, so the agent layers have no splice \
308 point. Update agents::dockerfile beside the stanza change."
309 ),
310 )
311}
312
313#[derive(Clone, Debug)]
318pub struct BedrockAccess {
319 pub region: Region,
320 pub token: BearerToken,
321}
322
323#[derive(Clone, Debug, Eq, PartialEq)]
325pub struct GuestFile {
326 pub path: String,
327 pub contents: Vec<u8>,
328 pub mode: &'static str,
330}
331
332pub fn sh_double_quote(value: &str) -> String {
337 let mut quoted = String::with_capacity(value.len() + 2);
338 quoted.push('"');
339 for ch in value.chars() {
340 if matches!(ch, '"' | '\\' | '$' | '`') {
341 quoted.push('\\');
342 }
343 quoted.push(ch);
344 }
345 quoted.push('"');
346 quoted
347}
348
349pub fn sh_single_quote(value: &str) -> String {
353 let mut quoted = String::with_capacity(value.len() + 2);
354 quoted.push('\'');
355 for ch in value.chars() {
356 if ch == '\'' {
357 quoted.push_str("'\\''");
358 } else {
359 quoted.push(ch);
360 }
361 }
362 quoted.push('\'');
363 quoted
364}
365
366pub fn provisioning_files(
369 specs: &[AgentSpec],
370 access: &BedrockAccess,
371) -> Result<Vec<GuestFile>, Error> {
372 require_specs(specs)?;
373 let specs = ordered(specs);
374
375 let mut env = vec![
379 ("HOME", WORKDIR.to_string()),
380 ("PATH", "/usr/local/bin:/usr/bin:/bin".to_string()),
381 ("AWS_REGION", access.region.as_str().to_string()),
382 ];
383 for spec in &specs {
384 env.extend(profile::env_pairs(
385 spec.agent,
386 spec.model(),
387 access.token.expose(),
388 ));
389 }
390 let mut env_file = String::new();
391 for (key, value) in env {
392 env_file.push_str(&format!("export {key}={}\n", sh_double_quote(&value)));
393 }
394
395 let mut files = vec![GuestFile {
396 path: ENV_FILE.to_string(),
397 contents: env_file.into_bytes(),
398 mode: "0600",
399 }];
400 if let Some(codex) = specs.iter().find(|spec| spec.agent == Agent::Codex) {
401 files.push(GuestFile {
402 path: CODEX_CONFIG_FILE.to_string(),
403 contents: profile::codex_config(codex.model(), &access.region).into_bytes(),
404 mode: "0600",
405 });
406 }
407 let marker = serde_json::json!({
408 "version": 1,
409 "region": access.region.as_str(),
410 "agents": specs.iter().map(|spec| serde_json::json!({
411 "agent": spec.agent.as_str(),
412 "model": spec.model(),
413 "cliVersion": spec.cli_version,
414 })).collect::<Vec<_>>(),
415 });
416 files.push(GuestFile {
417 path: MARKER_FILE.to_string(),
418 contents: serde_json::to_vec_pretty(&marker).map_err(|error| {
419 Error::new(
420 ErrorKind::Unexpected,
421 format!("the marker will not serialize: {error}"),
422 )
423 })?,
424 mode: "0644",
427 });
428 Ok(files)
429}
430
431pub async fn install_access(
438 session: &Session,
439 specs: &[AgentSpec],
440 access: &BedrockAccess,
441) -> Result<(), Error> {
442 for file in provisioning_files(specs, access)? {
443 session
444 .upload_file(&file.path, &file.contents, Some(file.mode))
445 .await?;
446 }
447 let request = crate::protocol::exec::StartRequest {
448 exec_id: mint_exec_id(),
449 command: vec![format!("chown -R {AGENT_UID}:{AGENT_GID} {WORKDIR}")],
450 shell: true.into(),
451 cwd: None,
452 env: std::collections::HashMap::new(),
453 user: None,
454 group: None,
455 timeout_sec: Some(CHOWN_TIMEOUT.as_secs_f64()),
456 stdin: false,
457 reap_group_on_exit: false,
458 inherit_image_env: false,
459 };
460 let result = session.run_sync(request, CHOWN_TIMEOUT).await?;
461 if !result.succeeded() {
462 return Err(Error::new(
463 ErrorKind::ExecFailed,
464 format!(
465 "handing {WORKDIR} to uid {AGENT_UID} failed (exit {:?}): {}",
466 result.exit_code(),
467 result.stderr().trim()
468 ),
469 ));
470 }
471 Ok(())
472}
473
474pub async fn installed_agents(session: &Session) -> Result<Vec<AgentSpec>, Error> {
480 let bytes = match session.download_file(MARKER_FILE).await {
481 Ok(bytes) => bytes,
482 Err(error) if error.wire_kind() == Some(crate::error::WireKind::NotFound) => {
483 return Err(Error::new(
484 ErrorKind::Precondition,
485 format!(
486 "{MARKER_FILE} is not in this VM, so it was not provisioned as an agent \
487 VM. `microvm agent-up --vm-name <NAME>` installs an agent and writes \
488 the marker; `--agent` names one explicitly when the marker is absent."
489 ),
490 ));
491 }
492 Err(error) => return Err(error),
493 };
494 let marker: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| {
495 Error::new(
496 ErrorKind::Protocol,
497 format!("{MARKER_FILE} is not the JSON agent-up wrote: {error}"),
498 )
499 })?;
500 let rows = marker["agents"].as_array().ok_or_else(|| {
501 Error::new(
502 ErrorKind::Protocol,
503 format!("{MARKER_FILE} carries no `agents` array"),
504 )
505 })?;
506 let mut specs = Vec::with_capacity(rows.len());
507 for row in rows {
508 let name = row["agent"].as_str().unwrap_or_default();
509 let agent = Agent::parse(name).ok_or_else(|| {
510 Error::new(
511 ErrorKind::Protocol,
512 format!("{MARKER_FILE} names an agent this client has no profile for: {name:?}"),
513 )
514 })?;
515 let mut spec = AgentSpec::new(agent);
516 if let Some(model) = row["model"].as_str() {
517 spec.model = Some(model.to_string());
518 }
519 if let Some(version) = row["cliVersion"].as_str() {
520 spec.cli_version = Some(version.to_string());
521 }
522 specs.push(spec);
523 }
524 require_specs(&specs)?;
525 Ok(specs)
526}
527
528pub const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(60);
536
537pub const VERSION_PROBE_WAIT: Duration = Duration::from_secs(90);
539
540pub async fn installed_version(session: &Session, agent: Agent) -> Result<String, Error> {
543 let request = crate::protocol::exec::StartRequest {
544 exec_id: mint_exec_id(),
545 command: profile::version_command(agent),
546 shell: false.into(),
547 cwd: Some(WORKDIR.to_string()),
548 env: std::collections::HashMap::from([(
549 "PATH".into(),
550 "/usr/local/bin:/usr/bin:/bin".into(),
551 )]),
552 user: Some(AGENT_UID.into()),
553 group: Some(AGENT_GID.into()),
554 timeout_sec: Some(VERSION_PROBE_TIMEOUT.as_secs_f64()),
555 stdin: false,
556 reap_group_on_exit: true,
557 inherit_image_env: false,
558 };
559 let result = session.run_sync(request, VERSION_PROBE_WAIT).await?;
560 if result.succeeded()
561 && let Some(version) = reported_version(result.stdout())
562 {
563 return Ok(version.to_string());
564 }
565 if result
566 .outcome
567 .as_ref()
568 .is_some_and(|outcome| outcome.timed_out)
569 {
570 return Err(Error::new(
571 ErrorKind::Timeout,
572 format!(
573 "{agent} --version did not answer within {}s; the first run on a fresh VM \
574 reads the executable from a demand-paged disk, so retry once before \
575 suspecting the image",
576 VERSION_PROBE_TIMEOUT.as_secs(),
577 ),
578 ));
579 }
580 Err(Error::new(
581 ErrorKind::Precondition,
582 format!(
583 "could not read {agent}'s installed version; verify the pinned agent executable in the image",
584 ),
585 ))
586}
587
588fn reported_version(output: &str) -> Option<&str> {
589 output.split_ascii_whitespace().find(|word| {
590 word.len() <= 32
591 && word.split('.').count() == 3
592 && word
593 .split('.')
594 .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
595 })
596}
597
598#[derive(Clone, Debug, Default)]
600pub struct PromptOptions {
601 pub exec_id: Option<String>,
603 pub timeout: Option<Duration>,
606 pub permission_mode: AgentPermissionMode,
608 pub reap_group_on_exit: bool,
610}
611
612pub fn prompt_request(
614 spec: &AgentSpec,
615 task: &str,
616 options: &PromptOptions,
617) -> Result<crate::protocol::exec::StartRequest, Error> {
618 if task.trim().is_empty() {
619 return Err(Error::invalid_arg(
620 "the task is empty. An agent prompted with nothing answers with nothing, at the \
621 price of a model call.",
622 ));
623 }
624 if options.timeout.is_some_and(|timeout| timeout.is_zero()) {
625 return Err(Error::invalid_arg("execution timeout must be positive"));
626 }
627 let command = format!(
628 ". {ENV_FILE} && {}",
629 profile::headless_command(spec.agent, &sh_single_quote(task), options.permission_mode)
630 );
631 Ok(crate::protocol::exec::StartRequest {
632 exec_id: options.exec_id.clone().unwrap_or_else(mint_exec_id),
633 command: vec![command],
634 shell: true.into(),
635 cwd: Some(WORKDIR.to_string()),
636 env: std::collections::HashMap::new(),
639 user: Some(AGENT_UID.into()),
640 group: Some(AGENT_GID.into()),
641 timeout_sec: options.timeout.map(|timeout| timeout.as_secs_f64()),
642 stdin: false,
643 reap_group_on_exit: options.reap_group_on_exit,
644 inherit_image_env: false,
645 })
646}
647
648pub fn headless_command_template(agent: Agent) -> String {
651 format!(
652 ". {ENV_FILE} && {}",
653 profile::headless_command(agent, "'<TASK>'", AgentPermissionMode::AgentDefault)
654 )
655}
656
657pub async fn prompt(
660 session: &Session,
661 spec: &AgentSpec,
662 task: &str,
663 options: &PromptOptions,
664) -> Result<ExecHandle, Error> {
665 session.run(prompt_request(spec, task, options)?).await
666}
667
668pub struct AgentVm {
673 sandbox: Sandbox,
674 specs: Vec<AgentSpec>,
675}
676
677impl std::fmt::Debug for AgentVm {
678 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
679 f.debug_struct("AgentVm")
680 .field("agents", &self.specs)
681 .field("sandbox", &self.sandbox)
682 .finish()
683 }
684}
685
686impl AgentVm {
687 pub fn new(sandbox: Sandbox, specs: Vec<AgentSpec>) -> Result<Self, Error> {
689 require_specs(&specs)?;
690 Ok(Self { sandbox, specs })
691 }
692
693 pub async fn adopt(
699 control: crate::control::ControlPlane,
700 specs: Vec<AgentSpec>,
701 microvm_id: impl Into<String>,
702 endpoint: impl Into<String>,
703 agent_token: impl Into<String>,
704 ) -> Result<Self, Error> {
705 require_specs(&specs)?;
706 let sandbox = Sandbox::adopt(control, microvm_id, endpoint, agent_token).await?;
707 Ok(Self { sandbox, specs })
708 }
709
710 pub async fn from_name(
712 store: &dyn crate::names::NameStore,
713 specs: Vec<AgentSpec>,
714 name: &str,
715 region: Option<Region>,
716 port: Option<u16>,
717 ) -> Result<Self, Error> {
718 require_specs(&specs)?;
719 let sandbox = Sandbox::from_name(store, name, region, port).await?;
720 Ok(Self { sandbox, specs })
721 }
722
723 pub async fn adopt_in(
725 region: Region,
726 specs: Vec<AgentSpec>,
727 microvm_id: impl Into<String>,
728 endpoint: impl Into<String>,
729 agent_token: impl Into<String>,
730 port: Option<u16>,
731 ) -> Result<Self, Error> {
732 require_specs(&specs)?;
733 let sandbox = Sandbox::adopt_in(region, microvm_id, endpoint, agent_token, port).await?;
734 Ok(Self { sandbox, specs })
735 }
736
737 pub fn detach(&mut self) -> Result<crate::sandbox::Detached, Error> {
740 self.sandbox.detach()
741 }
742
743 pub fn into_parts(self) -> (Sandbox, Vec<AgentSpec>) {
745 (self.sandbox, self.specs)
746 }
747
748 pub fn claude_code(sandbox: Sandbox) -> Self {
750 Self {
751 sandbox,
752 specs: vec![AgentSpec::new(Agent::ClaudeCode)],
753 }
754 }
755
756 pub fn codex(sandbox: Sandbox) -> Self {
758 Self {
759 sandbox,
760 specs: vec![AgentSpec::new(Agent::Codex)],
761 }
762 }
763
764 pub fn specs(&self) -> Vec<AgentSpec> {
766 ordered(&self.specs).into_iter().cloned().collect()
767 }
768
769 pub fn sandbox(&self) -> &Sandbox {
770 &self.sandbox
771 }
772
773 pub fn sandbox_mut(&mut self) -> &mut Sandbox {
774 &mut self.sandbox
775 }
776
777 pub fn session(&self) -> Option<&Session> {
779 self.sandbox.session()
780 }
781
782 pub fn image_name(&self, request: &CreateImageRequest) -> String {
786 image_name_for(&self.sandbox, &self.specs, request)
787 }
788
789 pub fn image_request(
796 &self,
797 binary: Vec<u8>,
798 build_role_arn: impl Into<String>,
799 size: SizeClass,
800 ) -> Result<CreateImageRequest, Error> {
801 image_request_for(&self.sandbox, &self.specs, binary, build_role_arn, size)
802 }
803
804 pub async fn build(&mut self, request: CreateImageRequest) -> Result<&Image, Error> {
806 self.sandbox.build_image(request).await
807 }
808
809 pub fn launch_request(
812 &self,
813 image_identifier: impl Into<String>,
814 execution_role_arn: Option<String>,
815 ) -> RunRequest {
816 launch_request_for(&self.specs, image_identifier, execution_role_arn)
817 }
818
819 pub async fn launch(&mut self, request: RunRequest) -> Result<&Session, Error> {
821 let session = self.sandbox.run(request).await?;
822 session
823 .wait_until_ready(crate::session::DEFAULT_READY_TIMEOUT)
824 .await?;
825 Ok(session)
826 }
827
828 fn require_session(&self) -> Result<&Session, Error> {
829 self.sandbox.session().ok_or_else(|| {
830 Error::new(
831 ErrorKind::Precondition,
832 "this agent VM has not been launched; call `launch` first.",
833 )
834 })
835 }
836
837 pub async fn install_access(&self, access: &BedrockAccess) -> Result<(), Error> {
839 install_access(self.require_session()?, &self.specs, access).await
840 }
841
842 pub async fn prompt(
844 &self,
845 agent: Agent,
846 task: &str,
847 options: &PromptOptions,
848 ) -> Result<ExecHandle, Error> {
849 let spec = spec_for(&self.specs, agent)?;
850 prompt(self.require_session()?, spec, task, options).await
851 }
852
853 pub async fn terminate(&mut self, opts: TeardownOpts) -> TeardownReport {
855 self.sandbox.terminate(opts).await
856 }
857}
858
859#[cfg(test)]
860mod tests {
861 use std::sync::Arc;
862
863 use super::*;
864 use crate::control::artifact::{
865 require_daemon_cmd, require_matching_agentd_port, require_matching_from, require_workdir,
866 };
867 use crate::session::testing::{Recorder, Reply, session_with};
868
869 fn both() -> Vec<AgentSpec> {
870 vec![
871 AgentSpec::new(Agent::Codex),
872 AgentSpec::new(Agent::ClaudeCode),
873 ]
874 }
875
876 fn access() -> BedrockAccess {
877 BedrockAccess {
878 region: Region::UsEast1,
879 token: bedrock::mint_with(
880 &aws_credential_types::Credentials::new("AKID", "secret", None, None, "t"),
881 &Region::UsEast1,
882 Duration::from_secs(60),
883 std::time::SystemTime::UNIX_EPOCH,
884 )
885 .expect("mints")
886 .token,
887 }
888 }
889
890 #[test]
899 fn the_dockerfile_passes_the_guards_and_carries_the_recipe() {
900 let base = BaseImage::al2023();
901 let text = dockerfile(&both(), &base, 9000).expect("derives");
902
903 require_matching_from(&base, &text).expect("FROM pairs with the base");
904 require_workdir(&base, Some(&text)).expect("a WORKDIR is declared");
905 require_daemon_cmd(&text).expect("ENTRYPOINT [] + CMD agentd survive");
906 require_matching_agentd_port(9000, &text).expect("the port agrees");
907
908 assert_eq!(
909 text.matches("RUN dnf install").count(),
910 1,
911 "the system layer is emitted once for two profiles:\n{text}"
912 );
913 assert!(
914 text.contains("RUN dnf install -y --setopt=install_weak_deps=0 nodejs22 nodejs22-npm "),
915 "with weak deps off, bare `npm` is Node 18's and the npm layer exits 127:\n{text}"
916 );
917 assert!(
918 text.contains("RUN npm install -g @anthropic-ai/claude-code @openai/codex && npm"),
919 "both CLIs in profile order, in one layer:\n{text}"
920 );
921 assert!(
922 text.contains("agent:x:1000:1000::/workspace:/bin/bash"),
923 "{text}"
924 );
925 assert!(
926 text.contains("RUN mkdir -p /workspace && chown 1000:1000 /workspace"),
927 "{text}"
928 );
929 let layers_at = text.find("RUN npm install").expect("layers");
930 let workdir_at = text.find("WORKDIR /workspace").expect("workdir");
931 let cmd_at = text.find("CMD [\"/agentd\"]").expect("cmd");
932 assert!(layers_at < workdir_at && workdir_at < cmd_at, "{text}");
933 }
934
935 #[test]
938 fn a_cli_version_pin_reaches_the_install_line() {
939 let base = BaseImage::al2023();
940 let pinned = vec![AgentSpec::new(Agent::ClaudeCode).with_cli_version("2.1.0")];
941 let text = dockerfile(&pinned, &base, 9000).expect("derives");
942 assert!(text.contains("@anthropic-ai/claude-code@2.1.0"), "{text}");
943 let plain = dockerfile(&[AgentSpec::new(Agent::ClaudeCode)], &base, 9000).expect("d");
944 assert_ne!(text, plain);
945 }
946
947 #[test]
948 fn an_empty_or_repeated_spec_set_is_refused_before_any_text_is_built() {
949 let base = BaseImage::al2023();
950 assert_eq!(
951 dockerfile(&[], &base, 9000).expect_err("empty").kind(),
952 ErrorKind::InvalidArg
953 );
954 let twice = vec![AgentSpec::new(Agent::Codex), AgentSpec::new(Agent::Codex)];
955 let error = dockerfile(&twice, &base, 9000).expect_err("repeated");
956 assert!(
957 error.to_string().contains("codex is named twice"),
958 "{error}"
959 );
960 }
961
962 #[test]
964 fn the_image_stem_is_stable_under_spec_order() {
965 assert_eq!(image_stem(&both()), "agent-vm-claude-code-codex");
966 assert_eq!(
967 image_stem(&[AgentSpec::new(Agent::Codex)]),
968 "agent-vm-codex"
969 );
970 }
971
972 #[test]
978 fn a_codex_only_vm_still_gets_the_variable_codex_reads() {
979 let files = provisioning_files(&[AgentSpec::new(Agent::Codex)], &access()).expect("files");
980 let env = String::from_utf8(files[0].contents.clone()).expect("utf-8");
981 assert!(
982 env.contains("export AWS_BEARER_TOKEN_BEDROCK="),
983 "no Claude Code profile is present to export it, and Codex 401s without it:\n{env}"
984 );
985 }
986
987 #[test]
988 fn the_file_set_follows_the_profiles_and_the_env_file_is_quoted() {
989 let access = access();
990 let claude_only =
991 provisioning_files(&[AgentSpec::new(Agent::ClaudeCode)], &access).expect("files");
992 assert_eq!(
993 claude_only
994 .iter()
995 .map(|f| f.path.as_str())
996 .collect::<Vec<_>>(),
997 vec![ENV_FILE, MARKER_FILE]
998 );
999
1000 let files = provisioning_files(&both(), &access).expect("files");
1001 assert_eq!(
1002 files.iter().map(|f| f.path.as_str()).collect::<Vec<_>>(),
1003 vec![ENV_FILE, CODEX_CONFIG_FILE, MARKER_FILE]
1004 );
1005 let env = String::from_utf8(files[0].contents.clone()).expect("utf-8");
1006 assert_eq!(files[0].mode, "0600");
1007 assert!(
1008 env.contains("export PATH=\"/usr/local/bin:/usr/bin:/bin\"\n"),
1009 "{env}"
1010 );
1011 assert!(env.contains("export HOME=\"/workspace\"\n"), "{env}");
1012 assert!(env.contains("export AWS_REGION=\"us-east-1\"\n"), "{env}");
1013 assert!(
1014 env.contains("export CLAUDE_CODE_USE_BEDROCK=\"1\"\n"),
1015 "{env}"
1016 );
1017 assert!(
1018 env.contains("export ANTHROPIC_MODEL=\"global.anthropic.claude-opus-5\"\n"),
1019 "{env}"
1020 );
1021 let token = access.token.expose();
1022 assert!(env.contains(&format!("export AWS_BEARER_TOKEN_BEDROCK=\"{token}\"\n")));
1023 assert!(env.contains(&format!("export OPENAI_API_KEY=\"{token}\"\n")));
1024
1025 let config = String::from_utf8(files[1].contents.clone()).expect("utf-8");
1026 assert!(
1027 config.contains("bedrock-runtime.us-east-1.amazonaws.com/openai/v1"),
1028 "{config}"
1029 );
1030 assert!(
1031 config.contains("model = \"global.openai.gpt-5.6-sol\""),
1032 "{config}"
1033 );
1034
1035 let marker: serde_json::Value = serde_json::from_slice(&files[2].contents).expect("json");
1036 assert_eq!(files[2].mode, "0644");
1037 assert_eq!(marker["agents"][0]["agent"], "claude-code");
1038 assert_eq!(marker["agents"][1]["agent"], "codex");
1039 assert_eq!(marker["agents"][1]["model"], "global.openai.gpt-5.6-sol");
1040 assert!(
1041 !files[2]
1042 .contents
1043 .windows(token.len())
1044 .any(|w| w == token.as_bytes()),
1045 "the marker is world-readable and must never carry the token"
1046 );
1047 }
1048
1049 #[test]
1050 fn shell_quoting_escapes_what_each_quote_style_cannot_hold() {
1051 assert_eq!(sh_double_quote("a$b\"c`d\\e"), "\"a\\$b\\\"c\\`d\\\\e\"");
1052 assert_eq!(sh_single_quote("it's"), "'it'\\''s'");
1053 assert_eq!(sh_single_quote("plain"), "'plain'");
1054 }
1055
1056 #[tokio::test(start_paused = true)]
1062 async fn install_access_uploads_then_chowns_as_root() {
1063 let recorder = Recorder::with([
1064 Reply::Body(200, Vec::new()),
1065 Reply::Body(200, Vec::new()),
1066 Reply::Body(200, Vec::new()),
1067 Reply::ok(serde_json::json!({"exec_id":"c1","phase":"running"})),
1068 Reply::ok(serde_json::json!({"exec_id":"c1","phase":"exited"})),
1069 Reply::ok(serde_json::json!({
1070 "exec_id":"c1","phase":"exited","exit_code":0,"signal":null,
1071 "stdout":"","stderr":"","truncated":false,"writers_may_be_alive":false
1072 })),
1073 ]);
1074 let (session, _, _) = session_with(Arc::clone(&recorder));
1075
1076 install_access(&session, &both(), &access())
1077 .await
1078 .expect("installs");
1079
1080 let seen = recorder.requests();
1081 assert_eq!(seen.len(), 6, "{seen:#?}");
1082 for (request, (encoded, mode)) in seen.iter().zip([
1083 ("%2Fworkspace%2F.agent-env", "0600"),
1084 ("%2Fworkspace%2F.codex%2Fconfig.toml", "0600"),
1085 ("%2Fworkspace%2F.agent-vm.json", "0644"),
1086 ]) {
1087 assert_eq!(request.method, "PUT");
1088 assert_eq!(
1089 request.path,
1090 format!("/v1/fs/file?path={encoded}&mode={mode}"),
1091 "each file goes to the file route with its own mode"
1092 );
1093 }
1094 assert_eq!(seen[3].path, "/v1/exec/start");
1095 let start: serde_json::Value = serde_json::from_slice(&seen[3].body).expect("json");
1096 assert_eq!(start["command"][0], "chown -R 1000:1000 /workspace");
1097 assert_eq!(start["shell"], true);
1098 assert!(
1099 start["user"].is_null(),
1100 "the chown runs as the daemon's own user"
1101 );
1102 assert_eq!(seen[5].path, "/v1/exec/c1/ack");
1103 }
1104
1105 #[tokio::test(start_paused = true)]
1108 async fn a_failed_chown_fails_the_install() {
1109 let recorder = Recorder::with([
1110 Reply::Body(200, Vec::new()),
1111 Reply::Body(200, Vec::new()),
1112 Reply::ok(serde_json::json!({"exec_id":"c1","phase":"running"})),
1113 Reply::ok(serde_json::json!({"exec_id":"c1","phase":"exited"})),
1114 Reply::ok(serde_json::json!({
1115 "exec_id":"c1","phase":"exited","exit_code":1,"signal":null,
1116 "stdout":"","stderr":"chown: cannot access","truncated":false,
1117 "writers_may_be_alive":false
1118 })),
1119 ]);
1120 let (session, _, _) = session_with(recorder);
1121 let error = install_access(&session, &[AgentSpec::new(Agent::ClaudeCode)], &access())
1122 .await
1123 .expect_err("fails");
1124 assert_eq!(error.kind(), ErrorKind::ExecFailed);
1125 assert!(
1126 error.to_string().contains("chown: cannot access"),
1127 "{error}"
1128 );
1129 }
1130
1131 #[tokio::test]
1134 async fn installed_agents_reads_the_marker_back() {
1135 let specs = vec![
1136 AgentSpec::new(Agent::ClaudeCode).with_model("global.anthropic.claude-sonnet-5"),
1137 AgentSpec::new(Agent::Codex).with_cli_version("0.50.0"),
1138 ];
1139 let files = provisioning_files(&specs, &access()).expect("files");
1140 let marker = files.last().expect("marker").contents.clone();
1141 let recorder = Recorder::with([Reply::Body(200, marker)]);
1142 let (session, _, _) = session_with(recorder);
1143
1144 let read = installed_agents(&session).await.expect("reads");
1145 assert_eq!(read.len(), 2);
1146 assert_eq!(read[0].agent, Agent::ClaudeCode);
1147 assert_eq!(read[0].model(), "global.anthropic.claude-sonnet-5");
1148 assert_eq!(read[1].agent, Agent::Codex);
1149 assert_eq!(read[1].cli_version.as_deref(), Some("0.50.0"));
1150 assert_eq!(read[1].model(), "global.openai.gpt-5.6-sol");
1151 }
1152
1153 #[tokio::test]
1156 async fn a_missing_marker_names_agent_up_and_other_failures_propagate() {
1157 let recorder = Recorder::with([
1158 Reply::Body(404, b"not found".to_vec()),
1159 Reply::Body(503, b"not bootstrapped".to_vec()),
1160 ]);
1161 let (session, _, _) = session_with(recorder);
1162 let error = installed_agents(&session).await.expect_err("absent");
1163 assert_eq!(error.kind(), ErrorKind::Precondition);
1164 assert!(error.to_string().contains("agent-up"), "{error}");
1165 let other = installed_agents(&session).await.expect_err("503");
1166 assert_ne!(other.kind(), ErrorKind::Precondition);
1167 }
1168
1169 #[test]
1175 fn the_prompt_request_runs_the_agent_demoted_in_the_workspace() {
1176 let spec = AgentSpec::new(Agent::ClaudeCode);
1177 let request = prompt_request(
1178 &spec,
1179 "count the files; it's quick",
1180 &PromptOptions {
1181 exec_id: Some("p1".into()),
1182 timeout: Some(Duration::from_secs(120)),
1183 ..PromptOptions::default()
1184 },
1185 )
1186 .expect("builds");
1187 assert_eq!(request.exec_id, "p1");
1188 assert_eq!(request.user, Some(1000.into()));
1189 assert_eq!(request.group, Some(1000.into()));
1190 assert_eq!(request.cwd.as_deref(), Some("/workspace"));
1191 assert!(request.shell.is_shell());
1192 assert!(
1193 request.env.is_empty(),
1194 "the token never rides in the request"
1195 );
1196 assert_eq!(request.timeout_sec, Some(120.0));
1197 assert!(!request.stdin);
1198 assert_eq!(
1199 request.command,
1200 vec![
1201 ". /workspace/.agent-env && claude -p 'count the files; it'\\''s quick' \
1202 --allowedTools Bash,Read,Edit,Write,Grep,Glob"
1203 .to_string()
1204 ]
1205 );
1206
1207 let codex = prompt_request(
1208 &AgentSpec::new(Agent::Codex),
1209 "hi",
1210 &PromptOptions::default(),
1211 )
1212 .expect("builds");
1213 assert!(
1214 codex.command[0].contains("codex exec --skip-git-repo-check -s workspace-write 'hi'")
1215 );
1216 assert!(codex.timeout_sec.is_none());
1217 assert!(!codex.exec_id.is_empty(), "an id was minted");
1218 }
1219
1220 #[test]
1221 fn an_empty_task_is_refused_locally() {
1222 let error = prompt_request(
1223 &AgentSpec::new(Agent::Codex),
1224 " \n",
1225 &PromptOptions::default(),
1226 )
1227 .expect_err("empty");
1228 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1229 }
1230
1231 fn sandbox() -> Sandbox {
1234 Sandbox::with_control_plane(crate::control::ControlPlane::with_transport(
1235 Arc::new(crate::control::fake::FakeControlPlane::new()),
1236 Region::UsEast1,
1237 Arc::new(crate::control::fake::TestClock::new()),
1238 ))
1239 }
1240
1241 #[tokio::test]
1244 async fn an_agent_vm_adopts_with_checked_specs_and_the_service_lifecycle() {
1245 use crate::control::fake::{Answer, FakeControlPlane, TestClock, microvm_response};
1246 let plane = |recorder: &Arc<FakeControlPlane>| {
1247 crate::control::ControlPlane::with_transport(
1248 Arc::clone(recorder) as Arc<dyn crate::control::transport::Transport>,
1249 Region::UsEast1,
1250 Arc::new(TestClock::new()),
1251 )
1252 };
1253 let endpoint = "https://mvm-abc123.microvm.us-east-1.amazonaws.com";
1254
1255 let recorder = Arc::new(FakeControlPlane::new());
1256 let error = AgentVm::adopt(plane(&recorder), vec![], "mvm-abc123", endpoint, "t")
1257 .await
1258 .expect_err("an empty spec set");
1259 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1260 assert_eq!(recorder.calls().len(), 0);
1261
1262 let recorder = Arc::new(FakeControlPlane::new());
1263 recorder.answer(
1264 "GetMicrovm",
1265 Answer::ok(microvm_response("SUSPENDED", None)),
1266 );
1267 let vm = AgentVm::adopt(plane(&recorder), both(), "mvm-abc123", endpoint, "t")
1268 .await
1269 .expect("adopts");
1270 assert!(vm.sandbox().adopted());
1271 assert_eq!(
1272 vm.sandbox().lifecycle(),
1273 crate::sandbox::Lifecycle::Suspended
1274 );
1275 assert_eq!(vm.specs().len(), 2);
1276 }
1277
1278 #[test]
1281 fn the_image_request_is_content_named_and_egress_is_on_at_launch() {
1282 let vm = AgentVm::new(sandbox(), both()).expect("specs");
1283 let a = vm
1284 .image_request(
1285 b"daemon".to_vec(),
1286 "arn:aws:iam::123456789012:role/b",
1287 DEFAULT_SIZE,
1288 )
1289 .expect("request");
1290 let b = vm
1291 .image_request(
1292 b"daemon".to_vec(),
1293 "arn:aws:iam::123456789012:role/b",
1294 DEFAULT_SIZE,
1295 )
1296 .expect("request");
1297 let c = vm
1298 .image_request(
1299 b"other".to_vec(),
1300 "arn:aws:iam::123456789012:role/b",
1301 DEFAULT_SIZE,
1302 )
1303 .expect("request");
1304 assert_eq!(a.name, b.name);
1305 assert_ne!(a.name, c.name);
1306 assert!(
1307 a.name.starts_with("agent-vm-claude-code-codex-"),
1308 "{}",
1309 a.name
1310 );
1311 assert_eq!(a.name.len(), "agent-vm-claude-code-codex-".len() + 12);
1312 assert_eq!(a.token_scope.as_deref(), Some(a.name.as_str()));
1313 assert!(a.code_artifact_uri.is_empty(), "the upload is the caller's");
1314 assert!(
1315 a.dockerfile
1316 .as_deref()
1317 .is_some_and(|d| d.contains("@openai/codex"))
1318 );
1319 assert_eq!(a.size, SizeClass::Mib1024);
1320
1321 let launch = vm.launch_request("arn:image", Some("arn:role".into()));
1322 assert!(
1323 launch.egress,
1324 "neither agent reaches Bedrock without egress"
1325 );
1326 assert_eq!(launch.image_identifier.as_deref(), Some("arn:image"));
1327 assert_eq!(
1328 launch.token_scope.as_deref(),
1329 Some("agent-vm-claude-code-codex")
1330 );
1331 }
1332
1333 #[tokio::test]
1336 async fn the_named_constructors_carry_one_agent_each() {
1337 let claude = AgentVm::claude_code(sandbox());
1338 assert_eq!(claude.specs(), vec![AgentSpec::new(Agent::ClaudeCode)]);
1339 let codex = AgentVm::codex(sandbox());
1340 assert_eq!(codex.specs(), vec![AgentSpec::new(Agent::Codex)]);
1341
1342 let error = codex
1343 .prompt(Agent::ClaudeCode, "hi", &PromptOptions::default())
1344 .await
1345 .expect_err("wrong agent");
1346 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1347 assert!(error.to_string().contains("carries codex"), "{error}");
1348 let unlaunched = codex
1349 .prompt(Agent::Codex, "hi", &PromptOptions::default())
1350 .await
1351 .expect_err("no session");
1352 assert_eq!(unlaunched.kind(), ErrorKind::Precondition);
1353 }
1354
1355 #[test]
1356 fn unrestricted_permissions_deadline_and_reaping_are_explicit_and_demoted() {
1357 for (agent, expected, forbidden) in [
1358 (
1359 Agent::ClaudeCode,
1360 "--dangerously-skip-permissions",
1361 "--allowedTools",
1362 ),
1363 (
1364 Agent::Codex,
1365 "--dangerously-bypass-approvals-and-sandbox",
1366 "workspace-write",
1367 ),
1368 ] {
1369 let task = "a quote ' and $(touch /tmp/must-not-run); \"hello\"";
1370 let request = prompt_request(
1371 &AgentSpec::new(agent),
1372 task,
1373 &PromptOptions {
1374 exec_id: Some("same-task".into()),
1375 timeout: Some(Duration::from_secs(1200)),
1376 permission_mode: AgentPermissionMode::Unrestricted,
1377 reap_group_on_exit: true,
1378 },
1379 )
1380 .expect("valid");
1381 assert!(request.command[0].contains(expected));
1382 assert!(!request.command[0].contains(forbidden));
1383 assert!(request.command[0].contains(&sh_single_quote(task)));
1384 assert_eq!(
1385 (request.user, request.group),
1386 (Some(1000.into()), Some(1000.into()))
1387 );
1388 assert_eq!(request.timeout_sec, Some(1200.0));
1389 assert!(request.reap_group_on_exit);
1390 assert_eq!(request.exec_id, "same-task");
1391 let default =
1392 prompt_request(&AgentSpec::new(agent), task, &PromptOptions::default()).unwrap();
1393 assert!(default.command[0].contains(forbidden));
1394 assert!(!default.command[0].contains(expected));
1395 assert!(!default.reap_group_on_exit);
1396 assert_eq!(default.timeout_sec, None);
1397 }
1398 assert!("auto".parse::<AgentPermissionMode>().is_err());
1399 assert!(
1400 prompt_request(
1401 &AgentSpec::new(Agent::Codex),
1402 "task",
1403 &PromptOptions {
1404 timeout: Some(Duration::ZERO),
1405 ..PromptOptions::default()
1406 }
1407 )
1408 .is_err()
1409 );
1410 }
1411
1412 #[test]
1413 fn version_reporting_only_accepts_numeric_version_words() {
1414 assert_eq!(reported_version("2.1.7 (Claude Code)\n"), Some("2.1.7"));
1415 assert_eq!(reported_version("codex-cli 0.154.0\n"), Some("0.154.0"));
1416 assert_eq!(reported_version("SECRET=credential\n"), None);
1417 assert_eq!(reported_version("1.2.3-secret"), None);
1418 }
1419
1420 #[cfg(unix)]
1422 #[test]
1423 fn unrestricted_task_text_is_one_literal_shell_argument() {
1424 let task = "task ' with $(printf INJECTED) and `printf ALSO` ; $HOME";
1425 for agent in Agent::ALL {
1426 let command = profile::headless_command(
1427 agent,
1428 &sh_single_quote(task),
1429 AgentPermissionMode::Unrestricted,
1430 );
1431 let script = format!(
1432 "claude() {{ printf '%s\\n' \"$@\"; }}; codex() {{ printf '%s\\n' \"$@\"; }}; {command}"
1433 );
1434 let output = std::process::Command::new("/bin/sh")
1435 .args(["-c", &script])
1436 .output()
1437 .unwrap();
1438 assert!(output.status.success());
1439 let stdout = String::from_utf8(output.stdout).unwrap();
1440 assert_eq!(
1441 stdout.lines().filter(|line| *line == task).count(),
1442 1,
1443 "{stdout}"
1444 );
1445 }
1446 }
1447
1448 #[tokio::test]
1455 async fn a_timed_out_version_probe_reports_a_timeout_naming_the_budget() {
1456 let recorder = Recorder::with([
1457 Reply::ok(serde_json::json!({"exec_id":"version","phase":"running"})),
1458 Reply::ok(serde_json::json!({"exec_id":"version","phase":"exited"})),
1459 Reply::ok(serde_json::json!({
1460 "exec_id":"version","phase":"exited","exit_code":null,"signal":9,
1461 "stdout":"","stderr":"","timed_out":true,
1462 "truncated":false,"writers_may_be_alive":false
1463 })),
1464 ]);
1465 let (session, _, _) = session_with(Arc::clone(&recorder));
1466 let error = installed_version(&session, Agent::Codex)
1467 .await
1468 .expect_err("a killed probe has no version");
1469 assert_eq!(error.kind(), ErrorKind::Timeout, "{error}");
1470 assert!(error.to_string().contains("60s"), "{error}");
1471 }
1472
1473 #[tokio::test]
1474 async fn installed_version_probe_is_demoted_and_never_sources_agent_credentials() {
1475 let recorder = Recorder::with([
1476 Reply::ok(serde_json::json!({"exec_id":"version","phase":"running"})),
1477 Reply::ok(serde_json::json!({"exec_id":"version","phase":"exited"})),
1478 Reply::ok(serde_json::json!({
1479 "exec_id":"version","phase":"exited","exit_code":0,"signal":null,
1480 "stdout":"codex-cli 0.154.0\nSECRET=credential","stderr":"private diagnostic",
1481 "truncated":false,"writers_may_be_alive":false
1482 })),
1483 ]);
1484 let (session, _, _) = session_with(Arc::clone(&recorder));
1485 assert_eq!(
1486 installed_version(&session, Agent::Codex).await.unwrap(),
1487 "0.154.0"
1488 );
1489 let requests = recorder.requests();
1490 let start: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap();
1491 assert_eq!(start["command"], serde_json::json!(["codex", "--version"]));
1492 assert_eq!(start["shell"], false);
1493 assert_eq!(start["user"], 1000);
1494 assert_eq!(start["group"], 1000);
1495 assert_eq!(
1496 start["env"],
1497 serde_json::json!({"PATH":"/usr/local/bin:/usr/bin:/bin"})
1498 );
1499 assert_eq!(start["timeout_sec"], VERSION_PROBE_TIMEOUT.as_secs_f64());
1500 assert!(
1501 VERSION_PROBE_TIMEOUT >= Duration::from_secs(30),
1502 "must cover a cold first run"
1503 );
1504 assert!(VERSION_PROBE_WAIT > VERSION_PROBE_TIMEOUT);
1505 assert_eq!(start["reap_group_on_exit"], true);
1506 }
1507}