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