| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 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 |
|
| 22 |
|
| 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 |
|
| 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 |
|
| 77 |
|
| 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 |
|
| 99 |
|
| 100 |
let rpc = AgentRpc::new( |
| 101 |
"http://127.0.0.1:1", |
| 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 |
|