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