Skip to main content

max / makenotwork

13.8 KB · 429 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::{
9 Action, AgentRpc, CapabilityDenied, CapabilitySet, Executor, LogSink, Step, SyncOpts,
10 };
11 use std::net::SocketAddr;
12 use std::path::PathBuf;
13 use std::sync::Arc;
14
15 #[derive(Default)]
16 struct VecSink(Vec<u8>);
17 #[async_trait::async_trait]
18 impl LogSink for VecSink {
19 async fn write_chunk(&mut self, bytes: &[u8]) {
20 self.0.extend_from_slice(bytes);
21 }
22 }
23
24 /// Spin the agent on an ephemeral loopback port; whois always says the caller
25 /// is `fw13`. Returns the base URL.
26 async fn spawn_agent(allow: Vec<CallerGrant>, grant: GrantConfig) -> String {
27 spawn_agent_with_pull(allow, grant, None).await
28 }
29
30 /// A build legitimately runs for minutes, so `/run` must have no whole-request
31 /// timeout; only `/health` does. `reqwest`'s request timeout runs until the
32 /// response *body* has finished, so a client-level timeout severs a real build
33 /// mid-flight and looks exactly like a build failure. Sleeps past
34 /// `HEALTH_TIMEOUT` (10s) to prove the cap is not applied here. Slow on
35 /// purpose: it is the only thing standing between a "just set a client timeout"
36 /// change and a severed release.
37 #[tokio::test]
38 async fn a_step_outliving_the_health_timeout_is_not_severed() {
39 let base = spawn_agent(
40 vec![CallerGrant {
41 identity: "fw13".into(),
42 actuate: vec!["build".into()],
43 observe: vec![],
44 }],
45 builder_grant(),
46 )
47 .await;
48 let rpc = AgentRpc::new(
49 base,
50 "mbp",
51 CapabilitySet::from_tokens(["build"], Vec::<&str>::new()),
52 );
53
54 let mut sink = VecSink::default();
55 let step = Step::shell(Action::Build, "sleep 12; printf 'built'");
56 let out = rpc
57 .run_streaming(&step, &mut sink)
58 .await
59 .expect("a 12s build must not be cut off");
60 assert!(out.success());
61 assert_eq!(out.stdout, b"built");
62 }
63
64 /// As [`spawn_agent`], but with a configurable `pull_root` for the file-read path.
65 async fn spawn_agent_with_pull(
66 allow: Vec<CallerGrant>,
67 grant: GrantConfig,
68 pull_root: Option<PathBuf>,
69 ) -> String {
70 let config = AgentConfig {
71 listen: "127.0.0.1:0".parse().unwrap(),
72 grant,
73 allow,
74 pull_root,
75 pin: Vec::new(),
76 };
77 let state = AgentState {
78 config: Arc::new(config),
79 whois: Arc::new(|_ip| {
80 Box::pin(async {
81 Ok(CallerIdentity {
82 node: "fw13".into(),
83 tags: vec![],
84 })
85 })
86 }),
87 };
88 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
89 let addr = listener.local_addr().unwrap();
90 tokio::spawn(async move {
91 axum::serve(
92 listener,
93 router(state).into_make_service_with_connect_info::<SocketAddr>(),
94 )
95 .await
96 .unwrap();
97 });
98 format!("http://{addr}")
99 }
100
101 fn builder_grant() -> GrantConfig {
102 GrantConfig {
103 actuate: vec![
104 "build".into(),
105 "sign".into(),
106 "notarize".into(),
107 "staple".into(),
108 ],
109 observe: vec![],
110 }
111 }
112
113 #[tokio::test]
114 async fn agent_runs_a_granted_step_and_streams_output() {
115 let base = spawn_agent(
116 vec![CallerGrant {
117 identity: "fw13".into(),
118 actuate: vec!["build".into(), "sign".into()],
119 observe: vec![],
120 }],
121 builder_grant(),
122 )
123 .await;
124
125 // The driver's caller-side caps also include sign.
126 let rpc = AgentRpc::new(
127 base,
128 "mbp",
129 CapabilitySet::from_tokens(["build", "sign"], Vec::<&str>::new()),
130 );
131 let health = rpc.health().await.unwrap();
132 assert!(health.ok);
133 assert!(health.actuate.contains(&"sign".to_string()));
134
135 let mut sink = VecSink::default();
136 let step = Step::shell(Action::Sign, "printf 'signed-ok'");
137 let out = rpc.run_streaming(&step, &mut sink).await.unwrap();
138 assert!(out.success());
139 assert_eq!(sink.0, b"signed-ok");
140 assert_eq!(out.stdout, b"signed-ok");
141 }
142
143 #[tokio::test]
144 async fn preflight_accepts_a_matching_protocol_version() {
145 let base = spawn_agent(
146 vec![CallerGrant {
147 identity: "fw13".into(),
148 actuate: vec!["build".into()],
149 observe: vec![],
150 }],
151 builder_grant(),
152 )
153 .await;
154 let rpc = AgentRpc::new(
155 base,
156 "mbp",
157 CapabilitySet::from_tokens(["build"], Vec::<&str>::new()),
158 );
159 // The agent advertises PROTOCOL_VERSION; preflight must accept it.
160 let health = rpc.health().await.unwrap();
161 assert_eq!(health.version, ops_exec::wire::PROTOCOL_VERSION);
162 rpc.preflight()
163 .await
164 .expect("preflight accepts a same-version agent");
165 }
166
167 #[tokio::test]
168 async fn agent_denies_action_outside_its_grant() {
169 // The agent host grants build/sign only; the caller asks to deploy. Even
170 // though the client-side caps below include deploy, the agent must refuse.
171 let base = spawn_agent(
172 vec![CallerGrant {
173 identity: "fw13".into(),
174 actuate: vec!["build".into(), "sign".into(), "deploy".into()],
175 observe: vec![],
176 }],
177 builder_grant(),
178 )
179 .await;
180
181 let rpc = AgentRpc::new(
182 base,
183 "mbp",
184 CapabilitySet::from_tokens(["deploy"], Vec::<&str>::new()),
185 );
186 let mut sink = VecSink::default();
187 let step = Step::shell(Action::Deploy, "echo should-not-run");
188 let err = rpc.run_streaming(&step, &mut sink).await.unwrap_err();
189 let msg = format!("{err:#}");
190 assert!(msg.contains("denied"), "expected agent denial, got: {msg}");
191 }
192
193 #[tokio::test]
194 async fn caller_side_gate_rejects_before_round_trip() {
195 // The client's own caps omit `sign`, so AgentRpc must refuse before any
196 // HTTP call (CapabilityDenied), independent of the agent.
197 let rpc = AgentRpc::new(
198 "http://127.0.0.1:1", // unreachable; must never be dialed
199 "mbp",
200 CapabilitySet::from_tokens(["build"], Vec::<&str>::new()),
201 );
202 let mut sink = VecSink::default();
203 let err = rpc
204 .run_streaming(&Step::shell(Action::Sign, "true"), &mut sink)
205 .await
206 .unwrap_err();
207 assert!(
208 err.downcast_ref::<CapabilityDenied>().is_some(),
209 "expected caller-side CapabilityDenied"
210 );
211 }
212
213 /// The caller-side grant a driver needs to pull an artifact.
214 fn puller() -> CapabilitySet {
215 CapabilitySet::from_tokens(Vec::<&str>::new(), ["artifact"])
216 }
217
218 /// An allow-listed caller with an `artifact` observe grant can pull a file that
219 /// lives under the configured `pull_root`.
220 #[tokio::test]
221 async fn agent_pull_serves_an_in_root_file() {
222 let dir = tempfile::tempdir().unwrap();
223 let root = dir.path().join("dist");
224 tokio::fs::create_dir_all(&root).await.unwrap();
225 let artifact = root.join("GoingsOn.dmg");
226 tokio::fs::write(&artifact, b"DMGBYTES").await.unwrap();
227
228 let grant = GrantConfig {
229 actuate: vec!["build".into()],
230 observe: vec!["artifact".into()],
231 };
232 let base = spawn_agent_with_pull(
233 vec![CallerGrant {
234 identity: "fw13".into(),
235 actuate: vec![],
236 observe: vec!["artifact".into()],
237 }],
238 grant,
239 Some(root.clone()),
240 )
241 .await;
242 let rpc = AgentRpc::new(base, "mbp", puller());
243 let local = dir.path().join("pulled.dmg");
244 rpc.pull_file(&artifact, &local, &SyncOpts::default())
245 .await
246 .unwrap();
247 assert_eq!(tokio::fs::read(&local).await.unwrap(), b"DMGBYTES");
248 }
249
250 /// `artifact` and `build-log` are independent grants: a caller allowed to read
251 /// build logs is NOT thereby allowed to retrieve artifacts. This is the split
252 /// the old `build-log`-gated /pull could not express.
253 #[tokio::test]
254 async fn agent_pull_denied_with_only_build_log_observe() {
255 let dir = tempfile::tempdir().unwrap();
256 let root = dir.path().join("dist");
257 tokio::fs::create_dir_all(&root).await.unwrap();
258 let artifact = root.join("a.bin");
259 tokio::fs::write(&artifact, b"x").await.unwrap();
260
261 let grant = GrantConfig {
262 actuate: vec![],
263 observe: vec!["build-log".into(), "artifact".into()],
264 };
265 let base = spawn_agent_with_pull(
266 // The caller may read logs, but was never granted artifacts.
267 vec![CallerGrant {
268 identity: "fw13".into(),
269 actuate: vec![],
270 observe: vec!["build-log".into()],
271 }],
272 grant,
273 Some(root.clone()),
274 )
275 .await;
276 // Client-side grant is deliberately wide so the *agent* is what refuses.
277 let rpc = AgentRpc::new(base, "mbp", puller());
278 let local = dir.path().join("out.bin");
279 let err = rpc
280 .pull_file(&artifact, &local, &SyncOpts::default())
281 .await
282 .unwrap_err();
283 assert!(
284 err.to_string().contains("artifact"),
285 "denial names the grant: {err}"
286 );
287 assert!(!local.exists(), "denied pull must not write a file");
288 }
289
290 /// A caller without any observe grant is refused, even for an in-root file —
291 /// pull is gated, not open.
292 #[tokio::test]
293 async fn agent_pull_denied_without_observe_grant() {
294 let dir = tempfile::tempdir().unwrap();
295 let root = dir.path().join("dist");
296 tokio::fs::create_dir_all(&root).await.unwrap();
297 let artifact = root.join("a.bin");
298 tokio::fs::write(&artifact, b"x").await.unwrap();
299
300 let base = spawn_agent_with_pull(
301 vec![CallerGrant {
302 identity: "fw13".into(),
303 actuate: vec!["build".into()],
304 observe: vec![],
305 }],
306 builder_grant(),
307 Some(root.clone()),
308 )
309 .await;
310 let rpc = AgentRpc::new(base, "mbp", puller());
311 let local = dir.path().join("out.bin");
312 // The agent returns 403; AgentRpc surfaces the reason and the file never
313 // transfers.
314 assert!(
315 rpc.pull_file(&artifact, &local, &SyncOpts::default())
316 .await
317 .is_err()
318 );
319 assert!(!local.exists(), "denied pull must not write a file");
320 }
321
322 /// The caller-side half of double enforcement: a driver that never declared
323 /// `artifact` fails before any request leaves the process. This is the check
324 /// that saves a full build + notary round trip on a misconfigured driver.
325 #[tokio::test]
326 async fn agent_pull_denied_caller_side_without_declaring_artifact() {
327 let dir = tempfile::tempdir().unwrap();
328 let root = dir.path().join("dist");
329 tokio::fs::create_dir_all(&root).await.unwrap();
330 let artifact = root.join("a.bin");
331 tokio::fs::write(&artifact, b"x").await.unwrap();
332
333 let grant = GrantConfig {
334 actuate: vec![],
335 observe: vec!["artifact".into()],
336 };
337 let base = spawn_agent_with_pull(
338 vec![CallerGrant {
339 identity: "fw13".into(),
340 actuate: vec![],
341 observe: vec!["artifact".into()],
342 }],
343 grant,
344 Some(root.clone()),
345 )
346 .await;
347 // Agent would happily serve this; the caller's own set is what refuses.
348 let rpc = AgentRpc::new(
349 base,
350 "mbp",
351 CapabilitySet::from_tokens(["build"], ["build-log"]),
352 );
353 let local = dir.path().join("out.bin");
354 let err = rpc
355 .pull_file(&artifact, &local, &SyncOpts::default())
356 .await
357 .unwrap_err();
358 assert!(
359 err.to_string().contains("observe:artifact"),
360 "caller-side denial: {err}"
361 );
362 assert!(!local.exists(), "denied pull must not write a file");
363 }
364
365 /// A path outside `pull_root` is refused even for an authorized caller — the
366 /// confinement boundary holds against traversal.
367 #[tokio::test]
368 async fn agent_pull_denied_outside_root() {
369 let dir = tempfile::tempdir().unwrap();
370 let root = dir.path().join("dist");
371 tokio::fs::create_dir_all(&root).await.unwrap();
372 let secret = dir.path().join("secret.key");
373 tokio::fs::write(&secret, b"topsecret").await.unwrap();
374
375 let grant = GrantConfig {
376 actuate: vec![],
377 observe: vec!["artifact".into()],
378 };
379 let base = spawn_agent_with_pull(
380 vec![CallerGrant {
381 identity: "fw13".into(),
382 actuate: vec![],
383 observe: vec!["artifact".into()],
384 }],
385 grant,
386 Some(root.clone()),
387 )
388 .await;
389 let rpc = AgentRpc::new(base, "mbp", puller());
390 let local = dir.path().join("out.key");
391 let err = rpc
392 .pull_file(&secret, &local, &SyncOpts::default())
393 .await
394 .unwrap_err();
395 assert!(!local.exists(), "out-of-root pull must not write a file");
396 let _ = err; // status is non-2xx; the point is the secret never transfers
397 }
398
399 /// The agent serves exactly one file per `/pull`; there is no directory form.
400 /// `pull_dir` must refuse locally rather than invent one — this is the half of
401 /// the trait split that keeps a transport swap honest.
402 #[tokio::test]
403 async fn agent_pull_dir_is_refused_by_design() {
404 let dir = tempfile::tempdir().unwrap();
405 let root = dir.path().join("dist");
406 tokio::fs::create_dir_all(&root).await.unwrap();
407
408 let grant = GrantConfig {
409 actuate: vec![],
410 observe: vec!["artifact".into()],
411 };
412 let base = spawn_agent_with_pull(
413 vec![CallerGrant {
414 identity: "fw13".into(),
415 actuate: vec![],
416 observe: vec!["artifact".into()],
417 }],
418 grant,
419 Some(root.clone()),
420 )
421 .await;
422 let rpc = AgentRpc::new(base, "mbp", puller());
423 let err = rpc
424 .pull_dir(&root, &dir.path().join("out"), &SyncOpts::default())
425 .await
426 .expect_err("pull_dir must be refused, not attempted");
427 assert!(err.to_string().contains("unsupported by design"), "{err}");
428 }
429