Plan M4b: approvals over Mattermost in 15 tasks
Skeletons for new files only; existing files change by exact edits, so no task overwrites an earlier one's work. Each task's tests were run against a reference built in order from master, with the gate at each step and mutations checked; each skeleton compiles and fails its tests, and every todo!() comment was read beside its signature. The reference is kept off this machine. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
//! A fake `gatewayd` on a Unix socket: it accepts each courier's connection, reads the approval,
|
||||
//! and lets the test post it, answer it, close, or read the outcome. Do not edit.
|
||||
|
||||
#![allow(dead_code)] // each test file uses a different part of this module
|
||||
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
use std::time::Duration;
|
||||
|
||||
use proto::{
|
||||
ApprovalPosted, ApprovalReply, Envelope, ErrorCode, GatewayOutcome, Message, PROTOCOL_VERSION,
|
||||
PendingApproval, WireError, read_frame, write_frame,
|
||||
};
|
||||
|
||||
pub const KYLE: &str = "k0000000000000000000000000";
|
||||
pub const POST: &str = "c0000000000000000000000001";
|
||||
|
||||
pub struct FakeGateway {
|
||||
pub socket: PathBuf,
|
||||
conns: Receiver<Conn>,
|
||||
}
|
||||
|
||||
/// One courier's connection, its request already read.
|
||||
pub struct Conn {
|
||||
stream: UnixStream,
|
||||
pub request: PendingApproval,
|
||||
}
|
||||
|
||||
fn frame(id: u64, last: bool, msg: Message) -> Envelope {
|
||||
Envelope {
|
||||
v: PROTOCOL_VERSION,
|
||||
id,
|
||||
r#final: last,
|
||||
msg,
|
||||
}
|
||||
}
|
||||
|
||||
impl FakeGateway {
|
||||
/// Listen on `socket` (created now).
|
||||
pub fn start(socket: &Path) -> FakeGateway {
|
||||
let listener = UnixListener::bind(socket).unwrap();
|
||||
let (tx, conns) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
let Ok(mut stream) = stream else { continue };
|
||||
let Ok(env) = read_frame(&mut stream) else {
|
||||
continue;
|
||||
};
|
||||
let Message::ApprovalRequest(req) = env.msg else {
|
||||
panic!("not a request: {env:?}")
|
||||
};
|
||||
assert_eq!((env.id, env.r#final), (1, true));
|
||||
if tx
|
||||
.send(Conn {
|
||||
stream,
|
||||
request: req.approval,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
FakeGateway {
|
||||
socket: socket.to_path_buf(),
|
||||
conns,
|
||||
}
|
||||
}
|
||||
|
||||
/// The next courier connection within `wait`, or `None`.
|
||||
pub fn next(&self, wait: Duration) -> Option<Conn> {
|
||||
self.conns.recv_timeout(wait).ok()
|
||||
}
|
||||
}
|
||||
|
||||
impl Conn {
|
||||
pub fn posted(&mut self) {
|
||||
let posts = vec![POST.to_string()];
|
||||
write_frame(
|
||||
&mut self.stream,
|
||||
&frame(1, false, Message::ApprovalPosted(ApprovalPosted { posts })),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
pub fn cannot_post(&mut self) {
|
||||
let e = WireError {
|
||||
code: ErrorCode::Internal,
|
||||
detail: "Mattermost is down".to_string(),
|
||||
};
|
||||
write_frame(&mut self.stream, &frame(1, true, Message::Error(e))).unwrap();
|
||||
}
|
||||
|
||||
pub fn reply(&mut self, reply: ApprovalReply) {
|
||||
let _ = write_frame(
|
||||
&mut self.stream,
|
||||
&frame(1, true, Message::ApprovalReply(reply)),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn approve(&mut self) {
|
||||
let approval = self.request.approval;
|
||||
self.reply(ApprovalReply {
|
||||
approval,
|
||||
approved: true,
|
||||
by: KYLE.to_string(),
|
||||
post: POST.to_string(),
|
||||
reason: None,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn refuse(&mut self, reason: &str) {
|
||||
let approval = self.request.approval;
|
||||
self.reply(ApprovalReply {
|
||||
approval,
|
||||
approved: false,
|
||||
by: KYLE.to_string(),
|
||||
post: POST.to_string(),
|
||||
reason: Some(reason.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
/// Frame 4 within five seconds, or `None` if the courier closed without one.
|
||||
pub fn outcome(&mut self) -> Option<GatewayOutcome> {
|
||||
self.stream
|
||||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
match read_frame(&mut self.stream) {
|
||||
Ok(env) => {
|
||||
assert_eq!((env.id, env.r#final), (2, true));
|
||||
match env.msg {
|
||||
Message::ApprovalOutcome(o) => Some(o.outcome),
|
||||
other => panic!("not an outcome: {other:?}"),
|
||||
}
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Did the courier close the connection within `wait`, without sending anything?
|
||||
pub fn closed_within(&mut self, wait: Duration) -> bool {
|
||||
self.stream.set_read_timeout(Some(wait)).unwrap();
|
||||
matches!(read_frame(&mut self.stream), Err(proto::FrameError::Closed))
|
||||
}
|
||||
|
||||
pub fn close(self) {
|
||||
let _ = self.stream.shutdown(std::net::Shutdown::Both);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! A home for ledger and broker tests: grants, audit log, session state, a flaky audit sink and
|
||||
//! a log to read. Do not edit.
|
||||
//!
|
||||
//! Included with `#[path = "support/rig.rs"] mod rig;`, beside `tmp` and `sink`. The broker
|
||||
//! tests add `client`.
|
||||
|
||||
#![allow(dead_code)] // each test file uses a different part of this module
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use brokerd::audit::Writer;
|
||||
use brokerd::config::{Approvals, Config, Paths, Sockets};
|
||||
use brokerd::ledger::Ledger;
|
||||
use brokerd::state::StateStore;
|
||||
use proto::{AuditEvent, AuditRecord, CallId, SessionId, ToolRequest};
|
||||
|
||||
use crate::sink::{Flaky, Lines, Switch};
|
||||
use crate::tmp::TempDir;
|
||||
|
||||
pub struct Rig {
|
||||
pub dir: TempDir,
|
||||
pub cfg: Config,
|
||||
pub switch: Switch,
|
||||
pub lines: Lines,
|
||||
}
|
||||
|
||||
impl Rig {
|
||||
pub fn new(tag: &str) -> Rig {
|
||||
Rig::with_ttl(tag, 900_000)
|
||||
}
|
||||
|
||||
pub fn with_ttl(tag: &str, ttl_ms: u64) -> Rig {
|
||||
let dir = TempDir::new(tag);
|
||||
let grants = dir.path().join("grants");
|
||||
std::fs::create_dir_all(&grants).unwrap();
|
||||
let cfg = Config {
|
||||
paths: Paths {
|
||||
home: dir.path().to_path_buf(),
|
||||
grants,
|
||||
},
|
||||
sockets: Sockets::default(),
|
||||
approvals: Approvals { ttl_ms },
|
||||
runner: None,
|
||||
gateway: None,
|
||||
};
|
||||
Rig {
|
||||
dir,
|
||||
cfg,
|
||||
switch: Switch::default(),
|
||||
lines: Lines::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state(&self) -> StateStore {
|
||||
StateStore::new(&self.cfg.state_dir())
|
||||
}
|
||||
|
||||
/// Opens the audit log (once: the writer holds its lock) behind the flaky sink.
|
||||
pub fn ledger(&self) -> Ledger {
|
||||
let opened = Writer::open(&self.cfg.audit_dir(), false).unwrap();
|
||||
let sink = Flaky {
|
||||
writer: opened.writer,
|
||||
switch: self.switch.clone(),
|
||||
};
|
||||
Ledger::new(Box::new(sink), self.state(), self.lines.sink())
|
||||
}
|
||||
|
||||
/// Writes `grants/<id>.toml`.
|
||||
pub fn grant(&self, id: &str, text: &str) {
|
||||
std::fs::write(self.cfg.paths.grants.join(format!("{id}.toml")), text).unwrap();
|
||||
}
|
||||
|
||||
pub fn remove_grant(&self, id: &str) {
|
||||
std::fs::remove_file(self.cfg.paths.grants.join(format!("{id}.toml"))).unwrap();
|
||||
}
|
||||
|
||||
pub fn state_file(&self, session: &str) -> PathBuf {
|
||||
self.cfg.state_dir().join(format!("{session}.json"))
|
||||
}
|
||||
|
||||
/// Every record in the audit log, in order.
|
||||
pub fn records(&self) -> Vec<AuditRecord> {
|
||||
let dir = self.cfg.audit_dir();
|
||||
let mut names: Vec<String> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().file_name().into_string().unwrap())
|
||||
.filter(|n| n.ends_with(".jsonl"))
|
||||
.collect();
|
||||
names.sort();
|
||||
let mut out = Vec::new();
|
||||
for name in names {
|
||||
let text = std::fs::read_to_string(dir.join(name)).unwrap();
|
||||
for line in text.lines() {
|
||||
out.push(serde_json::from_str(line).unwrap());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn events(&self) -> Vec<AuditEvent> {
|
||||
self.records().into_iter().map(|r| r.event).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A grant file's text. `extra` goes before `[constraints]`, `constraints` after it.
|
||||
pub fn grant_text(tool: &str, mode: &str, extra: &str, constraints: &str) -> String {
|
||||
format!(
|
||||
"tool = \"{tool}\"\nmode = \"{mode}\"\nmax_taint = \"secret\"\nresult_class = \"private\"\n\
|
||||
untrusted = false\n{extra}\n[constraints]\n{constraints}\n"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn request(session: &str, call: u64, tool: &str, arguments: &str) -> ToolRequest {
|
||||
ToolRequest {
|
||||
session: SessionId::new(session).unwrap(),
|
||||
call: CallId(call),
|
||||
tool: tool.to_string(),
|
||||
arguments: arguments.to_string(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user