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, ExecResult, Session};
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(
180 sandbox: &Sandbox,
181 specs: &[AgentSpec],
182 request: &CreateImageRequest,
183) -> String {
184 let identity = crate::control::ensure::create_identity_hash(
185 &sandbox.artifact_content_hash_for(request),
186 request,
187 );
188 let stem = image_stem(specs);
189 crate::control::ensure::ensured_image_name(&stem, &identity)
192 .unwrap_or_else(|_| format!("{stem}-{}", &identity[..12]))
193}
194
195pub fn ensure_request_for(
199 sandbox: &Sandbox,
200 specs: &[AgentSpec],
201 binary: Vec<u8>,
202 build_role_arn: impl Into<String>,
203 size: SizeClass,
204 s3_bucket: impl Into<String>,
205 s3_key_prefix: Option<String>,
206) -> Result<crate::control::EnsureImageRequest, Error> {
207 require_specs(specs)?;
208 let base = BaseImage::al2023();
209 let dockerfile = dockerfile(specs, &base, sandbox.port())?;
210 let mut request = crate::control::EnsureImageRequest::new(
211 image_stem(specs),
212 binary,
213 dockerfile,
214 s3_bucket,
215 build_role_arn,
216 );
217 request.base_image = Some(base);
218 request.size = size;
219 request.s3_key_prefix = s3_key_prefix;
220 Ok(request)
221}
222
223pub fn image_request_for(
225 sandbox: &Sandbox,
226 specs: &[AgentSpec],
227 binary: Vec<u8>,
228 build_role_arn: impl Into<String>,
229 size: SizeClass,
230) -> Result<CreateImageRequest, Error> {
231 require_specs(specs)?;
232 let base = BaseImage::al2023();
233 let dockerfile = dockerfile(specs, &base, sandbox.port())?;
234 let mut request =
235 CreateImageRequest::new(image_stem(specs), binary, String::new(), build_role_arn);
236 request.base_image = base;
237 request.dockerfile = Some(dockerfile);
238 request.size = size;
239 let name = image_name_for(sandbox, specs, &request);
240 request.name = name.clone();
241 request.token_scope = Some(name);
242 Ok(request)
243}
244
245pub fn launch_request_for(
247 specs: &[AgentSpec],
248 image_identifier: impl Into<String>,
249 execution_role_arn: Option<String>,
250) -> RunRequest {
251 let identifier = image_identifier.into();
252 let mut request = RunRequest::new().with_image(&identifier).with_egress();
253 request.execution_role_arn = execution_role_arn;
254 request.token_scope = Some(image_stem(specs));
255 request
256}
257
258pub fn default_specs() -> Vec<AgentSpec> {
264 vec![AgentSpec::new(Agent::ClaudeCode)]
265}
266
267pub fn launched_session(sandbox: &Sandbox) -> Result<&Session, Error> {
271 sandbox.session().ok_or_else(|| {
272 Error::new(
273 ErrorKind::Precondition,
274 "this agent VM has not been launched; call `launch` first.",
275 )
276 })
277}
278
279pub fn agent_vm_egress_posture() -> crate::control::EgressPosture {
287 launch_request_for(&[], "arn:image", None).egress_posture()
288}
289
290pub fn image_stem(specs: &[AgentSpec]) -> String {
293 let agents: Vec<&str> = ordered(specs)
294 .into_iter()
295 .map(|spec| spec.agent.as_str())
296 .collect();
297 format!("agent-vm-{}", agents.join("-"))
298}
299
300pub fn dockerfile(specs: &[AgentSpec], base: &BaseImage, port: u16) -> Result<String, Error> {
308 require_specs(specs)?;
309 let stanza = default_dockerfile(port, Some(WORKDIR), base, None);
310 let mut lines: Vec<String> = stanza.lines().map(str::to_string).collect();
311
312 let chmod = lines
313 .iter()
314 .position(|line| line == "RUN chmod 0755 /agentd")
315 .ok_or_else(|| stanza_drift("RUN chmod 0755 /agentd"))?;
316 let mut layers = vec![
317 String::new(),
318 "# The coding agents. Node 22 runs both CLIs; the rest is what a coding agent".to_string(),
319 "# expects of a working shell. Unpinned unless a cli_version was given, so the".to_string(),
320 "# image carries the registry's latest at build time and a pin changes the reuse hash."
321 .to_string(),
322 format!(
323 "RUN dnf install -y --setopt=install_weak_deps=0 {} && dnf clean all",
324 profile::SYSTEM_PACKAGES
325 ),
326 ];
327 let packages: Vec<String> = ordered(specs)
328 .into_iter()
329 .map(AgentSpec::npm_spec)
330 .collect();
331 layers.push(format!(
332 "RUN npm install -g {} && npm cache clean --force",
333 packages.join(" ")
334 ));
335 layers.extend([
336 String::new(),
337 "# The non-root user the agents run as. Appended directly because `useradd` is not"
338 .to_string(),
339 "# in the minimal base. Not decoration: Claude Code refuses --dangerously-skip-permissions"
340 .to_string(),
341 "# as root and then denies its own tool calls while still reporting (measured: zero"
342 .to_string(),
343 "# shell calls as uid 0, 147 as uid 1000 on the same task).".to_string(),
344 format!(
345 "RUN echo \"agent:x:{AGENT_UID}:{AGENT_GID}::{WORKDIR}:/bin/bash\" >> /etc/passwd \\"
346 ),
347 format!(" && echo \"agent:x:{AGENT_GID}:\" >> /etc/group"),
348 ]);
349 lines.splice(chmod + 1..chmod + 1, layers);
350
351 let mkdir = lines
352 .iter()
353 .position(|line| line == &format!("RUN mkdir -p {WORKDIR}"))
354 .ok_or_else(|| stanza_drift("RUN mkdir -p /workspace"))?;
355 lines[mkdir] = format!("RUN mkdir -p {WORKDIR} && chown {AGENT_UID}:{AGENT_GID} {WORKDIR}");
358
359 let mut text = lines.join("\n");
360 text.push('\n');
361 Ok(text)
362}
363
364fn stanza_drift(line: &str) -> Error {
365 Error::new(
366 ErrorKind::Unexpected,
367 format!(
368 "default_dockerfile no longer emits `{line}`, so the agent layers have no splice \
369 point. Update agents::dockerfile beside the stanza change."
370 ),
371 )
372}
373
374#[derive(Clone, Debug)]
379pub struct BedrockAccess {
380 pub region: Region,
381 pub token: BearerToken,
382}
383
384#[derive(Clone, Debug, Eq, PartialEq)]
386pub struct GuestFile {
387 pub path: String,
388 pub contents: Vec<u8>,
389 pub mode: &'static str,
391}
392
393pub fn sh_double_quote(value: &str) -> String {
398 let mut quoted = String::with_capacity(value.len() + 2);
399 quoted.push('"');
400 for ch in value.chars() {
401 if matches!(ch, '"' | '\\' | '$' | '`') {
402 quoted.push('\\');
403 }
404 quoted.push(ch);
405 }
406 quoted.push('"');
407 quoted
408}
409
410pub fn sh_single_quote(value: &str) -> String {
414 let mut quoted = String::with_capacity(value.len() + 2);
415 quoted.push('\'');
416 for ch in value.chars() {
417 if ch == '\'' {
418 quoted.push_str("'\\''");
419 } else {
420 quoted.push(ch);
421 }
422 }
423 quoted.push('\'');
424 quoted
425}
426
427pub fn provisioning_files(
430 specs: &[AgentSpec],
431 access: &BedrockAccess,
432) -> Result<Vec<GuestFile>, Error> {
433 require_specs(specs)?;
434 let specs = ordered(specs);
435
436 let mut env = vec![
440 ("HOME", WORKDIR.to_string()),
441 ("PATH", "/usr/local/bin:/usr/bin:/bin".to_string()),
442 ("AWS_REGION", access.region.as_str().to_string()),
443 ];
444 for spec in &specs {
445 env.extend(profile::env_pairs(
446 spec.agent,
447 spec.model(),
448 access.token.expose(),
449 ));
450 }
451 let mut env_file = String::new();
452 for (key, value) in env {
453 env_file.push_str(&format!("export {key}={}\n", sh_double_quote(&value)));
454 }
455
456 let mut files = vec![GuestFile {
457 path: ENV_FILE.to_string(),
458 contents: env_file.into_bytes(),
459 mode: "0600",
460 }];
461 if let Some(codex) = specs.iter().find(|spec| spec.agent == Agent::Codex) {
462 files.push(GuestFile {
463 path: CODEX_CONFIG_FILE.to_string(),
464 contents: profile::codex_config(codex.model(), &access.region).into_bytes(),
465 mode: "0600",
466 });
467 }
468 let marker = serde_json::json!({
469 "version": 1,
470 "region": access.region.as_str(),
471 "agents": specs.iter().map(|spec| serde_json::json!({
472 "agent": spec.agent.as_str(),
473 "model": spec.model(),
474 "cliVersion": spec.cli_version,
475 })).collect::<Vec<_>>(),
476 });
477 files.push(GuestFile {
478 path: MARKER_FILE.to_string(),
479 contents: serde_json::to_vec_pretty(&marker).map_err(|error| {
480 Error::new(
481 ErrorKind::Unexpected,
482 format!("the marker will not serialize: {error}"),
483 )
484 })?,
485 mode: "0644",
488 });
489 Ok(files)
490}
491
492pub async fn install_access(
499 session: &Session,
500 specs: &[AgentSpec],
501 access: &BedrockAccess,
502) -> Result<(), Error> {
503 for file in provisioning_files(specs, access)? {
504 session
505 .upload_file(&file.path, &file.contents, Some(file.mode))
506 .await?;
507 }
508 let request = crate::protocol::exec::StartRequest::new(
509 session.mint_exec_id(),
510 vec![format!("chown -R {AGENT_UID}:{AGENT_GID} {WORKDIR}")],
511 )
512 .with_shell(true)
513 .with_timeout_sec(Some(CHOWN_TIMEOUT.as_secs_f64()));
514 let result = session.run_sync(request, CHOWN_TIMEOUT).await?;
515 if !result.succeeded() {
516 return Err(Error::new(
517 ErrorKind::ExecFailed,
518 format!(
519 "handing {WORKDIR} to uid {AGENT_UID} failed (exit {:?}): {}",
520 result.exit_code(),
521 result.stderr().trim()
522 ),
523 ));
524 }
525 Ok(())
526}
527
528pub async fn installed_agents(session: &Session) -> Result<Vec<AgentSpec>, Error> {
534 let bytes = match session.download_file(MARKER_FILE).await {
535 Ok(bytes) => bytes,
536 Err(error) if error.wire_kind() == Some(crate::error::WireKind::NotFound) => {
537 return Err(Error::new(
538 ErrorKind::Precondition,
539 format!(
540 "{MARKER_FILE} is not in this VM, so it was not provisioned as an agent \
541 VM. `microvm agent-up --vm-name <NAME>` installs an agent and writes \
542 the marker; `--agent` names one explicitly when the marker is absent."
543 ),
544 ));
545 }
546 Err(error) => return Err(error),
547 };
548 let marker: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| {
549 Error::new(
550 ErrorKind::Protocol,
551 format!("{MARKER_FILE} is not the JSON agent-up wrote: {error}"),
552 )
553 })?;
554 let rows = marker["agents"].as_array().ok_or_else(|| {
555 Error::new(
556 ErrorKind::Protocol,
557 format!("{MARKER_FILE} carries no `agents` array"),
558 )
559 })?;
560 let mut specs = Vec::with_capacity(rows.len());
561 for row in rows {
562 let name = row["agent"].as_str().unwrap_or_default();
563 let agent = Agent::parse(name).ok_or_else(|| {
564 Error::new(
565 ErrorKind::Protocol,
566 format!("{MARKER_FILE} names an agent this client has no profile for: {name:?}"),
567 )
568 })?;
569 let mut spec = AgentSpec::new(agent);
570 if let Some(model) = row["model"].as_str() {
571 spec.model = Some(model.to_string());
572 }
573 if let Some(version) = row["cliVersion"].as_str() {
574 spec.cli_version = Some(version.to_string());
575 }
576 specs.push(spec);
577 }
578 require_specs(&specs)?;
579 Ok(specs)
580}
581
582pub const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(60);
590
591pub const VERSION_PROBE_WAIT: Duration = Duration::from_secs(90);
593
594pub async fn installed_version(session: &Session, agent: Agent) -> Result<String, Error> {
597 let request = crate::protocol::exec::StartRequest::new(
598 session.mint_exec_id(),
599 profile::version_command(agent),
600 )
601 .with_cwd(Some(WORKDIR.to_string()))
602 .with_env(std::collections::HashMap::from([(
603 "PATH".into(),
604 "/usr/local/bin:/usr/bin:/bin".into(),
605 )]))
606 .with_user(Some(AGENT_UID.into()))
607 .with_group(Some(AGENT_GID.into()))
608 .with_timeout_sec(Some(VERSION_PROBE_TIMEOUT.as_secs_f64()))
609 .with_reap_group_on_exit(true);
610 let result = session.run_sync(request, VERSION_PROBE_WAIT).await?;
611 if result.succeeded()
612 && let Some(version) = reported_version(result.stdout())
613 {
614 return Ok(version.to_string());
615 }
616 if result
617 .outcome
618 .as_ref()
619 .is_some_and(|outcome| outcome.timed_out)
620 {
621 return Err(Error::new(
622 ErrorKind::Timeout,
623 format!(
624 "{agent} --version did not answer within {}s; the first run on a fresh VM \
625 reads the executable from a demand-paged disk, so retry once before \
626 suspecting the image",
627 VERSION_PROBE_TIMEOUT.as_secs(),
628 ),
629 ));
630 }
631 Err(Error::new(
632 ErrorKind::Precondition,
633 format!(
634 "could not read {agent}'s installed version; verify the pinned agent executable in the image",
635 ),
636 ))
637}
638
639fn reported_version(output: &str) -> Option<&str> {
640 output.split_ascii_whitespace().find(|word| {
641 word.len() <= 32
642 && word.split('.').count() == 3
643 && word
644 .split('.')
645 .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
646 })
647}
648
649#[derive(Clone, Debug, Default)]
651pub struct PromptOptions {
652 pub exec_id: Option<String>,
654 pub timeout: Option<Duration>,
657 pub permission_mode: AgentPermissionMode,
659 pub reap_group_on_exit: bool,
661}
662
663pub fn prompt_request_with(
668 spec: &AgentSpec,
669 task: &str,
670 options: &PromptOptions,
671 mint: impl FnOnce() -> String,
672) -> Result<crate::protocol::exec::StartRequest, Error> {
673 if task.trim().is_empty() {
674 return Err(Error::invalid_arg(
675 "the task is empty. An agent prompted with nothing answers with nothing, at the \
676 price of a model call.",
677 ));
678 }
679 if options.timeout.is_some_and(|timeout| timeout.is_zero()) {
680 return Err(Error::invalid_arg("execution timeout must be positive"));
681 }
682 let command = format!(
683 ". {ENV_FILE} && {}",
684 profile::headless_command(spec.agent, &sh_single_quote(task), options.permission_mode)
685 );
686 Ok(crate::protocol::exec::StartRequest::new(
689 options.exec_id.clone().unwrap_or_else(mint),
690 vec![command],
691 )
692 .with_shell(true)
693 .with_cwd(Some(WORKDIR.to_string()))
694 .with_user(Some(AGENT_UID.into()))
695 .with_group(Some(AGENT_GID.into()))
696 .with_timeout_sec(options.timeout.map(|timeout| timeout.as_secs_f64()))
697 .with_reap_group_on_exit(options.reap_group_on_exit))
698}
699
700pub fn headless_command_template(agent: Agent) -> String {
703 format!(
704 ". {ENV_FILE} && {}",
705 profile::headless_command(agent, "'<TASK>'", AgentPermissionMode::AgentDefault)
706 )
707}
708
709pub async fn prompt(
712 session: &Session,
713 spec: &AgentSpec,
714 task: &str,
715 options: &PromptOptions,
716) -> Result<ExecHandle, Error> {
717 session
718 .run(prompt_request_with(spec, task, options, || {
719 session.mint_exec_id()
720 })?)
721 .await
722}
723
724pub async fn prompt_sync(
731 session: &Session,
732 spec: &AgentSpec,
733 task: &str,
734 options: &PromptOptions,
735) -> Result<ExecResult, Error> {
736 let timeout = options.timeout.unwrap_or(DEFAULT_PROMPT_TIMEOUT);
737 let bounded = PromptOptions {
738 timeout: Some(timeout),
739 ..options.clone()
740 };
741 let request = prompt_request_with(spec, task, &bounded, || session.mint_exec_id())?;
742 session.run_sync(request, timeout).await
743}
744
745pub struct AgentVm {
750 sandbox: Sandbox,
751 specs: Vec<AgentSpec>,
752}
753
754impl std::fmt::Debug for AgentVm {
755 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
756 f.debug_struct("AgentVm")
757 .field("agents", &self.specs)
758 .field("sandbox", &self.sandbox)
759 .finish()
760 }
761}
762
763impl AgentVm {
764 pub fn new(sandbox: Sandbox, specs: Vec<AgentSpec>) -> Result<Self, Error> {
766 require_specs(&specs)?;
767 Ok(Self { sandbox, specs })
768 }
769
770 pub async fn adopt(
779 control: crate::control::ControlPlane,
780 specs: Vec<AgentSpec>,
781 microvm_id: impl Into<String>,
782 endpoint: impl Into<String>,
783 agent_token: impl Into<String>,
784 ) -> Result<Self, Error> {
785 require_specs(&specs)?;
786 let sandbox = Sandbox::adopt(control, microvm_id, endpoint, agent_token).await?;
787 Ok(Self { sandbox, specs })
788 }
789
790 pub fn detach(&mut self) -> Result<crate::sandbox::Detached, Error> {
793 self.sandbox.detach()
794 }
795
796 pub fn into_parts(self) -> (Sandbox, Vec<AgentSpec>) {
798 (self.sandbox, self.specs)
799 }
800
801 pub fn claude_code(sandbox: Sandbox) -> Self {
803 Self {
804 sandbox,
805 specs: vec![AgentSpec::new(Agent::ClaudeCode)],
806 }
807 }
808
809 pub fn codex(sandbox: Sandbox) -> Self {
811 Self {
812 sandbox,
813 specs: vec![AgentSpec::new(Agent::Codex)],
814 }
815 }
816
817 pub fn specs(&self) -> Vec<AgentSpec> {
819 ordered(&self.specs).into_iter().cloned().collect()
820 }
821
822 pub fn sandbox(&self) -> &Sandbox {
823 &self.sandbox
824 }
825
826 pub fn sandbox_mut(&mut self) -> &mut Sandbox {
827 &mut self.sandbox
828 }
829
830 pub fn session(&self) -> Option<&Session> {
832 self.sandbox.session()
833 }
834
835 pub fn image_name(&self, request: &CreateImageRequest) -> String {
840 image_name_for(&self.sandbox, &self.specs, request)
841 }
842
843 pub fn image_request(
850 &self,
851 binary: Vec<u8>,
852 build_role_arn: impl Into<String>,
853 size: SizeClass,
854 ) -> Result<CreateImageRequest, Error> {
855 image_request_for(&self.sandbox, &self.specs, binary, build_role_arn, size)
856 }
857
858 pub async fn build(&mut self, request: CreateImageRequest) -> Result<&Image, Error> {
860 self.sandbox.build_image(request).await
861 }
862
863 pub async fn ensure_image(
868 &mut self,
869 binary: Vec<u8>,
870 build_role_arn: impl Into<String>,
871 size: SizeClass,
872 s3_bucket: impl Into<String>,
873 s3_key_prefix: Option<String>,
874 ) -> Result<crate::control::EnsuredImage, Error> {
875 let request = ensure_request_for(
876 &self.sandbox,
877 &self.specs,
878 binary,
879 build_role_arn,
880 size,
881 s3_bucket,
882 s3_key_prefix,
883 )?;
884 self.sandbox.ensure_image(request).await
885 }
886
887 pub fn launch_request(
890 &self,
891 image_identifier: impl Into<String>,
892 execution_role_arn: Option<String>,
893 ) -> RunRequest {
894 launch_request_for(&self.specs, image_identifier, execution_role_arn)
895 }
896
897 pub async fn launch(&mut self, request: RunRequest) -> Result<&Session, Error> {
899 self.sandbox.run(request).await.map(|session| &*session)
900 }
901
902 fn require_session(&self) -> Result<&Session, Error> {
903 launched_session(&self.sandbox)
904 }
905
906 pub async fn install_access(&self, access: &BedrockAccess) -> Result<(), Error> {
908 install_access(self.require_session()?, &self.specs, access).await
909 }
910
911 pub async fn prompt(
913 &self,
914 agent: Agent,
915 task: &str,
916 options: &PromptOptions,
917 ) -> Result<ExecHandle, Error> {
918 let spec = spec_for(&self.specs, agent)?;
919 prompt(self.require_session()?, spec, task, options).await
920 }
921
922 pub async fn prompt_sync(
924 &self,
925 agent: Agent,
926 task: &str,
927 options: &PromptOptions,
928 ) -> Result<ExecResult, Error> {
929 let spec = spec_for(&self.specs, agent)?;
930 prompt_sync(self.require_session()?, spec, task, options).await
931 }
932
933 pub async fn terminate(&mut self, opts: TeardownOpts) -> TeardownReport {
935 self.sandbox.terminate(opts).await
936 }
937}
938
939#[cfg(test)]
940mod tests {
941 use std::sync::Arc;
942
943 use super::*;
944 use crate::control::artifact::{
945 require_daemon_cmd, require_matching_agentd_port, require_matching_from, require_workdir,
946 };
947 use crate::session::testing::{Recorder, Reply, session_with};
948
949 fn prompt_request(
952 spec: &AgentSpec,
953 task: &str,
954 options: &PromptOptions,
955 ) -> Result<crate::protocol::exec::StartRequest, Error> {
956 prompt_request_with(spec, task, options, || {
957 crate::session::exec_id_at(Duration::from_secs(1_767_225_600))
958 })
959 }
960
961 fn both() -> Vec<AgentSpec> {
962 vec![
963 AgentSpec::new(Agent::Codex),
964 AgentSpec::new(Agent::ClaudeCode),
965 ]
966 }
967
968 fn access() -> BedrockAccess {
969 BedrockAccess {
970 region: Region::UsEast1,
971 token: bedrock::BearerToken::new("bedrock-api-key-test".to_string()),
973 }
974 }
975
976 #[test]
985 fn the_dockerfile_passes_the_guards_and_carries_the_recipe() {
986 let base = BaseImage::al2023();
987 let text = dockerfile(&both(), &base, 9000).expect("derives");
988
989 require_matching_from(&base, &text).expect("FROM pairs with the base");
990 require_workdir(&base, Some(&text)).expect("a WORKDIR is declared");
991 require_daemon_cmd(&text).expect("ENTRYPOINT [] + CMD agentd survive");
992 require_matching_agentd_port(9000, &text).expect("the port agrees");
993
994 assert_eq!(
995 text.matches("RUN dnf install").count(),
996 1,
997 "the system layer is emitted once for two profiles:\n{text}"
998 );
999 assert!(
1000 text.contains("RUN dnf install -y --setopt=install_weak_deps=0 nodejs22 nodejs22-npm "),
1001 "with weak deps off, bare `npm` is Node 18's and the npm layer exits 127:\n{text}"
1002 );
1003 assert!(
1004 text.contains("RUN npm install -g @anthropic-ai/claude-code @openai/codex && npm"),
1005 "both CLIs in profile order, in one layer:\n{text}"
1006 );
1007 assert!(
1008 text.contains("agent:x:1000:1000::/workspace:/bin/bash"),
1009 "{text}"
1010 );
1011 assert!(
1012 text.contains("RUN mkdir -p /workspace && chown 1000:1000 /workspace"),
1013 "{text}"
1014 );
1015 let layers_at = text.find("RUN npm install").expect("layers");
1016 let workdir_at = text.find("WORKDIR /workspace").expect("workdir");
1017 let cmd_at = text.find("CMD [\"/agentd\"]").expect("cmd");
1018 assert!(layers_at < workdir_at && workdir_at < cmd_at, "{text}");
1019 }
1020
1021 #[test]
1024 fn a_cli_version_pin_reaches_the_install_line() {
1025 let base = BaseImage::al2023();
1026 let pinned = vec![AgentSpec::new(Agent::ClaudeCode).with_cli_version("2.1.0")];
1027 let text = dockerfile(&pinned, &base, 9000).expect("derives");
1028 assert!(text.contains("@anthropic-ai/claude-code@2.1.0"), "{text}");
1029 let plain = dockerfile(&[AgentSpec::new(Agent::ClaudeCode)], &base, 9000).expect("d");
1030 assert_ne!(text, plain);
1031 }
1032
1033 #[test]
1034 fn an_empty_or_repeated_spec_set_is_refused_before_any_text_is_built() {
1035 let base = BaseImage::al2023();
1036 assert_eq!(
1037 dockerfile(&[], &base, 9000).expect_err("empty").kind(),
1038 ErrorKind::InvalidArg
1039 );
1040 let twice = vec![AgentSpec::new(Agent::Codex), AgentSpec::new(Agent::Codex)];
1041 let error = dockerfile(&twice, &base, 9000).expect_err("repeated");
1042 assert!(
1043 error.to_string().contains("codex is named twice"),
1044 "{error}"
1045 );
1046 }
1047
1048 #[test]
1050 fn the_image_stem_is_stable_under_spec_order() {
1051 assert_eq!(image_stem(&both()), "agent-vm-claude-code-codex");
1052 assert_eq!(
1053 image_stem(&[AgentSpec::new(Agent::Codex)]),
1054 "agent-vm-codex"
1055 );
1056 }
1057
1058 #[test]
1064 fn a_codex_only_vm_still_gets_the_variable_codex_reads() {
1065 let files = provisioning_files(&[AgentSpec::new(Agent::Codex)], &access()).expect("files");
1066 let env = String::from_utf8(files[0].contents.clone()).expect("utf-8");
1067 assert!(
1068 env.contains("export AWS_BEARER_TOKEN_BEDROCK="),
1069 "no Claude Code profile is present to export it, and Codex 401s without it:\n{env}"
1070 );
1071 }
1072
1073 #[test]
1074 fn the_file_set_follows_the_profiles_and_the_env_file_is_quoted() {
1075 let access = access();
1076 let claude_only =
1077 provisioning_files(&[AgentSpec::new(Agent::ClaudeCode)], &access).expect("files");
1078 assert_eq!(
1079 claude_only
1080 .iter()
1081 .map(|f| f.path.as_str())
1082 .collect::<Vec<_>>(),
1083 vec![ENV_FILE, MARKER_FILE]
1084 );
1085
1086 let files = provisioning_files(&both(), &access).expect("files");
1087 assert_eq!(
1088 files.iter().map(|f| f.path.as_str()).collect::<Vec<_>>(),
1089 vec![ENV_FILE, CODEX_CONFIG_FILE, MARKER_FILE]
1090 );
1091 let env = String::from_utf8(files[0].contents.clone()).expect("utf-8");
1092 assert_eq!(files[0].mode, "0600");
1093 assert!(
1094 env.contains("export PATH=\"/usr/local/bin:/usr/bin:/bin\"\n"),
1095 "{env}"
1096 );
1097 assert!(env.contains("export HOME=\"/workspace\"\n"), "{env}");
1098 assert!(env.contains("export AWS_REGION=\"us-east-1\"\n"), "{env}");
1099 assert!(
1100 env.contains("export CLAUDE_CODE_USE_BEDROCK=\"1\"\n"),
1101 "{env}"
1102 );
1103 assert!(
1104 env.contains("export ANTHROPIC_MODEL=\"global.anthropic.claude-opus-5\"\n"),
1105 "{env}"
1106 );
1107 let token = access.token.expose();
1108 assert!(env.contains(&format!("export AWS_BEARER_TOKEN_BEDROCK=\"{token}\"\n")));
1109 assert!(env.contains(&format!("export OPENAI_API_KEY=\"{token}\"\n")));
1110
1111 let config = String::from_utf8(files[1].contents.clone()).expect("utf-8");
1112 assert!(
1113 config.contains("bedrock-runtime.us-east-1.amazonaws.com/openai/v1"),
1114 "{config}"
1115 );
1116 assert!(
1117 config.contains("model = \"global.openai.gpt-5.6-sol\""),
1118 "{config}"
1119 );
1120
1121 let marker: serde_json::Value = serde_json::from_slice(&files[2].contents).expect("json");
1122 assert_eq!(files[2].mode, "0644");
1123 assert_eq!(marker["agents"][0]["agent"], "claude-code");
1124 assert_eq!(marker["agents"][1]["agent"], "codex");
1125 assert_eq!(marker["agents"][1]["model"], "global.openai.gpt-5.6-sol");
1126 assert!(
1127 !files[2]
1128 .contents
1129 .windows(token.len())
1130 .any(|w| w == token.as_bytes()),
1131 "the marker is world-readable and must never carry the token"
1132 );
1133 }
1134
1135 #[test]
1136 fn shell_quoting_escapes_what_each_quote_style_cannot_hold() {
1137 assert_eq!(sh_double_quote("a$b\"c`d\\e"), "\"a\\$b\\\"c\\`d\\\\e\"");
1138 assert_eq!(sh_single_quote("it's"), "'it'\\''s'");
1139 assert_eq!(sh_single_quote("plain"), "'plain'");
1140 }
1141
1142 #[tokio::test(start_paused = true)]
1148 async fn install_access_uploads_then_chowns_as_root() {
1149 let recorder = Recorder::with([
1150 Reply::Body(200, Vec::new()),
1151 Reply::Body(200, Vec::new()),
1152 Reply::Body(200, Vec::new()),
1153 Reply::ok(serde_json::json!({"exec_id":"c1","phase":"running"})),
1154 Reply::ok(serde_json::json!({"exec_id":"c1","phase":"exited"})),
1155 Reply::ok(serde_json::json!({
1156 "exec_id":"c1","phase":"exited","exit_code":0,"signal":null,
1157 "stdout":"","stderr":"","truncated":false,"writers_may_be_alive":false
1158 })),
1159 ]);
1160 let (session, _, _) = session_with(Arc::clone(&recorder));
1161
1162 install_access(&session, &both(), &access())
1163 .await
1164 .expect("installs");
1165
1166 let seen = recorder.requests();
1167 assert_eq!(seen.len(), 6, "{seen:#?}");
1168 for (request, (encoded, mode)) in seen.iter().zip([
1169 ("%2Fworkspace%2F.agent-env", "0600"),
1170 ("%2Fworkspace%2F.codex%2Fconfig.toml", "0600"),
1171 ("%2Fworkspace%2F.agent-vm.json", "0644"),
1172 ]) {
1173 assert_eq!(request.method, "PUT");
1174 assert_eq!(
1175 request.path,
1176 format!("/v1/fs/file?path={encoded}&mode={mode}"),
1177 "each file goes to the file route with its own mode"
1178 );
1179 }
1180 assert_eq!(seen[3].path, "/v1/exec/start");
1181 let start: serde_json::Value = serde_json::from_slice(&seen[3].body).expect("json");
1182 assert_eq!(start["command"][0], "chown -R 1000:1000 /workspace");
1183 assert_eq!(start["shell"], true);
1184 assert!(
1185 start["user"].is_null(),
1186 "the chown runs as the daemon's own user"
1187 );
1188 assert_eq!(seen[5].path, "/v1/exec/c1/ack");
1189 }
1190
1191 #[tokio::test(start_paused = true)]
1194 async fn a_failed_chown_fails_the_install() {
1195 let recorder = Recorder::with([
1196 Reply::Body(200, Vec::new()),
1197 Reply::Body(200, Vec::new()),
1198 Reply::ok(serde_json::json!({"exec_id":"c1","phase":"running"})),
1199 Reply::ok(serde_json::json!({"exec_id":"c1","phase":"exited"})),
1200 Reply::ok(serde_json::json!({
1201 "exec_id":"c1","phase":"exited","exit_code":1,"signal":null,
1202 "stdout":"","stderr":"chown: cannot access","truncated":false,
1203 "writers_may_be_alive":false
1204 })),
1205 ]);
1206 let (session, _, _) = session_with(recorder);
1207 let error = install_access(&session, &[AgentSpec::new(Agent::ClaudeCode)], &access())
1208 .await
1209 .expect_err("fails");
1210 assert_eq!(error.kind(), ErrorKind::ExecFailed);
1211 assert!(
1212 error.to_string().contains("chown: cannot access"),
1213 "{error}"
1214 );
1215 }
1216
1217 #[tokio::test]
1220 async fn installed_agents_reads_the_marker_back() {
1221 let specs = vec![
1222 AgentSpec::new(Agent::ClaudeCode).with_model("global.anthropic.claude-sonnet-5"),
1223 AgentSpec::new(Agent::Codex).with_cli_version("0.50.0"),
1224 ];
1225 let files = provisioning_files(&specs, &access()).expect("files");
1226 let marker = files.last().expect("marker").contents.clone();
1227 let recorder = Recorder::with([Reply::Body(200, marker)]);
1228 let (session, _, _) = session_with(recorder);
1229
1230 let read = installed_agents(&session).await.expect("reads");
1231 assert_eq!(read.len(), 2);
1232 assert_eq!(read[0].agent, Agent::ClaudeCode);
1233 assert_eq!(read[0].model(), "global.anthropic.claude-sonnet-5");
1234 assert_eq!(read[1].agent, Agent::Codex);
1235 assert_eq!(read[1].cli_version.as_deref(), Some("0.50.0"));
1236 assert_eq!(read[1].model(), "global.openai.gpt-5.6-sol");
1237 }
1238
1239 #[tokio::test]
1242 async fn a_missing_marker_names_agent_up_and_other_failures_propagate() {
1243 let recorder = Recorder::with([
1244 Reply::Body(404, b"not found".to_vec()),
1245 Reply::Body(503, b"not bootstrapped".to_vec()),
1246 ]);
1247 let (session, _, _) = session_with(recorder);
1248 let error = installed_agents(&session).await.expect_err("absent");
1249 assert_eq!(error.kind(), ErrorKind::Precondition);
1250 assert!(error.to_string().contains("agent-up"), "{error}");
1251 let other = installed_agents(&session).await.expect_err("503");
1252 assert_ne!(other.kind(), ErrorKind::Precondition);
1253 }
1254
1255 #[test]
1261 fn the_prompt_request_runs_the_agent_demoted_in_the_workspace() {
1262 let spec = AgentSpec::new(Agent::ClaudeCode);
1263 let request = prompt_request(
1264 &spec,
1265 "count the files; it's quick",
1266 &PromptOptions {
1267 exec_id: Some("p1".into()),
1268 timeout: Some(Duration::from_secs(120)),
1269 ..PromptOptions::default()
1270 },
1271 )
1272 .expect("builds");
1273 assert_eq!(request.exec_id, "p1");
1274 assert_eq!(request.user, Some(1000.into()));
1275 assert_eq!(request.group, Some(1000.into()));
1276 assert_eq!(request.cwd.as_deref(), Some("/workspace"));
1277 assert!(request.shell.is_shell());
1278 assert!(
1279 request.env.is_empty(),
1280 "the token never rides in the request"
1281 );
1282 assert_eq!(request.timeout_sec, Some(120.0));
1283 assert!(!request.stdin);
1284 assert_eq!(
1285 request.command,
1286 vec![
1287 ". /workspace/.agent-env && claude -p 'count the files; it'\\''s quick' \
1288 --allowedTools Bash,Read,Edit,Write,Grep,Glob"
1289 .to_string()
1290 ]
1291 );
1292
1293 let codex = prompt_request(
1294 &AgentSpec::new(Agent::Codex),
1295 "hi",
1296 &PromptOptions::default(),
1297 )
1298 .expect("builds");
1299 assert!(
1300 codex.command[0].contains("codex exec --skip-git-repo-check -s workspace-write 'hi'")
1301 );
1302 assert!(codex.timeout_sec.is_none());
1303 assert!(!codex.exec_id.is_empty(), "an id was minted");
1304 }
1305
1306 #[test]
1307 fn an_empty_task_is_refused_locally() {
1308 let error = prompt_request(
1309 &AgentSpec::new(Agent::Codex),
1310 " \n",
1311 &PromptOptions::default(),
1312 )
1313 .expect_err("empty");
1314 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1315 }
1316
1317 fn sandbox() -> Sandbox {
1320 Sandbox::with_control_plane(crate::testing::control_plane(
1321 Arc::new(crate::control::fake::FakeControlPlane::new()),
1322 Region::UsEast1,
1323 Arc::new(crate::control::fake::TestClock::new()),
1324 ))
1325 }
1326
1327 #[test]
1329 fn the_default_agents_are_claude_code_alone() {
1330 let specs = default_specs();
1331 assert_eq!(
1332 specs.iter().map(|spec| spec.agent).collect::<Vec<_>>(),
1333 [Agent::ClaudeCode]
1334 );
1335 AgentVm::new(sandbox(), specs).expect("the default set is a valid one");
1336 }
1337
1338 #[tokio::test]
1341 async fn a_call_before_launch_is_refused_with_the_remedy() {
1342 let fresh = sandbox();
1343 let error = launched_session(&fresh).expect_err("nothing launched");
1344 assert_eq!(error.kind(), ErrorKind::Precondition);
1345 assert_eq!(
1346 error.to_string(),
1347 "this agent VM has not been launched; call `launch` first."
1348 );
1349 let vm = AgentVm::new(sandbox(), default_specs()).expect("a valid set");
1350 let through_the_vm = vm
1351 .install_access(&access())
1352 .await
1353 .expect_err("nothing launched");
1354 assert_eq!(through_the_vm.to_string(), error.to_string());
1355 }
1356
1357 #[tokio::test]
1360 async fn an_agent_vm_adopts_with_checked_specs_and_the_service_lifecycle() {
1361 use crate::control::fake::{Answer, FakeControlPlane, TestClock, microvm_response};
1362 let plane = |recorder: &Arc<FakeControlPlane>| {
1363 crate::testing::control_plane(
1364 Arc::clone(recorder) as Arc<dyn crate::control::transport::Transport>,
1365 Region::UsEast1,
1366 Arc::new(TestClock::new()),
1367 )
1368 };
1369 let endpoint = "https://mvm-abc123.microvm.us-east-1.amazonaws.com";
1370
1371 let recorder = Arc::new(FakeControlPlane::new());
1372 let error = AgentVm::adopt(plane(&recorder), vec![], "mvm-abc123", endpoint, "t")
1373 .await
1374 .expect_err("an empty spec set");
1375 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1376 assert_eq!(recorder.calls().len(), 0);
1377
1378 let recorder = Arc::new(FakeControlPlane::new());
1379 recorder.answer(
1380 "GetMicrovm",
1381 Answer::ok(microvm_response("SUSPENDED", None)),
1382 );
1383 let vm = AgentVm::adopt(plane(&recorder), both(), "mvm-abc123", endpoint, "t")
1384 .await
1385 .expect("adopts");
1386 assert!(vm.sandbox().adopted());
1387 assert_eq!(
1388 vm.sandbox().lifecycle(),
1389 crate::sandbox::Lifecycle::Suspended
1390 );
1391 assert_eq!(vm.specs().len(), 2);
1392 }
1393
1394 #[test]
1401 fn the_two_step_name_is_the_name_ensure_gives_and_it_covers_the_size() {
1402 let vm = AgentVm::new(sandbox(), both()).expect("specs");
1403 let role = "arn:aws:iam::123456789012:role/b";
1404 let daemon = crate::testing::aarch64_daemon(b"daemon");
1405 let two_step = vm
1406 .image_request(daemon.clone(), role, DEFAULT_SIZE)
1407 .expect("request");
1408 let ensure = ensure_request_for(
1409 vm.sandbox(),
1410 &vm.specs(),
1411 daemon.clone(),
1412 role,
1413 DEFAULT_SIZE,
1414 "bucket",
1415 Some("agents".to_string()),
1416 )
1417 .expect("request");
1418 let prepared = crate::control::ensure::prepare(
1419 &crate::testing::control_plane(
1420 Arc::new(crate::control::fake::FakeControlPlane::new()),
1421 Region::UsEast1,
1422 Arc::new(crate::control::fake::TestClock::new()),
1423 ),
1424 ensure,
1425 )
1426 .expect("prepares");
1427 assert_eq!(prepared.name, two_step.name, "one name for one agent image");
1428 assert_eq!(
1429 prepared.key,
1430 format!("agents/{}/artifact.zip", two_step.name),
1431 "the ensure's key"
1432 );
1433
1434 let larger = SizeClass::from_baseline_mib(4096).expect("a class");
1435 let other_size = vm.image_request(daemon, role, larger).expect("request");
1436 assert_ne!(
1437 other_size.name, two_step.name,
1438 "another size is another image"
1439 );
1440 }
1441
1442 #[test]
1445 fn the_image_request_is_content_named_and_egress_is_on_at_launch() {
1446 let vm = AgentVm::new(sandbox(), both()).expect("specs");
1447 let a = vm
1448 .image_request(
1449 b"daemon".to_vec(),
1450 "arn:aws:iam::123456789012:role/b",
1451 DEFAULT_SIZE,
1452 )
1453 .expect("request");
1454 let b = vm
1455 .image_request(
1456 b"daemon".to_vec(),
1457 "arn:aws:iam::123456789012:role/b",
1458 DEFAULT_SIZE,
1459 )
1460 .expect("request");
1461 let c = vm
1462 .image_request(
1463 b"other".to_vec(),
1464 "arn:aws:iam::123456789012:role/b",
1465 DEFAULT_SIZE,
1466 )
1467 .expect("request");
1468 assert_eq!(a.name, b.name);
1469 assert_ne!(a.name, c.name);
1470 assert!(
1471 a.name.starts_with("agent-vm-claude-code-codex-"),
1472 "{}",
1473 a.name
1474 );
1475 assert_eq!(a.name.len(), "agent-vm-claude-code-codex-".len() + 12);
1476 assert_eq!(a.token_scope.as_deref(), Some(a.name.as_str()));
1477 assert!(a.code_artifact_uri.is_empty(), "the upload is the caller's");
1478 assert!(
1479 a.dockerfile
1480 .as_deref()
1481 .is_some_and(|d| d.contains("@openai/codex"))
1482 );
1483 assert_eq!(a.size, SizeClass::Mib1024);
1484
1485 let launch = vm.launch_request("arn:image", Some("arn:role".into()));
1486 assert!(
1487 launch.egress,
1488 "neither agent reaches Bedrock without egress"
1489 );
1490 assert_eq!(launch.image_identifier.as_deref(), Some("arn:image"));
1491 assert_eq!(
1492 launch.token_scope.as_deref(),
1493 Some("agent-vm-claude-code-codex")
1494 );
1495 }
1496
1497 #[tokio::test]
1500 async fn the_named_constructors_carry_one_agent_each() {
1501 let claude = AgentVm::claude_code(sandbox());
1502 assert_eq!(claude.specs(), vec![AgentSpec::new(Agent::ClaudeCode)]);
1503 let codex = AgentVm::codex(sandbox());
1504 assert_eq!(codex.specs(), vec![AgentSpec::new(Agent::Codex)]);
1505
1506 let error = codex
1507 .prompt(Agent::ClaudeCode, "hi", &PromptOptions::default())
1508 .await
1509 .expect_err("wrong agent");
1510 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1511 assert!(error.to_string().contains("carries codex"), "{error}");
1512 let unlaunched = codex
1513 .prompt(Agent::Codex, "hi", &PromptOptions::default())
1514 .await
1515 .expect_err("no session");
1516 assert_eq!(unlaunched.kind(), ErrorKind::Precondition);
1517 }
1518
1519 #[test]
1520 fn unrestricted_permissions_deadline_and_reaping_are_explicit_and_demoted() {
1521 for (agent, expected, forbidden) in [
1522 (
1523 Agent::ClaudeCode,
1524 "--dangerously-skip-permissions",
1525 "--allowedTools",
1526 ),
1527 (
1528 Agent::Codex,
1529 "--dangerously-bypass-approvals-and-sandbox",
1530 "workspace-write",
1531 ),
1532 ] {
1533 let task = "a quote ' and $(touch /tmp/must-not-run); \"hello\"";
1534 let request = prompt_request(
1535 &AgentSpec::new(agent),
1536 task,
1537 &PromptOptions {
1538 exec_id: Some("same-task".into()),
1539 timeout: Some(Duration::from_secs(1200)),
1540 permission_mode: AgentPermissionMode::Unrestricted,
1541 reap_group_on_exit: true,
1542 },
1543 )
1544 .expect("valid");
1545 assert!(request.command[0].contains(expected));
1546 assert!(!request.command[0].contains(forbidden));
1547 assert!(request.command[0].contains(&sh_single_quote(task)));
1548 assert_eq!(
1549 (request.user, request.group),
1550 (Some(1000.into()), Some(1000.into()))
1551 );
1552 assert_eq!(request.timeout_sec, Some(1200.0));
1553 assert!(request.reap_group_on_exit);
1554 assert_eq!(request.exec_id, "same-task");
1555 let default =
1556 prompt_request(&AgentSpec::new(agent), task, &PromptOptions::default()).unwrap();
1557 assert!(default.command[0].contains(forbidden));
1558 assert!(!default.command[0].contains(expected));
1559 assert!(!default.reap_group_on_exit);
1560 assert_eq!(default.timeout_sec, None);
1561 }
1562 assert!("auto".parse::<AgentPermissionMode>().is_err());
1563 assert!(
1564 prompt_request(
1565 &AgentSpec::new(Agent::Codex),
1566 "task",
1567 &PromptOptions {
1568 timeout: Some(Duration::ZERO),
1569 ..PromptOptions::default()
1570 }
1571 )
1572 .is_err()
1573 );
1574 }
1575
1576 #[test]
1577 fn version_reporting_only_accepts_numeric_version_words() {
1578 assert_eq!(reported_version("2.1.7 (Claude Code)\n"), Some("2.1.7"));
1579 assert_eq!(reported_version("codex-cli 0.154.0\n"), Some("0.154.0"));
1580 assert_eq!(reported_version("SECRET=credential\n"), None);
1581 assert_eq!(reported_version("1.2.3-secret"), None);
1582 }
1583
1584 #[tokio::test]
1591 async fn a_timed_out_version_probe_reports_a_timeout_naming_the_budget() {
1592 let recorder = Recorder::with([
1593 Reply::ok(serde_json::json!({"exec_id":"version","phase":"running"})),
1594 Reply::ok(serde_json::json!({"exec_id":"version","phase":"exited"})),
1595 Reply::ok(serde_json::json!({
1596 "exec_id":"version","phase":"exited","exit_code":null,"signal":9,
1597 "stdout":"","stderr":"","timed_out":true,
1598 "truncated":false,"writers_may_be_alive":false
1599 })),
1600 ]);
1601 let (session, _, _) = session_with(Arc::clone(&recorder));
1602 let error = installed_version(&session, Agent::Codex)
1603 .await
1604 .expect_err("a killed probe has no version");
1605 assert_eq!(error.kind(), ErrorKind::Timeout, "{error}");
1606 assert!(error.to_string().contains("60s"), "{error}");
1607 }
1608
1609 #[tokio::test]
1617 async fn an_agent_exec_id_is_minted_from_the_sessions_clock() {
1618 let recorder = Recorder::with([
1619 Reply::ok(serde_json::json!({"exec_id":"version","phase":"running"})),
1620 Reply::ok(serde_json::json!({"exec_id":"version","phase":"exited"})),
1621 Reply::ok(serde_json::json!({
1622 "exec_id":"version","phase":"exited","exit_code":0,"signal":null,
1623 "stdout":"codex-cli 0.154.0","stderr":"",
1624 "truncated":false,"writers_may_be_alive":false
1625 })),
1626 ]);
1627 let (session, _, clock) = session_with(Arc::clone(&recorder));
1628 let pinned = Duration::from_secs(1_767_225_600) + Duration::from_nanos(123_456_789);
1629 clock.set_unix_now(pinned);
1630 installed_version(&session, Agent::Codex).await.unwrap();
1631
1632 let start: serde_json::Value =
1633 serde_json::from_slice(&recorder.requests()[0].body).unwrap();
1634 let id = start["exec_id"].as_str().expect("an exec id");
1635 let value =
1636 u64::from_str_radix(id.strip_prefix("x-").expect("the x- shape"), 16).expect("hex");
1637 let low_bits = (1_u64 << 40) - 1;
1638 assert_eq!(
1639 value & low_bits,
1640 (pinned.as_nanos() as u64) & low_bits,
1641 "{id} wasn't minted from the session's clock"
1642 );
1643 }
1644
1645 #[tokio::test]
1646 async fn installed_version_probe_is_demoted_and_never_sources_agent_credentials() {
1647 let recorder = Recorder::with([
1648 Reply::ok(serde_json::json!({"exec_id":"version","phase":"running"})),
1649 Reply::ok(serde_json::json!({"exec_id":"version","phase":"exited"})),
1650 Reply::ok(serde_json::json!({
1651 "exec_id":"version","phase":"exited","exit_code":0,"signal":null,
1652 "stdout":"codex-cli 0.154.0\nSECRET=credential","stderr":"private diagnostic",
1653 "truncated":false,"writers_may_be_alive":false
1654 })),
1655 ]);
1656 let (session, _, _) = session_with(Arc::clone(&recorder));
1657 assert_eq!(
1658 installed_version(&session, Agent::Codex).await.unwrap(),
1659 "0.154.0"
1660 );
1661 let requests = recorder.requests();
1662 let start: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap();
1663 assert_eq!(start["command"], serde_json::json!(["codex", "--version"]));
1664 assert_eq!(start["shell"], false);
1665 assert_eq!(start["user"], 1000);
1666 assert_eq!(start["group"], 1000);
1667 assert_eq!(
1668 start["env"],
1669 serde_json::json!({"PATH":"/usr/local/bin:/usr/bin:/bin"})
1670 );
1671 assert_eq!(start["timeout_sec"], VERSION_PROBE_TIMEOUT.as_secs_f64());
1672 assert!(
1673 VERSION_PROBE_TIMEOUT >= Duration::from_secs(30),
1674 "must cover a cold first run"
1675 );
1676 assert!(VERSION_PROBE_WAIT > VERSION_PROBE_TIMEOUT);
1677 assert_eq!(start["reap_group_on_exit"], true);
1678 }
1679
1680 #[tokio::test(start_paused = true)]
1687 async fn a_synchronous_prompt_bounds_the_daemon_by_the_wait_it_makes() {
1688 for (timeout, sent) in [(None, 900.0), (Some(Duration::from_secs(60)), 60.0)] {
1689 let recorder = Recorder::with([
1690 Reply::ok(serde_json::json!({"exec_id":"p1","phase":"running"})),
1691 Reply::ok(serde_json::json!({"exec_id":"p1","phase":"exited"})),
1692 Reply::ok(serde_json::json!({
1693 "exec_id":"p1","phase":"exited","exit_code":0,"signal":null,
1694 "stdout":"done","stderr":"","truncated":false,
1695 "writers_may_be_alive":false
1696 })),
1697 ]);
1698 let (session, _, _) = session_with(Arc::clone(&recorder));
1699 let options = PromptOptions {
1700 timeout,
1701 ..PromptOptions::default()
1702 };
1703 let result = prompt_sync(
1704 &session,
1705 &AgentSpec::new(Agent::ClaudeCode),
1706 "review the diff",
1707 &options,
1708 )
1709 .await
1710 .expect("a result");
1711 assert_eq!(result.stdout(), "done");
1712 let start: serde_json::Value =
1713 serde_json::from_slice(&recorder.requests()[0].body).expect("a JSON start");
1714 assert_eq!(start["timeout_sec"], sent, "timeout_sec for {timeout:?}");
1715 }
1716 }
1717}