Skip to main content

max / makenotwork

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