1use std::{
2 fs,
3 io::{self, Read},
4 path::{Path, PathBuf},
5 sync::Arc,
6 time::{Duration, Instant},
7};
8
9use agent_infra_sdk::{
10 CallOptions, InfraClient, StaticCredentials,
11 agent_contract::{
12 AgentId, AgentSkillRef, AgentStatus, CreateAgentRequest, ListAgentsRequest,
13 ResourceRelations, UpdateAgentRequest,
14 },
15 context_contract::ConversationRecord,
16 runtime_identity_contract::{
17 CreateRuntimeIdentityRequest, IssueRuntimeCredentialRequest, ReplacePermissionsRequest,
18 RuntimeIdentityId, RuntimeIdentityKind, TransitionIdentityRequest,
19 UpdateRuntimeIdentityRequest,
20 },
21 workspace_contract::{
22 CreateComputerRequest, CreateSnapshotResourceRequest, CreateSpaceRequest,
23 ListEnvironmentResourcesQuery, OwnerKind, SnapshotVisibility,
24 },
25};
26use anyhow::{Context, Result, anyhow, bail};
27use clap::{Args, Parser, Subcommand, ValueEnum};
28use serde::{Serialize, de::DeserializeOwned};
29use serde_json::Value;
30
31mod config;
32mod credential;
33mod eval_cli;
34mod evaluate_bundle;
35mod evaluate_cli;
36mod execution_cli;
37mod init;
38mod runtime_repo_checkout;
39mod trace;
40
41use config::{ConfigStore, resolve_credential};
42use eval_cli::execute_eval;
43pub use eval_cli::{EvalAdminCommand, EvalCommand};
44use evaluate_cli::execute_evaluate;
45pub use evaluate_cli::{EvaluateCommand, EvaluateMetricsCommand, EvaluateReviewCommand};
46pub use execution_cli::ExecutionCommand;
47use execution_cli::execute as execute_execution;
48use trace::TraceCommand;
49
50const DEFAULT_GATEWAY: &str = "http://127.0.0.1:5200";
51
52#[derive(Debug, Parser)]
53#[command(
54 name = "infra",
55 version,
56 about = "Operate Agent Infra through the typed Infra SDK",
57 arg_required_else_help = true
58)]
59pub struct Cli {
60 #[arg(long, global = true)]
62 pub gateway: Option<String>,
63
64 #[arg(long, global = true, hide_env_values = true)]
66 pub token: Option<String>,
67
68 #[arg(long, global = true, env = "AGENT_INFRA_CONFIG", value_name = "PATH")]
70 pub config: Option<PathBuf>,
71
72 #[arg(
74 long,
75 global = true,
76 env = "AGENT_INFRA_SKY_HOME",
77 value_name = "PATH",
78 hide = true
79 )]
80 pub sky_home: Option<PathBuf>,
81
82 #[arg(long, global = true, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..=600))]
84 pub timeout: u64,
85
86 #[arg(long, global = true)]
88 pub trusted_mesh_http: bool,
89
90 #[arg(long, global = true, value_enum, default_value_t = OutputFormat::Pretty)]
92 pub output: OutputFormat,
93
94 #[command(subcommand)]
95 pub command: Command,
96}
97
98#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
99pub enum OutputFormat {
100 #[default]
102 Pretty,
103 Json,
105}
106
107#[derive(Debug, Subcommand)]
108pub enum Command {
109 Config {
111 #[command(subcommand)]
112 command: ConfigCommand,
113 },
114 Auth {
116 #[command(subcommand)]
117 command: AuthCommand,
118 },
119 Init {
121 #[arg(long)]
123 name: String,
124 #[arg(long)]
126 template_id: Option<String>,
127 #[arg(long)]
129 dir: Option<PathBuf>,
130 #[arg(long)]
132 no_clone: bool,
133 #[arg(long)]
135 owner_email: Option<String>,
136 #[arg(long)]
138 system_prompt: Option<String>,
139 #[arg(long = "skill")]
141 skills: Vec<String>,
142 },
143 Gateway {
145 #[command(subcommand)]
146 command: GatewayCommand,
147 },
148 Trace {
150 #[command(subcommand)]
151 command: TraceCommand,
152 },
153 Agent {
155 #[command(subcommand)]
156 command: AgentCommand,
157 },
158 Runtime {
160 #[command(subcommand)]
161 command: RuntimeCommand,
162 },
163 Context {
165 #[command(subcommand)]
166 command: ContextCommand,
167 },
168 Computer {
170 #[command(subcommand)]
171 command: ComputerCommand,
172 },
173 Space {
175 #[command(subcommand)]
176 command: SpaceCommand,
177 },
178 Snapshot {
180 #[command(subcommand)]
181 command: SnapshotCommand,
182 },
183 Model {
185 #[command(subcommand)]
186 command: ModelCommand,
187 },
188 Evaluate {
190 #[command(subcommand)]
191 command: EvaluateCommand,
192 },
193 Eval {
195 #[command(subcommand)]
196 command: EvalCommand,
197 },
198 Execution {
200 #[command(subcommand)]
201 command: ExecutionCommand,
202 },
203 Deploy {
205 #[command(subcommand)]
206 command: DeployCommand,
207 },
208}
209
210#[derive(Debug, Subcommand)]
211pub enum DeployCommand {
212 Template {
214 #[command(subcommand)]
215 command: DeployTemplateCommand,
216 },
217 MoulinConfig {
219 #[command(subcommand)]
220 command: DeployMoulinConfigCommand,
221 },
222 Repo {
224 #[command(subcommand)]
225 command: DeployRepoCommand,
226 },
227}
228
229#[derive(Debug, Subcommand)]
230pub enum DeployMoulinConfigCommand {
231 Get,
233 Update {
235 #[command(flatten)]
236 input: JsonInput,
237 },
238}
239
240#[derive(Debug, Subcommand)]
241pub enum DeployTemplateCommand {
242 List,
243}
244
245#[derive(Debug, Subcommand)]
246pub enum DeployRepoCommand {
247 Init {
249 #[command(flatten)]
250 input: JsonInput,
251 #[arg(long)]
252 idempotency_key: String,
253 #[arg(long)]
255 dir: Option<PathBuf>,
256 #[arg(long)]
258 no_clone: bool,
259 #[arg(long)]
261 agent_id: Option<String>,
262 #[arg(long)]
264 runtime_id: Option<String>,
265 },
266 List {
267 #[arg(long)]
268 owner_kind: Option<String>,
269 #[arg(long)]
270 owner_id: Option<String>,
271 },
272 Get {
273 repository_id: String,
274 },
275}
276
277#[derive(Debug, Subcommand)]
278pub enum ConfigCommand {
279 Show,
281 Gateway {
283 #[command(subcommand)]
284 command: ConfigGatewayCommand,
285 },
286}
287
288#[derive(Debug, Subcommand)]
289pub enum ConfigGatewayCommand {
290 Set { url: String },
292 Get,
294 Unset,
296}
297
298#[derive(Debug, Subcommand)]
299pub enum AuthCommand {
300 Status,
302}
303
304#[derive(Debug, Subcommand)]
305pub enum GatewayCommand {
306 Discovery,
307 Live,
308 Ready,
309 WaitReady {
311 #[arg(long, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..=3600))]
312 deadline: u64,
313 },
314 Dependencies,
315 Routes,
316 Metrics,
317 Policy,
318}
319
320#[derive(Debug, Subcommand)]
321pub enum AgentCommand {
322 Create(AgentCreateArgs),
324 Get {
325 agent_id: String,
326 },
327 List(AgentListArgs),
328 Update {
330 agent_id: String,
331 #[command(flatten)]
332 input: JsonInput,
333 },
334}
335
336#[derive(Debug, Args)]
337pub struct AgentCreateArgs {
338 #[arg(long, required_unless_present = "file")]
340 name: Option<String>,
341 #[arg(long, required_unless_present = "file")]
343 runtime_id: Option<String>,
344 #[arg(long)]
346 description: Option<String>,
347 #[arg(long)]
349 system_prompt: Option<String>,
350 #[arg(long = "skill")]
352 skills: Vec<String>,
353 #[arg(long)]
355 idempotency_key: Option<String>,
356 #[arg(
358 long,
359 short = 'f',
360 value_name = "PATH",
361 conflicts_with_all = ["name", "runtime_id", "description", "system_prompt", "skills", "idempotency_key"]
362 )]
363 file: Option<PathBuf>,
364}
365
366#[derive(Debug, Subcommand)]
367pub enum RuntimeCommand {
368 Create(RuntimeCreateArgs),
370 Get { runtime_id: String },
372 Rename {
374 runtime_id: String,
375 name: String,
376 #[arg(long)]
377 expected_revision: u64,
378 #[arg(long)]
379 idempotency_key: Option<String>,
380 },
381 Activate {
383 runtime_id: String,
384 #[command(flatten)]
385 input: JsonInput,
386 },
387 Suspend {
389 runtime_id: String,
390 #[command(flatten)]
391 input: JsonInput,
392 },
393 Resume {
395 runtime_id: String,
396 #[command(flatten)]
397 input: JsonInput,
398 },
399 Revoke {
401 runtime_id: String,
402 #[command(flatten)]
403 input: JsonInput,
404 },
405 Permissions {
407 runtime_id: String,
408 #[command(flatten)]
409 input: JsonInput,
410 },
411 BootstrapCredential {
413 #[command(flatten)]
414 input: JsonInput,
415 },
416 Credential {
418 #[command(subcommand)]
419 command: credential::RuntimeCredentialCommand,
420 },
421}
422
423#[derive(Debug, Args)]
424pub struct RuntimeCreateArgs {
425 #[arg(long, required_unless_present = "file")]
427 name: Option<String>,
428 #[arg(long, value_enum)]
430 kind: Option<CliRuntimeKind>,
431 #[arg(long)]
433 idempotency_key: Option<String>,
434 #[arg(
436 long,
437 short = 'f',
438 value_name = "PATH",
439 conflicts_with_all = ["name", "kind", "idempotency_key"]
440 )]
441 file: Option<PathBuf>,
442}
443
444#[derive(Clone, Copy, Debug, ValueEnum)]
445enum CliRuntimeKind {
446 Code,
447 Evaluate,
448 Tool,
449 Custom,
450}
451
452impl From<CliRuntimeKind> for RuntimeIdentityKind {
453 fn from(value: CliRuntimeKind) -> Self {
454 match value {
455 CliRuntimeKind::Code => Self::Code,
456 CliRuntimeKind::Evaluate => Self::Evaluate,
457 CliRuntimeKind::Tool => Self::Tool,
458 CliRuntimeKind::Custom => Self::Custom,
459 }
460 }
461}
462
463#[derive(Debug, Args)]
464pub struct AgentListArgs {
465 #[arg(long, value_enum)]
466 status: Option<CliAgentStatus>,
467 #[arg(long)]
468 related_agent_id: Option<String>,
469 #[arg(long)]
470 related_runtime_id: Option<String>,
471 #[arg(long)]
472 related_user_id: Option<String>,
473 #[arg(long)]
474 cursor: Option<String>,
475 #[arg(long, value_parser = clap::value_parser!(u32).range(1..=200))]
476 limit: Option<u32>,
477}
478
479#[derive(Clone, Copy, Debug, ValueEnum)]
480enum CliAgentStatus {
481 Active,
482 Archived,
483}
484
485impl From<CliAgentStatus> for AgentStatus {
486 fn from(value: CliAgentStatus) -> Self {
487 match value {
488 CliAgentStatus::Active => Self::Active,
489 CliAgentStatus::Archived => Self::Archived,
490 }
491 }
492}
493
494#[derive(Debug, Subcommand)]
495pub enum ContextCommand {
496 Recent {
497 conversation_id: String,
498 #[arg(long, value_parser = clap::value_parser!(u64).range(1..=1000))]
499 limit: Option<u64>,
500 #[arg(long, value_delimiter = ',')]
501 role: Vec<String>,
502 },
503 Search {
504 conversation_id: String,
505 query: String,
506 #[arg(long)]
507 role: Option<String>,
508 #[arg(long, value_parser = clap::value_parser!(u64).range(1..=1000))]
509 limit: Option<u64>,
510 },
511 Append(JsonInput),
513 Maintenance,
514}
515
516#[derive(Debug, Subcommand)]
517pub enum ComputerCommand {
518 Create {
520 #[command(flatten)]
521 input: JsonInput,
522 #[arg(long)]
523 idempotency_key: String,
524 },
525 Get {
526 computer_id: String,
527 },
528 List {
529 #[arg(long)]
530 owner_kind: Option<String>,
531 #[arg(long)]
532 owner_id: Option<String>,
533 },
534 Delete {
535 computer_id: String,
536 #[arg(long)]
537 idempotency_key: String,
538 },
539}
540
541#[derive(Debug, Subcommand)]
542pub enum SpaceCommand {
543 Create {
545 #[command(flatten)]
546 input: JsonInput,
547 #[arg(long)]
548 idempotency_key: String,
549 },
550 Get {
551 space_id: String,
552 },
553 List {
554 #[arg(long)]
555 owner_kind: Option<String>,
556 #[arg(long)]
557 owner_id: Option<String>,
558 },
559 Delete {
560 space_id: String,
561 #[arg(long)]
562 idempotency_key: String,
563 },
564}
565
566#[derive(Debug, Subcommand)]
567pub enum SnapshotCommand {
568 Create {
570 #[arg(long)]
571 provider: Option<String>,
572 #[arg(long)]
573 public: bool,
574 #[arg(long)]
575 idempotency_key: String,
576 },
577 Build {
579 #[command(flatten)]
580 input: JsonInput,
581 #[arg(long)]
582 idempotency_key: String,
583 },
584 Poll { snapshot_id: String },
586 Get { snapshot_id: String },
588 List,
590 Delete {
591 snapshot_id: String,
592 #[arg(long)]
593 idempotency_key: String,
594 },
595}
596
597#[derive(Debug, Subcommand)]
598pub enum ModelCommand {
599 Models,
600 Chat {
602 #[command(flatten)]
603 input: JsonInput,
604 #[arg(long)]
605 request_id: Option<String>,
606 },
607 Responses {
609 #[command(flatten)]
610 input: JsonInput,
611 #[arg(long)]
612 request_id: Option<String>,
613 },
614}
615
616#[derive(Debug, Args)]
617pub struct JsonInput {
618 #[arg(long, short = 'f', default_value = "-")]
620 file: PathBuf,
621}
622
623impl JsonInput {
624 fn read<T: DeserializeOwned>(&self) -> Result<T> {
625 let bytes = read_input(&self.file)?;
626 serde_json::from_slice(&bytes)
627 .with_context(|| format!("invalid JSON input from {}", display_source(&self.file)))
628 }
629}
630
631#[derive(Debug)]
632pub enum CommandOutput {
633 Json(Value),
634 Text(String),
635}
636
637impl CommandOutput {
638 fn json(value: impl Serialize) -> Result<Self> {
639 Ok(Self::Json(serde_json::to_value(value)?))
640 }
641
642 pub fn render(&self, format: OutputFormat) -> Result<String> {
643 match self {
644 Self::Text(value) => Ok(value.clone()),
645 Self::Json(value) => match format {
646 OutputFormat::Pretty => Ok(serde_json::to_string_pretty(value)?),
647 OutputFormat::Json => Ok(serde_json::to_string(value)?),
648 },
649 }
650 }
651}
652
653impl Cli {
654 pub async fn execute(self) -> Result<CommandOutput> {
655 if let Command::Config { command } = &self.command {
656 return execute_config(&self, command);
657 }
658 if let Command::Auth { command } = &self.command {
659 return execute_auth(&self, command);
660 }
661 let gateway_url = ConfigStore::discover(self.config.as_deref())?
662 .resolve_gateway(self.gateway.as_deref())?
663 .value;
664 let client = self.build_client()?;
665 match self.command {
666 Command::Config { .. } | Command::Auth { .. } => {
667 unreachable!("local commands returned before building the client")
668 }
669 Command::Gateway { command } => execute_gateway(&client, command).await,
670 Command::Init {
671 name,
672 template_id,
673 dir,
674 no_clone,
675 owner_email,
676 system_prompt,
677 skills,
678 } => {
679 init::execute(
680 &client,
681 init::InitArgs {
682 name,
683 template_id,
684 dir,
685 no_clone,
686 owner_email,
687 system_prompt,
688 skills: parse_skill_refs(&skills)?,
689 },
690 )
691 .await
692 }
693 Command::Trace { command } => trace::execute(&client, command).await,
694 Command::Agent { command } => execute_agent(&client, command).await,
695 Command::Runtime { command } => execute_runtime(&client, &gateway_url, command).await,
696 Command::Context { command } => execute_context(&client, command).await,
697 Command::Computer { command } => execute_computer(&client, command).await,
698 Command::Space { command } => execute_space(&client, command).await,
699 Command::Snapshot { command } => execute_snapshot(&client, command).await,
700 Command::Model { command } => execute_model(&client, command).await,
701 Command::Evaluate { command } => execute_evaluate(&client, command).await,
702 Command::Eval { command } => execute_eval(&client, command).await,
703 Command::Execution { command } => execute_execution(&client, command).await,
704 Command::Deploy { command } => execute_deploy(&client, command).await,
705 }
706 }
707
708 fn build_client(&self) -> Result<InfraClient> {
709 let store = ConfigStore::discover(self.config.as_deref())?;
710 let gateway = store.resolve_gateway(self.gateway.as_deref())?;
711 let (token, _) = resolve_credential(self.token.as_deref(), self.sky_home.as_deref())?;
712 let mut builder = InfraClient::builder(gateway.value)
713 .request_timeout(Duration::from_secs(self.timeout))
714 .trusted_mesh_http(self.trusted_mesh_http)
715 .user_agent(concat!("agent-infra-cli/", env!("CARGO_PKG_VERSION")));
716 if let Some(token) = token {
717 builder = builder.credentials(Arc::new(StaticCredentials::new(token)?));
718 }
719 builder.build().map_err(Into::into)
720 }
721}
722
723fn execute_config(cli: &Cli, command: &ConfigCommand) -> Result<CommandOutput> {
724 let store = ConfigStore::discover(cli.config.as_deref())?;
725 match command {
726 ConfigCommand::Show
727 | ConfigCommand::Gateway {
728 command: ConfigGatewayCommand::Get,
729 } => {
730 let config = store.load()?;
731 let effective = store.resolve_gateway(cli.gateway.as_deref())?;
732 CommandOutput::json(serde_json::json!({
733 "path": store.path(),
734 "gateway": config.gateway,
735 "effectiveGateway": effective.value,
736 "source": effective.source,
737 }))
738 }
739 ConfigCommand::Gateway {
740 command: ConfigGatewayCommand::Set { url },
741 } => {
742 let config = store.set_gateway(url)?;
743 CommandOutput::json(serde_json::json!({
744 "ok": true,
745 "path": store.path(),
746 "gateway": config.gateway,
747 }))
748 }
749 ConfigCommand::Gateway {
750 command: ConfigGatewayCommand::Unset,
751 } => {
752 store.unset_gateway()?;
753 CommandOutput::json(serde_json::json!({
754 "ok": true,
755 "path": store.path(),
756 "gateway": null,
757 }))
758 }
759 }
760}
761
762fn execute_auth(cli: &Cli, command: &AuthCommand) -> Result<CommandOutput> {
763 match command {
764 AuthCommand::Status => {
765 let store = ConfigStore::discover(cli.config.as_deref())?;
766 let gateway = store.resolve_gateway(cli.gateway.as_deref())?;
767 let (_, credential) =
768 resolve_credential(cli.token.as_deref(), cli.sky_home.as_deref())?;
769 CommandOutput::json(serde_json::json!({
770 "gateway": gateway.value,
771 "gatewaySource": gateway.source,
772 "credential": credential,
773 }))
774 }
775 }
776}
777
778async fn execute_gateway(client: &InfraClient, command: GatewayCommand) -> Result<CommandOutput> {
779 match command {
780 GatewayCommand::Discovery => CommandOutput::json(client.gateway().discovery().await?),
781 GatewayCommand::Live => CommandOutput::json(client.gateway().live().await?),
782 GatewayCommand::Ready => CommandOutput::json(client.gateway().ready().await?),
783 GatewayCommand::WaitReady { deadline } => CommandOutput::json(
784 client
785 .gateway()
786 .wait_until_ready(Duration::from_secs(deadline))
787 .await?,
788 ),
789 GatewayCommand::Dependencies => CommandOutput::json(client.gateway().dependencies().await?),
790 GatewayCommand::Routes => CommandOutput::json(client.gateway().routes().await?),
791 GatewayCommand::Metrics => CommandOutput::json(client.gateway().metrics().await?),
792 GatewayCommand::Policy => CommandOutput::json(client.gateway().effective_policy().await?),
793 }
794}
795
796async fn execute_agent(client: &InfraClient, command: AgentCommand) -> Result<CommandOutput> {
797 match command {
798 AgentCommand::Create(args) => {
799 let request = match args.file {
800 Some(file) => JsonInput { file }.read::<CreateAgentRequest>()?,
801 None => CreateAgentRequest {
802 name: args
803 .name
804 .ok_or_else(|| anyhow!("--name is required for inline agent creation"))?,
805 description: args.description.unwrap_or_default(),
806 system_prompt: args.system_prompt.unwrap_or_default(),
807 skills: parse_skill_refs(&args.skills)?,
808 relations: ResourceRelations {
809 runtime_ids: vec![args.runtime_id.ok_or_else(|| {
810 anyhow!("--runtime-id is required for inline agent creation")
811 })?],
812 ..Default::default()
813 },
814 idempotency_key: args.idempotency_key.unwrap_or_default(),
815 },
816 };
817 CommandOutput::json(client.agents().create(&request).await?)
818 }
819 AgentCommand::Get { agent_id } => {
820 let agent_id = parse_agent_id(&agent_id)?;
821 CommandOutput::json(client.agents().get(&agent_id).await?)
822 }
823 AgentCommand::List(args) => {
824 let request = ListAgentsRequest {
825 status: args.status.map(Into::into),
826 related_agent_id: args
827 .related_agent_id
828 .as_deref()
829 .map(parse_agent_id)
830 .transpose()?,
831 related_runtime_id: args.related_runtime_id,
832 related_user_id: args.related_user_id,
833 cursor: args.cursor,
834 limit: args.limit,
835 };
836 CommandOutput::json(client.agents().list(&request).await?)
837 }
838 AgentCommand::Update { agent_id, input } => {
839 let agent_id = parse_agent_id(&agent_id)?;
840 let request: UpdateAgentRequest = input.read()?;
841 CommandOutput::json(client.agents().update(&agent_id, &request).await?)
842 }
843 }
844}
845
846async fn execute_runtime(
847 client: &InfraClient,
848 gateway: &str,
849 command: RuntimeCommand,
850) -> Result<CommandOutput> {
851 match command {
852 RuntimeCommand::Create(args) => {
853 let request = match args.file {
854 Some(file) => JsonInput { file }.read::<CreateRuntimeIdentityRequest>()?,
855 None => CreateRuntimeIdentityRequest {
856 id: None,
857 name: args
858 .name
859 .ok_or_else(|| anyhow!("--name is required for inline runtime creation"))?,
860 kind: args.kind.unwrap_or(CliRuntimeKind::Code).into(),
861 agent_id: None,
862 relations: Default::default(),
863 metadata: Default::default(),
864 idempotency_key: args.idempotency_key.unwrap_or_default(),
865 },
866 };
867 CommandOutput::json(client.runtime_identity().create(&request).await?)
868 }
869 RuntimeCommand::Get { runtime_id } => {
870 let runtime_id = parse_runtime_id(&runtime_id)?;
871 CommandOutput::json(client.runtime_identity().get(&runtime_id).await?)
872 }
873 RuntimeCommand::Rename {
874 runtime_id,
875 name,
876 expected_revision,
877 idempotency_key,
878 } => {
879 let runtime_id = parse_runtime_id(&runtime_id)?;
880 let request = UpdateRuntimeIdentityRequest {
881 expected_revision,
882 name,
883 idempotency_key: idempotency_key.unwrap_or_default(),
884 };
885 CommandOutput::json(
886 client
887 .runtime_identity()
888 .update(&runtime_id, &request)
889 .await?,
890 )
891 }
892 RuntimeCommand::Activate { runtime_id, input } => {
893 execute_runtime_transition(client, runtime_id, "activate", input).await
894 }
895 RuntimeCommand::Suspend { runtime_id, input } => {
896 execute_runtime_transition(client, runtime_id, "suspend", input).await
897 }
898 RuntimeCommand::Resume { runtime_id, input } => {
899 execute_runtime_transition(client, runtime_id, "resume", input).await
900 }
901 RuntimeCommand::Revoke { runtime_id, input } => {
902 execute_runtime_transition(client, runtime_id, "revoke", input).await
903 }
904 RuntimeCommand::Permissions { runtime_id, input } => {
905 let runtime_id = parse_runtime_id(&runtime_id)?;
906 let request: ReplacePermissionsRequest = input.read()?;
907 CommandOutput::json(
908 client
909 .runtime_identity()
910 .replace_permissions(&runtime_id, &request)
911 .await?,
912 )
913 }
914 RuntimeCommand::BootstrapCredential { input } => {
915 let request: IssueRuntimeCredentialRequest = input.read()?;
916 CommandOutput::json(
917 client
918 .gateway()
919 .bootstrap_runtime_credential(&request)
920 .await?,
921 )
922 }
923 RuntimeCommand::Credential { command } => {
924 credential::execute(client, gateway, command).await
925 }
926 }
927}
928
929async fn execute_runtime_transition(
930 client: &InfraClient,
931 runtime_id: String,
932 action: &str,
933 input: JsonInput,
934) -> Result<CommandOutput> {
935 let runtime_id = parse_runtime_id(&runtime_id)?;
936 let request: TransitionIdentityRequest = input.read()?;
937 CommandOutput::json(
938 client
939 .runtime_identity()
940 .transition(&runtime_id, action, &request)
941 .await?,
942 )
943}
944
945async fn execute_context(client: &InfraClient, command: ContextCommand) -> Result<CommandOutput> {
946 match command {
947 ContextCommand::Recent {
948 conversation_id,
949 limit,
950 role,
951 } => {
952 let roles = role.iter().map(String::as_str).collect::<Vec<_>>();
953 CommandOutput::json(
954 client
955 .context()
956 .recent_messages(&conversation_id, limit.map(|value| value as usize), &roles)
957 .await?,
958 )
959 }
960 ContextCommand::Search {
961 conversation_id,
962 query,
963 role,
964 limit,
965 } => CommandOutput::json(
966 client
967 .context()
968 .search_messages(
969 &conversation_id,
970 &query,
971 role.as_deref(),
972 limit.map(|value| value as usize),
973 )
974 .await?,
975 ),
976 ContextCommand::Append(input) => {
977 let messages: Vec<ConversationRecord> = input.read()?;
978 if messages.is_empty() {
979 bail!("append input must contain at least one message");
980 }
981 CommandOutput::json(client.context().append_messages(messages).await?)
982 }
983 ContextCommand::Maintenance => {
984 CommandOutput::json(client.context().maintenance_status().await?)
985 }
986 }
987}
988
989async fn execute_computer(client: &InfraClient, command: ComputerCommand) -> Result<CommandOutput> {
990 let environment = client.environment();
991 match command {
992 ComputerCommand::Create {
993 input,
994 idempotency_key,
995 } => {
996 let request: CreateComputerRequest = input.read()?;
997 CommandOutput::json(
998 environment
999 .create_computer(&request, &idempotency_key)
1000 .await?,
1001 )
1002 }
1003 ComputerCommand::Get { computer_id } => {
1004 CommandOutput::json(environment.computer(&computer_id).await?)
1005 }
1006 ComputerCommand::List {
1007 owner_kind,
1008 owner_id,
1009 } => CommandOutput::json(
1010 environment
1011 .computers(Some(&list_environment_query(owner_kind, owner_id)?))
1012 .await?,
1013 ),
1014 ComputerCommand::Delete {
1015 computer_id,
1016 idempotency_key,
1017 } => CommandOutput::json(
1018 environment
1019 .delete_computer(&computer_id, &idempotency_key)
1020 .await?,
1021 ),
1022 }
1023}
1024
1025async fn execute_space(client: &InfraClient, command: SpaceCommand) -> Result<CommandOutput> {
1026 let environment = client.environment();
1027 match command {
1028 SpaceCommand::Create {
1029 input,
1030 idempotency_key,
1031 } => {
1032 let request: CreateSpaceRequest = input.read()?;
1033 CommandOutput::json(environment.create_space(&request, &idempotency_key).await?)
1034 }
1035 SpaceCommand::Get { space_id } => CommandOutput::json(environment.space(&space_id).await?),
1036 SpaceCommand::List {
1037 owner_kind,
1038 owner_id,
1039 } => CommandOutput::json(
1040 environment
1041 .spaces(Some(&list_environment_query(owner_kind, owner_id)?))
1042 .await?,
1043 ),
1044 SpaceCommand::Delete {
1045 space_id,
1046 idempotency_key,
1047 } => CommandOutput::json(
1048 environment
1049 .delete_space(&space_id, &idempotency_key)
1050 .await?,
1051 ),
1052 }
1053}
1054
1055async fn execute_snapshot(client: &InfraClient, command: SnapshotCommand) -> Result<CommandOutput> {
1056 let environment = client.environment();
1057 match command {
1058 SnapshotCommand::Create {
1059 provider,
1060 public,
1061 idempotency_key,
1062 } => {
1063 let request = CreateSnapshotResourceRequest {
1064 provider,
1065 visibility: if public {
1066 SnapshotVisibility::Public
1067 } else {
1068 SnapshotVisibility::Private
1069 },
1070 owners: vec![],
1071 builtin: false,
1072 build: None,
1073 };
1074 CommandOutput::json(
1075 environment
1076 .create_snapshot(&request, &idempotency_key)
1077 .await?,
1078 )
1079 }
1080 SnapshotCommand::Build {
1081 input,
1082 idempotency_key,
1083 } => {
1084 let request: CreateSnapshotResourceRequest = input.read()?;
1085 CommandOutput::json(
1086 environment
1087 .create_snapshot(&request, &idempotency_key)
1088 .await?,
1089 )
1090 }
1091 SnapshotCommand::Poll { snapshot_id } => {
1092 CommandOutput::json(environment.poll_snapshot(&snapshot_id).await?)
1093 }
1094 SnapshotCommand::Get { snapshot_id } => {
1095 CommandOutput::json(environment.poll_snapshot(&snapshot_id).await?)
1096 }
1097 SnapshotCommand::List => CommandOutput::json(environment.snapshots().await?),
1098 SnapshotCommand::Delete {
1099 snapshot_id,
1100 idempotency_key,
1101 } => CommandOutput::json(
1102 environment
1103 .delete_snapshot(&snapshot_id, &idempotency_key)
1104 .await?,
1105 ),
1106 }
1107}
1108
1109fn list_environment_query(
1110 owner_kind: Option<String>,
1111 owner_id: Option<String>,
1112) -> Result<ListEnvironmentResourcesQuery> {
1113 let owner_kind = match owner_kind {
1114 Some(value) => Some(parse_owner_kind(&value)?),
1115 None => None,
1116 };
1117 if owner_kind.is_some() != owner_id.is_some() {
1118 bail!("--owner-kind and --owner-id must be provided together");
1119 }
1120 Ok(ListEnvironmentResourcesQuery {
1121 owner_kind,
1122 owner_id,
1123 contains_all: vec![],
1124 })
1125}
1126
1127fn parse_owner_kind(value: &str) -> Result<OwnerKind> {
1128 match value.to_ascii_lowercase().as_str() {
1129 "user" => Ok(OwnerKind::User),
1130 "agent" => Ok(OwnerKind::Agent),
1131 "runtime" => Ok(OwnerKind::Runtime),
1132 other => bail!("unsupported owner kind {other:?}; expected user, agent, or runtime"),
1133 }
1134}
1135
1136fn parse_deploy_owner_kind(value: &str) -> Result<agent_infra_sdk::deploy_contract::OwnerKind> {
1137 agent_infra_sdk::deploy_contract::OwnerKind::parse(&value.to_ascii_lowercase()).ok_or_else(
1138 || anyhow!("unsupported owner kind {value:?}; expected user, agent, or runtime"),
1139 )
1140}
1141
1142async fn execute_model(client: &InfraClient, command: ModelCommand) -> Result<CommandOutput> {
1143 match command {
1144 ModelCommand::Models => CommandOutput::json(client.model().models().await?),
1145 ModelCommand::Chat { input, request_id } => {
1146 let request: Value = input.read()?;
1147 ensure_json_object(&request)?;
1148 CommandOutput::json(
1149 client
1150 .model()
1151 .chat_completions(&request, call_options(request_id))
1152 .await?,
1153 )
1154 }
1155 ModelCommand::Responses { input, request_id } => {
1156 let request: Value = input.read()?;
1157 ensure_json_object(&request)?;
1158 CommandOutput::json(
1159 client
1160 .model()
1161 .responses(&request, call_options(request_id))
1162 .await?,
1163 )
1164 }
1165 }
1166}
1167
1168async fn execute_deploy(client: &InfraClient, command: DeployCommand) -> Result<CommandOutput> {
1169 let deploy = client.deploy();
1170 match command {
1171 DeployCommand::Template {
1172 command: DeployTemplateCommand::List,
1173 } => CommandOutput::json(deploy.list_runtime_templates(None, None).await?),
1174 DeployCommand::MoulinConfig { command } => match command {
1175 DeployMoulinConfigCommand::Get => {
1176 CommandOutput::json(client.admin().moulin_config().await?)
1177 }
1178 DeployMoulinConfigCommand::Update { input } => {
1179 let request = input
1180 .read::<agent_infra_sdk::deploy_contract::UpdateMoulinAdminConfigRequest>(
1181 )?;
1182 CommandOutput::json(client.admin().update_moulin_config(&request).await?)
1183 }
1184 },
1185 DeployCommand::Repo { command } => match command {
1186 DeployRepoCommand::Init {
1187 input,
1188 idempotency_key,
1189 dir,
1190 no_clone,
1191 agent_id,
1192 runtime_id,
1193 } => {
1194 let mut request: agent_infra_sdk::deploy_contract::CreateRuntimeRepositoryRequest =
1195 input.read()?;
1196 request.owners = runtime_repo_checkout::merge_identity_owners(
1197 request.owners,
1198 agent_id.as_deref(),
1199 runtime_id.as_deref(),
1200 );
1201 let mut record = deploy
1202 .create_runtime_repository(&request, &idempotency_key)
1203 .await?;
1204 let deadline = Instant::now() + Duration::from_secs(120);
1205 while record.status
1206 == agent_infra_sdk::deploy_contract::RuntimeRepositoryStatus::Importing
1207 {
1208 if Instant::now() >= deadline {
1209 bail!(
1210 "runtime repository import timed out for {}",
1211 record.path_with_namespace
1212 );
1213 }
1214 tokio::time::sleep(Duration::from_secs(2)).await;
1215 record = deploy.runtime_repository(&record.id).await?;
1216 }
1217 if record.status
1218 == agent_infra_sdk::deploy_contract::RuntimeRepositoryStatus::Failed
1219 {
1220 bail!(
1221 "{}",
1222 record
1223 .error_message
1224 .unwrap_or_else(|| { "runtime repository import failed".to_string() })
1225 );
1226 }
1227 let checkout = runtime_repo_checkout::checkout_runtime_repository(
1228 &record,
1229 &runtime_repo_checkout::CheckoutOptions {
1230 dir,
1231 clone: !no_clone,
1232 agent_id,
1233 runtime_id,
1234 },
1235 )?;
1236 eprintln!(
1237 "checked out {} ({})",
1238 checkout.display(),
1239 checkout.join(".env.local").display()
1240 );
1241 CommandOutput::json(record)
1242 }
1243 DeployRepoCommand::List {
1244 owner_kind,
1245 owner_id,
1246 } => {
1247 let owner_kind = match owner_kind {
1248 Some(value) => Some(parse_deploy_owner_kind(&value)?),
1249 None => None,
1250 };
1251 if owner_kind.is_some() != owner_id.is_some() {
1252 bail!("--owner-kind and --owner-id must be provided together");
1253 }
1254 CommandOutput::json(
1255 deploy
1256 .runtime_repositories(owner_kind, owner_id.as_deref(), None, None)
1257 .await?,
1258 )
1259 }
1260 DeployRepoCommand::Get { repository_id } => {
1261 CommandOutput::json(deploy.runtime_repository(&repository_id).await?)
1262 }
1263 },
1264 }
1265}
1266
1267fn call_options(request_id: Option<String>) -> CallOptions {
1268 request_id.map_or_else(CallOptions::default, |value| {
1269 CallOptions::default().request_id(value)
1270 })
1271}
1272
1273fn ensure_json_object(value: &Value) -> Result<()> {
1274 if !value.is_object() {
1275 bail!("model request must be a JSON object");
1276 }
1277 Ok(())
1278}
1279
1280fn parse_agent_id(value: &str) -> Result<AgentId> {
1281 AgentId::new(value).map_err(|message| anyhow!(message))
1282}
1283
1284fn parse_skill_refs(values: &[String]) -> Result<Vec<AgentSkillRef>> {
1285 values
1286 .iter()
1287 .map(|value| AgentSkillRef::parse(value).map_err(|message| anyhow!(message)))
1288 .collect()
1289}
1290
1291fn parse_runtime_id(value: &str) -> Result<RuntimeIdentityId> {
1292 RuntimeIdentityId::new(value).map_err(|message| anyhow!(message))
1293}
1294
1295pub(crate) fn read_input(path: &Path) -> Result<Vec<u8>> {
1296 if path == Path::new("-") {
1297 let mut bytes = Vec::new();
1298 io::stdin()
1299 .read_to_end(&mut bytes)
1300 .context("failed to read standard input")?;
1301 Ok(bytes)
1302 } else {
1303 fs::read(path).with_context(|| format!("failed to read {}", path.display()))
1304 }
1305}
1306
1307pub(crate) fn display_source(path: &Path) -> String {
1308 if path == Path::new("-") {
1309 "standard input".into()
1310 } else {
1311 path.display().to_string()
1312 }
1313}
1314
1315#[cfg(test)]
1316mod tests {
1317 use super::*;
1318 use crate::trace::{
1319 LangfuseProjectCommand, LangfuseProjectListArgs, TraceBindingCommand, TraceMetricsCommand,
1320 };
1321 use agent_infra_sdk::trace_contract::MetricsDataSource;
1322 use clap::CommandFactory;
1323 use wiremock::{
1324 Mock, MockServer, ResponseTemplate,
1325 matchers::{body_json, header, method, path},
1326 };
1327
1328 #[test]
1329 fn clap_definition_is_valid() {
1330 Cli::command().debug_assert();
1331 }
1332
1333 #[test]
1334 fn parses_runtime_credential_ensure() {
1335 let command = Cli::try_parse_from([
1336 "infra",
1337 "runtime",
1338 "credential",
1339 "ensure",
1340 "rti_test",
1341 "--store",
1342 "file",
1343 "--yes",
1344 ])
1345 .unwrap()
1346 .command;
1347 match command {
1348 Command::Runtime {
1349 command:
1350 RuntimeCommand::Credential {
1351 command: crate::credential::RuntimeCredentialCommand::Ensure(args),
1352 },
1353 } => {
1354 assert_eq!(args.runtime_id, "rti_test");
1355 assert_eq!(
1356 args.store.store,
1357 crate::credential::CredentialStoreKind::File
1358 );
1359 assert!(args.store.yes);
1360 }
1361 other => panic!("unexpected command: {other:?}"),
1362 }
1363 }
1364
1365 #[test]
1366 fn rejects_admin_subcommand() {
1367 assert!(Cli::try_parse_from(["infra", "admin"]).is_err());
1368 assert!(Cli::try_parse_from(["infra", "admin", "langfuse-config", "get"]).is_err());
1369 assert!(Cli::try_parse_from(["infra", "admin", "clickhouse-config", "get"]).is_err());
1370 }
1371
1372 #[test]
1373 fn parses_snapshot_create_provider_and_public_flags() {
1374 let command = Cli::try_parse_from([
1375 "infra",
1376 "snapshot",
1377 "create",
1378 "--provider",
1379 "moulin",
1380 "--public",
1381 "--idempotency-key",
1382 "snap-1",
1383 ])
1384 .unwrap()
1385 .command;
1386 match command {
1387 Command::Snapshot {
1388 command:
1389 SnapshotCommand::Create {
1390 provider,
1391 public,
1392 idempotency_key,
1393 },
1394 } => {
1395 assert_eq!(provider.as_deref(), Some("moulin"));
1396 assert!(public);
1397 assert_eq!(idempotency_key, "snap-1");
1398 }
1399 other => panic!("unexpected command: {other:?}"),
1400 }
1401 }
1402
1403 #[test]
1404 fn parses_snapshot_build_and_single_poll() {
1405 let build = Cli::try_parse_from([
1406 "infra",
1407 "snapshot",
1408 "build",
1409 "--file",
1410 "e2b-build.json",
1411 "--idempotency-key",
1412 "build-1",
1413 ])
1414 .unwrap()
1415 .command;
1416 match build {
1417 Command::Snapshot {
1418 command:
1419 SnapshotCommand::Build {
1420 input,
1421 idempotency_key,
1422 },
1423 } => {
1424 assert_eq!(input.file, PathBuf::from("e2b-build.json"));
1425 assert_eq!(idempotency_key, "build-1");
1426 }
1427 other => panic!("unexpected command: {other:?}"),
1428 }
1429
1430 let poll = Cli::try_parse_from(["infra", "snapshot", "poll", "snap_1"])
1431 .unwrap()
1432 .command;
1433 assert!(matches!(
1434 poll,
1435 Command::Snapshot {
1436 command: SnapshotCommand::Poll { snapshot_id }
1437 } if snapshot_id == "snap_1"
1438 ));
1439 }
1440
1441 #[test]
1442 fn parses_init_name_template_and_no_clone() {
1443 let command = Cli::try_parse_from([
1444 "infra",
1445 "init",
1446 "--name",
1447 "Weather Bot",
1448 "--template-id",
1449 "rtpl-1",
1450 "--dir",
1451 "./weather",
1452 "--no-clone",
1453 ])
1454 .unwrap()
1455 .command;
1456 match command {
1457 Command::Init {
1458 name,
1459 template_id,
1460 dir,
1461 no_clone,
1462 owner_email,
1463 system_prompt,
1464 skills,
1465 } => {
1466 assert_eq!(name, "Weather Bot");
1467 assert_eq!(template_id.as_deref(), Some("rtpl-1"));
1468 assert_eq!(dir, Some(PathBuf::from("./weather")));
1469 assert!(no_clone);
1470 assert!(owner_email.is_none());
1471 assert!(system_prompt.is_none());
1472 assert!(skills.is_empty());
1473 }
1474 other => panic!("unexpected command: {other:?}"),
1475 }
1476 }
1477
1478 #[test]
1479 fn parses_agent_create_name_and_runtime_id() {
1480 let command = Cli::try_parse_from([
1481 "infra",
1482 "agent",
1483 "create",
1484 "--name",
1485 "weather",
1486 "--runtime-id",
1487 "rti_1",
1488 "--description",
1489 "demo",
1490 ])
1491 .unwrap()
1492 .command;
1493 match command {
1494 Command::Agent {
1495 command:
1496 AgentCommand::Create(AgentCreateArgs {
1497 name,
1498 runtime_id,
1499 description,
1500 system_prompt,
1501 skills,
1502 idempotency_key,
1503 file,
1504 }),
1505 } => {
1506 assert_eq!(name.as_deref(), Some("weather"));
1507 assert_eq!(runtime_id.as_deref(), Some("rti_1"));
1508 assert_eq!(description.as_deref(), Some("demo"));
1509 assert!(system_prompt.is_none());
1510 assert!(skills.is_empty());
1511 assert!(idempotency_key.is_none());
1512 assert!(file.is_none());
1513 }
1514 other => panic!("unexpected command: {other:?}"),
1515 }
1516 }
1517
1518 #[test]
1519 fn parses_deploy_template_and_repository_commands() {
1520 assert!(matches!(
1521 Cli::try_parse_from(["infra", "deploy", "moulin-config", "get"])
1522 .unwrap()
1523 .command,
1524 Command::Deploy {
1525 command: DeployCommand::MoulinConfig {
1526 command: DeployMoulinConfigCommand::Get,
1527 },
1528 }
1529 ));
1530 assert!(matches!(
1531 Cli::try_parse_from([
1532 "infra",
1533 "deploy",
1534 "moulin-config",
1535 "update",
1536 "--file",
1537 "moulin.json"
1538 ])
1539 .unwrap()
1540 .command,
1541 Command::Deploy {
1542 command: DeployCommand::MoulinConfig {
1543 command: DeployMoulinConfigCommand::Update { .. },
1544 },
1545 }
1546 ));
1547 assert!(matches!(
1548 Cli::try_parse_from(["infra", "deploy", "template", "list"])
1549 .unwrap()
1550 .command,
1551 Command::Deploy {
1552 command: DeployCommand::Template {
1553 command: DeployTemplateCommand::List,
1554 },
1555 }
1556 ));
1557 let init = Cli::try_parse_from([
1558 "infra",
1559 "deploy",
1560 "repo",
1561 "init",
1562 "--idempotency-key",
1563 "init-1",
1564 "--file",
1565 "repo.json",
1566 "--dir",
1567 "./demo",
1568 "--no-clone",
1569 "--agent-id",
1570 "agt_1",
1571 "--runtime-id",
1572 "rt-1",
1573 ])
1574 .unwrap()
1575 .command;
1576 match init {
1577 Command::Deploy {
1578 command:
1579 DeployCommand::Repo {
1580 command:
1581 DeployRepoCommand::Init {
1582 dir,
1583 no_clone,
1584 agent_id,
1585 runtime_id,
1586 ..
1587 },
1588 },
1589 } => {
1590 assert_eq!(dir, Some(PathBuf::from("./demo")));
1591 assert!(no_clone);
1592 assert_eq!(agent_id.as_deref(), Some("agt_1"));
1593 assert_eq!(runtime_id.as_deref(), Some("rt-1"));
1594 }
1595 other => panic!("unexpected command: {other:?}"),
1596 }
1597 assert!(matches!(
1598 Cli::try_parse_from([
1599 "infra",
1600 "deploy",
1601 "repo",
1602 "list",
1603 "--owner-kind",
1604 "user",
1605 "--owner-id",
1606 "user-1"
1607 ])
1608 .unwrap()
1609 .command,
1610 Command::Deploy {
1611 command: DeployCommand::Repo {
1612 command: DeployRepoCommand::List { .. },
1613 },
1614 }
1615 ));
1616 assert!(matches!(
1617 Cli::try_parse_from(["infra", "deploy", "repo", "get", "rrepo-1"])
1618 .unwrap()
1619 .command,
1620 Command::Deploy {
1621 command: DeployCommand::Repo {
1622 command: DeployRepoCommand::Get { repository_id },
1623 },
1624 } if repository_id == "rrepo-1"
1625 ));
1626 }
1627
1628 #[test]
1629 fn parses_global_options_after_subcommands() {
1630 let cli = Cli::try_parse_from([
1631 "infra",
1632 "gateway",
1633 "wait-ready",
1634 "--deadline",
1635 "42",
1636 "--gateway",
1637 "https://infra.example.com",
1638 "--output",
1639 "json",
1640 ])
1641 .unwrap();
1642 assert_eq!(cli.gateway.as_deref(), Some("https://infra.example.com"));
1643 assert_eq!(cli.output, OutputFormat::Json);
1644 assert!(matches!(
1645 cli.command,
1646 Command::Gateway {
1647 command: GatewayCommand::WaitReady { deadline: 42 }
1648 }
1649 ));
1650 }
1651
1652 #[test]
1653 fn rejects_removed_workspace_subcommand() {
1654 let result = Cli::try_parse_from(["infra", "workspace", "write", "README.md"]);
1655 assert!(result.is_err());
1656 }
1657
1658 #[test]
1659 fn parses_trace_metrics_query_options() {
1660 let cli = Cli::try_parse_from([
1661 "infra",
1662 "trace",
1663 "metrics",
1664 "token-usage",
1665 "--project-id",
1666 "project-a",
1667 "--project-id",
1668 "project-b",
1669 "--days",
1670 "7",
1671 "--data-source",
1672 "v4",
1673 ])
1674 .unwrap();
1675 let Command::Trace {
1676 command:
1677 TraceCommand::Metrics {
1678 command: TraceMetricsCommand::TokenUsage(args),
1679 },
1680 } = cli.command
1681 else {
1682 panic!("expected trace token-usage command");
1683 };
1684 let query = args.into_query();
1685 assert_eq!(query.project_ids.unwrap(), ["project-a", "project-b"]);
1686 assert_eq!(query.days, 7);
1687 assert_eq!(query.data_source, MetricsDataSource::V4);
1688 }
1689
1690 #[test]
1691 fn parses_langfuse_project_relationship_query() {
1692 let cli = Cli::try_parse_from([
1693 "infra",
1694 "trace",
1695 "langfuse-project",
1696 "list",
1697 "--related-agent-id",
1698 "agt_weather",
1699 "--limit",
1700 "20",
1701 ])
1702 .unwrap();
1703 assert!(matches!(
1704 cli.command,
1705 Command::Trace {
1706 command: TraceCommand::LangfuseProject {
1707 command: LangfuseProjectCommand::List(LangfuseProjectListArgs {
1708 related_agent_id: Some(ref agent_id),
1709 limit: Some(20),
1710 ..
1711 })
1712 }
1713 } if agent_id == "agt_weather"
1714 ));
1715 }
1716
1717 #[test]
1718 fn parses_trace_binding_get() {
1719 let cli = Cli::try_parse_from(["infra", "trace", "binding", "get", "rti_1"]).unwrap();
1720 assert!(matches!(
1721 cli.command,
1722 Command::Trace {
1723 command: TraceCommand::Binding {
1724 command: TraceBindingCommand::Get { ref runtime_id }
1725 }
1726 } if runtime_id == "rti_1"
1727 ));
1728 }
1729
1730 #[test]
1731 fn parses_trace_binding_initialize() {
1732 let cli = Cli::try_parse_from([
1733 "infra",
1734 "trace",
1735 "binding",
1736 "initialize",
1737 "rti_1",
1738 "--project-name",
1739 "demo",
1740 "--idempotency-key",
1741 "init-trace-rti_1",
1742 ])
1743 .unwrap();
1744 assert!(matches!(
1745 cli.command,
1746 Command::Trace {
1747 command: TraceCommand::Binding {
1748 command: TraceBindingCommand::Initialize {
1749 ref runtime_id,
1750 project_name: Some(ref project_name),
1751 ..
1752 }
1753 }
1754 } if runtime_id == "rti_1" && project_name == "demo"
1755 ));
1756 }
1757
1758 #[test]
1759 fn parses_evaluate_resource_and_task_commands() {
1760 let register = Cli::try_parse_from([
1761 "infra",
1762 "evaluate",
1763 "register-evaluator",
1764 "--file",
1765 "evaluator.json",
1766 ])
1767 .unwrap();
1768 assert!(matches!(
1769 register.command,
1770 Command::Evaluate {
1771 command: EvaluateCommand::RegisterEvaluator(_)
1772 }
1773 ));
1774 let task = Cli::try_parse_from(["infra", "evaluate", "task-create", "--file", "run.json"])
1775 .unwrap();
1776 assert!(matches!(
1777 task.command,
1778 Command::Evaluate {
1779 command: EvaluateCommand::TaskCreate(_)
1780 }
1781 ));
1782 let alias =
1783 Cli::try_parse_from(["infra", "evaluate", "run", "--file", "run.json"]).unwrap();
1784 assert!(matches!(
1785 alias.command,
1786 Command::Evaluate {
1787 command: EvaluateCommand::TaskCreate(_)
1788 }
1789 ));
1790 let health = Cli::try_parse_from([
1791 "infra",
1792 "evaluate",
1793 "metrics",
1794 "health",
1795 "--window-runs",
1796 "30",
1797 ])
1798 .unwrap();
1799 assert!(matches!(
1800 health.command,
1801 Command::Evaluate {
1802 command: EvaluateCommand::Metrics {
1803 command: EvaluateMetricsCommand::Health {
1804 window_runs: 30,
1805 evaluator_id: None
1806 }
1807 }
1808 }
1809 ));
1810 let sample = Cli::try_parse_from([
1811 "infra",
1812 "evaluate",
1813 "review",
1814 "sample",
1815 "--file",
1816 "sample.json",
1817 ])
1818 .unwrap();
1819 assert!(matches!(
1820 sample.command,
1821 Command::Evaluate {
1822 command: EvaluateCommand::Review {
1823 command: EvaluateReviewCommand::Sample(_)
1824 }
1825 }
1826 ));
1827 }
1828
1829 #[tokio::test]
1830 async fn evaluate_push_derives_versions_from_content_and_registers_dependencies_first() {
1831 let directory = tempfile::tempdir().unwrap();
1832 let bundle = directory.path().join("eval.json");
1833 fs::write(
1834 &bundle,
1835 serde_json::to_vec(&serde_json::json!({
1836 "suiteId": "core",
1837 "evaluators": [{
1838 "evaluatorId": "grounded",
1839 "implementation": {
1840 "kind": "builtin_assertions",
1841 "assertions": [{
1842 "kind": "output_contains",
1843 "turn": 0,
1844 "values": ["ok"]
1845 }]
1846 },
1847 "metric": { "class": "goal", "decision": "block_release", "owner": "team" }
1848 }],
1849 "cases": [{
1850 "id": "answer",
1851 "category": "correctness",
1852 "turns": [{ "user": "say ok" }],
1853 "evaluators": ["grounded"]
1854 }]
1855 }))
1856 .unwrap(),
1857 )
1858 .unwrap();
1859
1860 let dry_run = Cli::try_parse_from([
1861 "infra",
1862 "evaluate",
1863 "push",
1864 "--file",
1865 bundle.to_str().unwrap(),
1866 "--dry-run",
1867 ])
1868 .unwrap();
1869 let CommandOutput::Json(planned) = dry_run.execute().await.unwrap() else {
1870 panic!("push must produce JSON");
1871 };
1872 let suite_version = planned["suiteVersion"].as_str().unwrap().to_string();
1873 let evaluator_version = planned["evaluators"][0]["version"]
1874 .as_str()
1875 .unwrap()
1876 .to_string();
1877 let case_version = planned["cases"][0]["version"].as_str().unwrap().to_string();
1878 assert!(suite_version.starts_with("sha256-"));
1879 assert!(case_version.starts_with("sha256-"));
1880 assert_eq!(planned["applied"], serde_json::json!(false));
1881
1882 let server = MockServer::start().await;
1883 Mock::given(method("POST"))
1884 .and(path(
1885 agent_infra_sdk::evaluate_contract::EVALUATOR_VERSIONS_PATH,
1886 ))
1887 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
1888 "tenantId": "tenant",
1889 "projectId": "project",
1890 "evaluatorId": "grounded",
1891 "version": evaluator_version,
1892 "digest": "sha256:test",
1893 "implementation": { "kind": "builtin_assertions", "assertions": [] },
1894 "createdAtMs": 1
1895 })))
1896 .expect(1)
1897 .mount(&server)
1898 .await;
1899 Mock::given(method("POST"))
1900 .and(path(agent_infra_sdk::evaluate_contract::CASE_VERSIONS_PATH))
1901 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
1902 "tenantId": "tenant",
1903 "projectId": "project",
1904 "caseId": "answer",
1905 "version": case_version,
1906 "digest": "sha256:test",
1907 "category": "correctness",
1908 "turns": [{ "user": "say ok" }],
1909 "createdAtMs": 1
1910 })))
1911 .expect(1)
1912 .mount(&server)
1913 .await;
1914 Mock::given(method("POST"))
1915 .and(path(agent_infra_sdk::evaluate_contract::SUITES_PATH))
1916 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
1917 "tenantId": "tenant",
1918 "projectId": "project",
1919 "suiteId": "core",
1920 "version": suite_version,
1921 "manifestDigest": "sha256:test",
1922 "schemaVersion": 1,
1923 "cases": [{ "id": "answer", "version": case_version }],
1924 "createdAtMs": 1
1925 })))
1926 .expect(1)
1927 .mount(&server)
1928 .await;
1929
1930 let server_uri = server.uri();
1931 let applied = Cli::try_parse_from([
1932 "infra",
1933 "--gateway",
1934 server_uri.as_str(),
1935 "--token",
1936 "test-token",
1937 "evaluate",
1938 "push",
1939 "--file",
1940 bundle.to_str().unwrap(),
1941 ])
1942 .unwrap()
1943 .execute()
1944 .await
1945 .unwrap();
1946 let CommandOutput::Json(applied) = applied else {
1947 panic!("push must produce JSON");
1948 };
1949 assert_eq!(applied["applied"], serde_json::json!(true));
1950 assert_eq!(applied["suiteVersion"], serde_json::json!(suite_version));
1951 }
1952
1953 #[tokio::test]
1954 async fn evaluate_diff_fails_the_command_and_names_the_blocking_metric() {
1955 let server = MockServer::start().await;
1956 Mock::given(method("POST"))
1957 .and(path(
1958 agent_infra_sdk::evaluate_contract::COMPARE_REPORTS_PATH,
1959 ))
1960 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1961 "reportId": "report-b",
1962 "baselineReportId": "report-a",
1963 "passRateDelta": -0.5,
1964 "infraFailureRateDelta": 0.0,
1965 "passRateConfidenceLow": 0.1,
1966 "passRateConfidenceHigh": 0.4,
1967 "effectConfidenceLow": -0.6,
1968 "effectConfidenceHigh": -0.4,
1969 "pValue": 0.001,
1970 "statisticallySignificant": true,
1971 "changedCases": [],
1972 "gatePassed": false,
1973 "gate": {
1974 "passed": false,
1975 "blockingMetrics": [{
1976 "evaluatorId": "no-secret-leak",
1977 "class": "guardrail",
1978 "reason": "guardrail_failed",
1979 "decision": "block_release",
1980 "observedRate": 0.99,
1981 "delta": -0.01,
1982 "detail": "1 guardrail failures out of 100 observations"
1983 }],
1984 "warnings": []
1985 }
1986 })))
1987 .expect(1)
1988 .mount(&server)
1989 .await;
1990
1991 let server_uri = server.uri();
1992 let error = Cli::try_parse_from([
1993 "infra",
1994 "--gateway",
1995 server_uri.as_str(),
1996 "--token",
1997 "test-token",
1998 "evaluate",
1999 "diff",
2000 "--report-id",
2001 "report-b",
2002 "--baseline-report-id",
2003 "report-a",
2004 ])
2005 .unwrap()
2006 .execute()
2007 .await
2008 .unwrap_err()
2009 .to_string();
2010 assert!(
2011 error.contains("no-secret-leak"),
2012 "unexpected error: {error}"
2013 );
2014 assert!(error.contains("guardrail"), "unexpected error: {error}");
2015 }
2016
2017 #[test]
2018 fn reads_json_request_from_file() {
2019 let directory = tempfile::tempdir().unwrap();
2020 let path = directory.path().join("request.json");
2021 fs::write(&path, br#"{"model":"test"}"#).unwrap();
2022 let input = JsonInput { file: path };
2023 let value: Value = input.read().unwrap();
2024 assert_eq!(value["model"], "test");
2025 }
2026
2027 #[tokio::test]
2028 async fn agent_create_sends_name_and_runtime_id_without_idempotency_key() {
2029 let server = MockServer::start().await;
2030 Mock::given(method("POST"))
2031 .and(path("/v1/agents"))
2032 .and(header("authorization", "Bearer test-token"))
2033 .and(body_json(serde_json::json!({
2034 "name": "weather",
2035 "description": "demo",
2036 "relations": { "runtimeIds": ["rti_1"] },
2037 "idempotencyKey": ""
2038 })))
2039 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
2040 "id": "agt_weather",
2041 "ownerUserId": "user-1",
2042 "name": "weather",
2043 "description": "demo",
2044 "status": "active",
2045 "revision": 1,
2046 "relations": { "runtimeIds": ["rti_1"], "userIds": ["user-1"] },
2047 "createdAt": "2026-08-17T00:00:00Z",
2048 "updatedAt": "2026-08-17T00:00:00Z"
2049 })))
2050 .expect(1)
2051 .mount(&server)
2052 .await;
2053
2054 let cli = Cli::try_parse_from([
2055 "infra",
2056 "--gateway",
2057 server.uri().as_str(),
2058 "--token",
2059 "test-token",
2060 "agent",
2061 "create",
2062 "--name",
2063 "weather",
2064 "--runtime-id",
2065 "rti_1",
2066 "--description",
2067 "demo",
2068 ])
2069 .unwrap();
2070 let CommandOutput::Json(output) = cli.execute().await.unwrap() else {
2071 panic!("agent create must produce JSON");
2072 };
2073 assert_eq!(output["id"], "agt_weather");
2074 assert_eq!(output["name"], "weather");
2075 }
2076
2077 #[tokio::test]
2078 async fn deploy_repo_init_writes_env_local_without_clone() {
2079 let root = tempfile::tempdir().unwrap();
2080 let request_path = root.path().join("repo.json");
2081 fs::write(
2082 &request_path,
2083 r#"{"templateId":"rtpl-1","projectName":"demo"}"#,
2084 )
2085 .unwrap();
2086 let checkout = root.path().join("demo-runtime");
2087 let server = MockServer::start().await;
2088 Mock::given(method("POST"))
2089 .and(path("/internal/v1/deploy/runtime-repositories"))
2090 .and(header("Idempotency-Key", "init-1"))
2091 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
2092 "id": "rrepo-1",
2093 "tenantId": "local",
2094 "projectId": "default",
2095 "templateId": "rtpl-1",
2096 "owners": [
2097 {"kind":"agent","id":"agt_1"},
2098 {"kind":"runtime","id":"rt-1"}
2099 ],
2100 "namespace": "runtimes",
2101 "gitlabProjectId": 1,
2102 "pathWithNamespace": "runtimes/demo-runtime",
2103 "repositoryUrl": "https://git.test/runtimes/demo-runtime.git",
2104 "defaultBranch": "master",
2105 "status": "ready",
2106 "version": 1,
2107 "createdAt": "t",
2108 "updatedAt": "t"
2109 })))
2110 .expect(1)
2111 .mount(&server)
2112 .await;
2113
2114 let cli = Cli::try_parse_from([
2115 "infra",
2116 "--gateway",
2117 server.uri().as_str(),
2118 "--token",
2119 "test-token",
2120 "deploy",
2121 "repo",
2122 "init",
2123 "--file",
2124 request_path.to_str().unwrap(),
2125 "--idempotency-key",
2126 "init-1",
2127 "--dir",
2128 checkout.to_str().unwrap(),
2129 "--no-clone",
2130 "--agent-id",
2131 "agt_1",
2132 "--runtime-id",
2133 "rt-1",
2134 ])
2135 .unwrap();
2136 let CommandOutput::Json(output) = cli.execute().await.unwrap() else {
2137 panic!("init must produce JSON");
2138 };
2139 assert_eq!(output["id"], "rrepo-1");
2140 let env = fs::read_to_string(checkout.join(".env.local")).unwrap();
2141 assert!(env.contains("RREPO_ID=rrepo-1"));
2142 assert!(env.contains("AGENT_ID=agt_1"));
2143 assert!(env.contains("RUNTIME_ID=rt-1"));
2144 assert!(
2145 fs::read_to_string(checkout.join(".gitignore"))
2146 .unwrap()
2147 .contains(".env.local")
2148 );
2149 }
2150
2151 #[tokio::test]
2152 async fn init_orchestrates_runtime_agent_repo_trace_context_and_model() {
2153 let root = tempfile::tempdir().unwrap();
2154 let checkout = root.path().join("e2e-init");
2155 let server = MockServer::start().await;
2156 Mock::given(method("POST"))
2157 .and(path("/internal/v1/runtime-identities"))
2158 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
2159 "identity": runtime_identity_json("e2e_init", "pending", 1),
2160 "bootstrapToken": "rti_boot_secret"
2161 })))
2162 .expect(1)
2163 .mount(&server)
2164 .await;
2165 Mock::given(method("POST"))
2166 .and(path("/v1/agents"))
2167 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
2168 "id": "agt_e2e",
2169 "ownerUserId": "user-1",
2170 "name": "e2e_init",
2171 "description": "initialized by infra init (e2e_init)",
2172 "status": "active",
2173 "revision": 1,
2174 "relations": { "runtimeIds": ["rti_test"] },
2175 "createdAt": "2026-08-17T00:00:00Z",
2176 "updatedAt": "2026-08-17T00:00:00Z"
2177 })))
2178 .expect(1)
2179 .mount(&server)
2180 .await;
2181 Mock::given(method("POST"))
2182 .and(path("/internal/v1/deploy/runtime-repositories"))
2183 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
2184 "id": "rrepo-e2e",
2185 "tenantId": "local",
2186 "projectId": "default",
2187 "templateId": "rtpl-1",
2188 "owners": [],
2189 "namespace": "runtimes",
2190 "gitlabProjectId": 1,
2191 "pathWithNamespace": "runtimes/e2e-init",
2192 "repositoryUrl": "https://git.test/runtimes/e2e-init.git",
2193 "defaultBranch": "master",
2194 "status": "ready",
2195 "version": 1,
2196 "createdAt": "t",
2197 "updatedAt": "t"
2198 })))
2199 .expect(1)
2200 .mount(&server)
2201 .await;
2202 Mock::given(method("POST"))
2203 .and(path("/v1/trace/runtime-bindings/rti_test/initialize"))
2204 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2205 "runtimeId": "rti_test",
2206 "projectName": "e2e_init",
2207 "langfuseProjectId": "lf-e2e",
2208 "publicKey": "pk-lf-e2e",
2209 "status": "ready"
2210 })))
2211 .expect(1)
2212 .mount(&server)
2213 .await;
2214 Mock::given(method("POST"))
2215 .and(path("/internal/v1/context/messages/batch"))
2216 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2217 "appended": 1,
2218 "skipped": 0
2219 })))
2220 .expect(1)
2221 .mount(&server)
2222 .await;
2223 Mock::given(method("POST"))
2224 .and(path("/internal/v1/context/messages/recent"))
2225 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2226 "items": [{
2227 "id": "msg_init_e2e_init",
2228 "scope": {"conversationId": "conv_init_e2e_init"},
2229 "role": "system",
2230 "content": {"text": "initialized by infra init"},
2231 "sequence": 1,
2232 "createdAtMs": 1,
2233 "metadata": {"source": "infra-init"}
2234 }]
2235 })))
2236 .expect(1)
2237 .mount(&server)
2238 .await;
2239 Mock::given(method("POST"))
2240 .and(path("/v1/agents/agt_e2e/model-configs:ensure-default"))
2241 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2242 "id": "default",
2243 "displayName": "Platform Default",
2244 "enabled": true
2245 })))
2246 .expect(1)
2247 .mount(&server)
2248 .await;
2249
2250 let cli = Cli::try_parse_from([
2251 "infra",
2252 "--gateway",
2253 server.uri().as_str(),
2254 "--token",
2255 "test-token",
2256 "init",
2257 "--name",
2258 "e2e-init",
2259 "--template-id",
2260 "rtpl-1",
2261 "--dir",
2262 checkout.to_str().unwrap(),
2263 "--no-clone",
2264 ])
2265 .unwrap();
2266 let CommandOutput::Json(output) = cli.execute().await.unwrap() else {
2267 panic!("infra init must produce JSON");
2268 };
2269 assert_eq!(output["runtimeId"], "rti_test");
2270 assert_eq!(output["agentId"], "agt_e2e");
2271 assert_eq!(output["rrepoId"], "rrepo-e2e");
2272 assert_eq!(output["langfuseProjectId"], "lf-e2e");
2273 assert_eq!(output["conversationId"], "conv_init_e2e_init");
2274 assert_eq!(output["messageId"], "msg_init_e2e_init");
2275 assert_eq!(output["modelConfigId"], "default");
2276 }
2277
2278 #[tokio::test]
2279 async fn persists_and_resolves_gateway_without_network_access() {
2280 let directory = tempfile::tempdir().unwrap();
2281 let config = directory.path().join("infra.json");
2282 let cli = Cli::try_parse_from([
2283 "infra",
2284 "config",
2285 "gateway",
2286 "set",
2287 "https://infra.example.com/",
2288 "--config",
2289 config.to_str().unwrap(),
2290 ])
2291 .unwrap();
2292 let output = cli.execute().await.unwrap();
2293 let CommandOutput::Json(value) = output else {
2294 panic!("config set must produce JSON");
2295 };
2296 assert_eq!(value["gateway"], "https://infra.example.com");
2297
2298 let store = ConfigStore::discover(Some(&config)).unwrap();
2299 assert_eq!(
2300 store.load().unwrap().gateway.as_deref(),
2301 Some("https://infra.example.com")
2302 );
2303 }
2304
2305 #[tokio::test]
2306 async fn trace_metrics_command_calls_typed_sdk() {
2307 let server = MockServer::start().await;
2308 Mock::given(method("POST"))
2309 .and(path(
2310 agent_infra_sdk::trace_contract::METRICS_PROJECT_SUMMARY_PATH,
2311 ))
2312 .and(body_json(serde_json::json!({
2313 "projectIds": ["project-a"],
2314 "days": 14,
2315 "dataSource": "auto",
2316 "v4CutoverDate": "2026-09-01 00:00:00"
2317 })))
2318 .respond_with(
2319 ResponseTemplate::new(200).set_body_json(serde_json::json!([{
2320 "projectId": "project-a",
2321 "totalTokens": 15,
2322 "totalInputTokens": 10,
2323 "totalOutputTokens": 5,
2324 "observationCount": 2,
2325 "generationCount": 1,
2326 "totalCost": 0.01,
2327 "uniqueModels": 1,
2328 "uniqueTraces": 1
2329 }])),
2330 )
2331 .expect(1)
2332 .mount(&server)
2333 .await;
2334
2335 let server_uri = server.uri();
2336 let cli = Cli::try_parse_from([
2337 "infra",
2338 "--gateway",
2339 server_uri.as_str(),
2340 "trace",
2341 "metrics",
2342 "project-summary",
2343 "--project-id",
2344 "project-a",
2345 "--days",
2346 "14",
2347 ])
2348 .unwrap();
2349 let CommandOutput::Json(output) = cli.execute().await.unwrap() else {
2350 panic!("trace metrics must produce JSON");
2351 };
2352 assert_eq!(output[0]["totalTokens"], 15);
2353 }
2354
2355 #[tokio::test]
2356 async fn auth_status_detects_encrypted_sky_login_without_exposing_it() {
2357 let directory = tempfile::tempdir().unwrap();
2358 let sky_home = directory.path().join("sky");
2359 fs::create_dir_all(&sky_home).unwrap();
2360 fs::write(sky_home.join("credentials"), "ENCv1:secret-ciphertext").unwrap();
2361 let config = directory.path().join("infra.json");
2362 let cli = Cli::try_parse_from([
2363 "infra",
2364 "auth",
2365 "status",
2366 "--config",
2367 config.to_str().unwrap(),
2368 "--sky-home",
2369 sky_home.to_str().unwrap(),
2370 ])
2371 .unwrap();
2372
2373 let output = cli.execute().await.unwrap();
2374 let rendered = output.render(OutputFormat::Json).unwrap();
2375 assert!(rendered.contains("sky_login_encrypted"));
2376 assert!(!rendered.contains("secret-ciphertext"));
2377 }
2378
2379 #[test]
2380 fn renders_compact_and_pretty_json() {
2381 let output = CommandOutput::Json(serde_json::json!({ "ok": true }));
2382 assert_eq!(output.render(OutputFormat::Json).unwrap(), r#"{"ok":true}"#);
2383 assert!(output.render(OutputFormat::Pretty).unwrap().contains('\n'));
2384 }
2385
2386 #[tokio::test]
2387 async fn model_command_uses_sdk_route_and_bearer_credential() {
2388 let server = MockServer::start().await;
2389 Mock::given(method("GET"))
2390 .and(path("/v1/model/models"))
2391 .and(header("authorization", "Bearer test-token"))
2392 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2393 "object": "list",
2394 "data": [{ "id": "test-model" }]
2395 })))
2396 .expect(1)
2397 .mount(&server)
2398 .await;
2399
2400 let cli = Cli::try_parse_from([
2401 "infra",
2402 "model",
2403 "models",
2404 "--gateway",
2405 &server.uri(),
2406 "--token",
2407 "test-token",
2408 ])
2409 .unwrap();
2410 let output = cli.execute().await.unwrap();
2411 let CommandOutput::Json(value) = output else {
2412 panic!("model list must produce JSON");
2413 };
2414 assert_eq!(value["data"][0]["id"], "test-model");
2415 }
2416
2417 #[tokio::test]
2418 async fn runtime_commands_create_rename_and_revoke_through_the_typed_sdk() {
2419 let server = MockServer::start().await;
2420 let directory = tempfile::tempdir().unwrap();
2421 let transition_request = directory.path().join("transition-runtime.json");
2422 fs::write(
2423 &transition_request,
2424 br#"{"expectedRevision":2,"idempotencyKey":"revoke-runtime-test","reason":"test cleanup"}"#,
2425 )
2426 .unwrap();
2427
2428 Mock::given(method("POST"))
2429 .and(path("/internal/v1/runtime-identities"))
2430 .and(header("authorization", "Bearer test-token"))
2431 .and(body_json(serde_json::json!({
2432 "name": "code-dev",
2433 "kind": "code",
2434 "relations": {},
2435 "metadata": {},
2436 "idempotencyKey": ""
2437 })))
2438 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
2439 "identity": runtime_identity_json("code-dev", "pending", 1),
2440 "bootstrapToken": "rti_boot_secret"
2441 })))
2442 .expect(1)
2443 .mount(&server)
2444 .await;
2445 Mock::given(method("PATCH"))
2446 .and(path("/internal/v1/runtime-identities/rti_test"))
2447 .and(header("authorization", "Bearer test-token"))
2448 .and(body_json(serde_json::json!({
2449 "expectedRevision": 1,
2450 "name": "code-dev-renamed",
2451 "idempotencyKey": ""
2452 })))
2453 .respond_with(
2454 ResponseTemplate::new(200).set_body_json(runtime_identity_json(
2455 "code-dev-renamed",
2456 "pending",
2457 2,
2458 )),
2459 )
2460 .expect(1)
2461 .mount(&server)
2462 .await;
2463 Mock::given(method("POST"))
2464 .and(path("/internal/v1/runtime-identities/rti_test:revoke"))
2465 .and(header("authorization", "Bearer test-token"))
2466 .and(body_json(serde_json::json!({
2467 "expectedRevision": 2,
2468 "idempotencyKey": "revoke-runtime-test",
2469 "reason": "test cleanup"
2470 })))
2471 .respond_with(
2472 ResponseTemplate::new(200).set_body_json(runtime_identity_json(
2473 "code-dev-renamed",
2474 "revoked",
2475 3,
2476 )),
2477 )
2478 .expect(1)
2479 .mount(&server)
2480 .await;
2481
2482 let create = Cli::try_parse_from([
2483 "infra",
2484 "runtime",
2485 "create",
2486 "--name",
2487 "code-dev",
2488 "--gateway",
2489 &server.uri(),
2490 "--token",
2491 "test-token",
2492 ])
2493 .unwrap()
2494 .execute()
2495 .await
2496 .unwrap();
2497 let CommandOutput::Json(created) = create else {
2498 panic!("runtime create must produce JSON");
2499 };
2500 assert_eq!(created["identity"]["id"], "rti_test");
2501 assert_eq!(created["identity"]["name"], "code-dev");
2502 assert_eq!(created["bootstrapToken"], "rti_boot_secret");
2503
2504 let rename = Cli::try_parse_from([
2505 "infra",
2506 "runtime",
2507 "rename",
2508 "rti_test",
2509 "code-dev-renamed",
2510 "--expected-revision",
2511 "1",
2512 "--gateway",
2513 &server.uri(),
2514 "--token",
2515 "test-token",
2516 ])
2517 .unwrap()
2518 .execute()
2519 .await
2520 .unwrap();
2521 let CommandOutput::Json(renamed) = rename else {
2522 panic!("runtime rename must produce JSON");
2523 };
2524 assert_eq!(renamed["id"], "rti_test");
2525 assert_eq!(renamed["name"], "code-dev-renamed");
2526 assert_eq!(renamed["revision"], 2);
2527
2528 let revoke = Cli::try_parse_from([
2529 "infra",
2530 "runtime",
2531 "revoke",
2532 "rti_test",
2533 "--file",
2534 transition_request.to_str().unwrap(),
2535 "--gateway",
2536 &server.uri(),
2537 "--token",
2538 "test-token",
2539 ])
2540 .unwrap()
2541 .execute()
2542 .await
2543 .unwrap();
2544 let CommandOutput::Json(revoked) = revoke else {
2545 panic!("runtime revoke must produce JSON");
2546 };
2547 assert_eq!(revoked["status"], "revoked");
2548 assert_eq!(revoked["revision"], 3);
2549 }
2550
2551 #[tokio::test]
2552 async fn runtime_commands_replace_permissions_and_bootstrap_credential() {
2553 let server = MockServer::start().await;
2554 let directory = tempfile::tempdir().unwrap();
2555 let permissions_request = directory.path().join("permissions.json");
2556 fs::write(
2557 &permissions_request,
2558 br#"{"expectedRevision":0,"idempotencyKey":"grant-runtime-test","grants":[{"audience":"agent-infra","capabilities":["gateway:delegation:manage"]}]}"#,
2559 )
2560 .unwrap();
2561 let credential_request = directory.path().join("credential.json");
2562 fs::write(
2563 &credential_request,
2564 br#"{"workloadProof":"bootstrap-proof","audience":"agent-infra","capabilities":["gateway:delegation:manage"]}"#,
2565 )
2566 .unwrap();
2567
2568 Mock::given(method("PUT"))
2569 .and(path("/internal/v1/runtime-identities/rti_test/permissions"))
2570 .and(header("authorization", "Bearer test-token"))
2571 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2572 "identityId": "rti_test",
2573 "revision": 1,
2574 "grants": [{
2575 "id": "grt_test",
2576 "identityId": "rti_test",
2577 "audience": "agent-infra",
2578 "capabilities": ["gateway:delegation:manage"],
2579 "createdAt": "2026-08-13T00:00:00Z"
2580 }]
2581 })))
2582 .expect(1)
2583 .mount(&server)
2584 .await;
2585 Mock::given(method("POST"))
2586 .and(path(
2587 agent_infra_sdk::gateway_contract::RUNTIME_CREDENTIAL_BOOTSTRAP_PATH,
2588 ))
2589 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2590 "accessToken": "runtime-token",
2591 "tokenType": "Bearer",
2592 "expiresInSeconds": 120,
2593 "keyId": "runtime-key"
2594 })))
2595 .expect(1)
2596 .mount(&server)
2597 .await;
2598
2599 let permissions = Cli::try_parse_from([
2600 "infra",
2601 "runtime",
2602 "permissions",
2603 "rti_test",
2604 "--file",
2605 permissions_request.to_str().unwrap(),
2606 "--gateway",
2607 &server.uri(),
2608 "--token",
2609 "test-token",
2610 ])
2611 .unwrap()
2612 .execute()
2613 .await
2614 .unwrap();
2615 let CommandOutput::Json(permissions) = permissions else {
2616 panic!("runtime permissions must produce JSON");
2617 };
2618 assert_eq!(permissions["revision"], 1);
2619
2620 let credential = Cli::try_parse_from([
2621 "infra",
2622 "runtime",
2623 "bootstrap-credential",
2624 "--file",
2625 credential_request.to_str().unwrap(),
2626 "--gateway",
2627 &server.uri(),
2628 "--token",
2629 "test-token",
2630 ])
2631 .unwrap()
2632 .execute()
2633 .await
2634 .unwrap();
2635 let CommandOutput::Json(credential) = credential else {
2636 panic!("runtime bootstrap credential must produce JSON");
2637 };
2638 assert_eq!(credential["accessToken"], "runtime-token");
2639 let requests = server.received_requests().await.unwrap();
2640 let bootstrap = requests
2641 .iter()
2642 .find(|request| {
2643 request.url.path()
2644 == agent_infra_sdk::gateway_contract::RUNTIME_CREDENTIAL_BOOTSTRAP_PATH
2645 })
2646 .unwrap();
2647 assert!(!bootstrap.headers.contains_key("authorization"));
2648 }
2649
2650 fn runtime_identity_json(name: &str, status: &str, revision: u64) -> Value {
2651 serde_json::json!({
2652 "id": "rti_test",
2653 "name": name,
2654 "scope": { "tenantId": "tenant-test", "projectId": "project-test" },
2655 "kind": "code",
2656 "relations": {
2657 "runtimeIds": ["rti_test"],
2658 "userIds": ["user-test"]
2659 },
2660 "status": status,
2661 "revision": revision,
2662 "grantRevision": 0,
2663 "credentialGeneration": if status == "revoked" { 2 } else { 1 },
2664 "metadata": { "source": "cli-test" },
2665 "createdAt": "2026-08-13T00:00:00Z",
2666 "updatedAt": "2026-08-13T00:00:00Z"
2667 })
2668 }
2669
2670 #[tokio::test]
2671 async fn computer_list_uses_environment_api() {
2672 let server = MockServer::start().await;
2673 Mock::given(method("GET"))
2674 .and(path("/internal/v1/workspace/computers"))
2675 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
2676 .expect(1)
2677 .mount(&server)
2678 .await;
2679 let cli =
2680 Cli::try_parse_from(["infra", "computer", "list", "--gateway", &server.uri()]).unwrap();
2681
2682 let output = cli.execute().await.unwrap();
2683 let CommandOutput::Json(value) = output else {
2684 panic!("computer list must produce JSON");
2685 };
2686 assert_eq!(value, serde_json::json!([]));
2687 }
2688}