Skip to main content

max / makenotwork

4.7 KB · 129 lines History Blame Raw
1 //! End-to-end: a real `ops-agent` HTTP server (with a stubbed `whois`) driven
2 //! by the real `AgentRpc` client over a loopback socket. Proves the E2 path —
3 //! identity → authorization → in-session exec → streamed frames — without a
4 //! live tailnet.
5 #![cfg(feature = "agent")]
6
7 use ops_exec::agent::{AgentConfig, AgentState, CallerGrant, CallerIdentity, GrantConfig, router};
8 use ops_exec::{Action, AgentRpc, CapabilityDenied, CapabilitySet, Executor, LogSink, Step};
9 use std::net::SocketAddr;
10 use std::sync::Arc;
11
12 #[derive(Default)]
13 struct VecSink(Vec<u8>);
14 #[async_trait::async_trait]
15 impl LogSink for VecSink {
16 async fn write_chunk(&mut self, bytes: &[u8]) {
17 self.0.extend_from_slice(bytes);
18 }
19 }
20
21 /// Spin the agent on an ephemeral loopback port; whois always says the caller
22 /// is `fw13`. Returns the base URL.
23 async fn spawn_agent(allow: Vec<CallerGrant>, grant: GrantConfig) -> String {
24 let config = AgentConfig { listen: "127.0.0.1:0".parse().unwrap(), grant, allow };
25 let state = AgentState {
26 config: Arc::new(config),
27 whois: Arc::new(|_ip| {
28 Box::pin(async { Ok(CallerIdentity { node: "fw13".into(), tags: vec![] }) })
29 }),
30 };
31 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
32 let addr = listener.local_addr().unwrap();
33 tokio::spawn(async move {
34 axum::serve(listener, router(state).into_make_service_with_connect_info::<SocketAddr>())
35 .await
36 .unwrap();
37 });
38 format!("http://{addr}")
39 }
40
41 fn builder_grant() -> GrantConfig {
42 GrantConfig {
43 actuate: vec!["build".into(), "sign".into(), "notarize".into(), "staple".into()],
44 observe: vec![],
45 }
46 }
47
48 #[tokio::test]
49 async fn agent_runs_a_granted_step_and_streams_output() {
50 let base = spawn_agent(
51 vec![CallerGrant {
52 identity: "fw13".into(),
53 actuate: vec!["build".into(), "sign".into()],
54 observe: vec![],
55 }],
56 builder_grant(),
57 )
58 .await;
59
60 // The driver's caller-side caps also include sign.
61 let rpc = AgentRpc::new(base, "mbp", CapabilitySet::from_tokens(["build", "sign"], Vec::<&str>::new()));
62 let health = rpc.health().await.unwrap();
63 assert!(health.ok);
64 assert!(health.actuate.contains(&"sign".to_string()));
65
66 let mut sink = VecSink::default();
67 let step = Step::shell(Action::Sign, "printf 'signed-ok'");
68 let out = rpc.run_streaming(&step, &mut sink).await.unwrap();
69 assert!(out.success());
70 assert_eq!(sink.0, b"signed-ok");
71 assert_eq!(out.stdout, b"signed-ok");
72 }
73
74 #[tokio::test]
75 async fn agent_denies_action_outside_its_grant() {
76 // The agent host grants build/sign only; the caller asks to deploy. Even
77 // though the client-side caps below include deploy, the agent must refuse.
78 let base = spawn_agent(
79 vec![CallerGrant {
80 identity: "fw13".into(),
81 actuate: vec!["build".into(), "sign".into(), "deploy".into()],
82 observe: vec![],
83 }],
84 builder_grant(),
85 )
86 .await;
87
88 let rpc = AgentRpc::new(base, "mbp", CapabilitySet::from_tokens(["deploy"], Vec::<&str>::new()));
89 let mut sink = VecSink::default();
90 let step = Step::shell(Action::Deploy, "echo should-not-run");
91 let err = rpc.run_streaming(&step, &mut sink).await.unwrap_err();
92 let msg = format!("{err:#}");
93 assert!(msg.contains("denied"), "expected agent denial, got: {msg}");
94 }
95
96 #[tokio::test]
97 async fn caller_side_gate_rejects_before_round_trip() {
98 // The client's own caps omit `sign`, so AgentRpc must refuse before any
99 // HTTP call (CapabilityDenied), independent of the agent.
100 let rpc = AgentRpc::new(
101 "http://127.0.0.1:1", // unreachable; must never be dialed
102 "mbp",
103 CapabilitySet::from_tokens(["build"], Vec::<&str>::new()),
104 );
105 let mut sink = VecSink::default();
106 let err = rpc
107 .run_streaming(&Step::shell(Action::Sign, "true"), &mut sink)
108 .await
109 .unwrap_err();
110 assert!(err.downcast_ref::<CapabilityDenied>().is_some(), "expected caller-side CapabilityDenied");
111 }
112
113 #[tokio::test]
114 async fn agent_pull_serves_a_file() {
115 let dir = tempfile::tempdir().unwrap();
116 let artifact = dir.path().join("GoingsOn.dmg");
117 tokio::fs::write(&artifact, b"DMGBYTES").await.unwrap();
118
119 let base = spawn_agent(
120 vec![CallerGrant { identity: "fw13".into(), actuate: vec![], observe: vec![] }],
121 builder_grant(),
122 )
123 .await;
124 let rpc = AgentRpc::new(base, "mbp", CapabilitySet::default());
125 let local = dir.path().join("pulled.dmg");
126 rpc.pull(&artifact, &local, &Default::default()).await.unwrap();
127 assert_eq!(tokio::fs::read(&local).await.unwrap(), b"DMGBYTES");
128 }
129