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:
2026-09-24 03:34:21 -07:00
co-authored by Claude Opus 5.5
parent b1f732a42a
commit 2d415fc1be
64 changed files with 7701 additions and 2 deletions
@@ -0,0 +1,235 @@
//! The courier: one pending approval carried to `gatewayd` over `gateway.sock`, and its answer
//! back (M4b spec, section 5). It runs beside the connection thread that waits for the verdict,
//! and is one more taker of the table's entry, answering it exactly as `bxctl` does.
use std::os::unix::net::UnixStream;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
use std::time::{Duration, Instant};
use proto::{
ApprovalOutcome, ApprovalReply, ApprovalRequest, DecisionRecord, DenyReason, Envelope,
FrameError, GatewayOutcome, Message, PendingApproval, Timestamp, read_frame,
};
use crate::admin;
use crate::broker::{Broker, send};
use crate::ledger::Answer;
pub const RUNBOOK: &str = "see docs/runbook.md#gateway-unreachable";
/// How long to wait before trying again, and how often to look at the table meanwhile.
#[derive(Debug, Clone, Copy)]
pub struct Timing {
pub retry: Duration,
pub tick: Duration,
}
impl Default for Timing {
fn default() -> Self {
Timing {
retry: Duration::from_secs(5),
tick: Duration::from_secs(1),
}
}
}
/// How one attempt ended.
enum Attempt {
/// The approval is answered or no longer pending: the courier is done.
Done,
/// It was never posted: `gatewayd` could not be reached, or could not post.
Unreachable(String),
/// It was posted, then `gatewayd` went away or broke the protocol.
Lost(String),
}
/// Run `wait` (the connection thread's wait for the verdict) with a courier beside it, when
/// `[gateway]` is set. The courier stops when `wait` returns.
pub fn alongside<T>(broker: &Broker, info: &PendingApproval, wait: impl FnOnce() -> T) -> T {
let Some(socket) = broker.cfg().gateway_socket() else {
return wait();
};
let stop = AtomicBool::new(false);
std::thread::scope(|scope| {
let started = std::thread::Builder::new().spawn_scoped(scope, || {
carry(broker, &socket, info, &stop, Timing::default())
});
if let Err(e) = started {
broker.log(&format!(
"brokerd: approval {}: cannot start its courier: {e}; bxctl can still answer it",
info.approval
));
}
let out = wait();
stop.store(true, Ordering::SeqCst);
out
})
}
/// Carry approval `info` to `gatewayd` until it is answered or no longer pending.
pub fn carry(
broker: &Broker,
socket: &Path,
info: &PendingApproval,
stop: &AtomicBool,
timing: Timing,
) {
let id = info.approval;
let mut warned = false;
loop {
if finished(broker, id, stop) {
return;
}
match attempt(broker, socket, info, stop, timing, warned) {
Attempt::Done => return,
Attempt::Unreachable(why) => {
if !warned {
warned = true;
broker.log(&format!(
"brokerd: approval {id}: gatewayd is not reachable at {}: {why}; bxctl can still answer it\n{RUNBOOK}",
socket.display()
));
}
pause(broker, id, stop, timing);
}
Attempt::Lost(why) => {
broker.log(&format!("brokerd: approval {id}: {why}; posting it again"));
pause(broker, id, stop, timing);
}
}
}
}
/// The courier's work is over: stopped, or the entry has left the table.
fn finished(broker: &Broker, id: u64, stop: &AtomicBool) -> bool {
// `stop.load(Ordering::SeqCst) || !broker.table().contains(id)`.
todo!()
}
/// Wait `timing.retry`, in ticks, unless the courier's work ends first.
fn pause(broker: &Broker, id: u64, stop: &AtomicBool, timing: Timing) {
// `let until = Instant::now() + timing.retry;` then, while `Instant::now() < until` and not
// `finished(broker, id, stop)`, sleep
// `timing.tick.min(until.saturating_duration_since(Instant::now()))`.
todo!()
}
/// Every frame `gatewayd` sends, read whole on a thread of its own, so the courier can wait with
/// a timeout without losing half a frame. Shutting the stream down ends the thread.
fn frames(stream: &UnixStream) -> std::io::Result<Receiver<Result<Envelope, FrameError>>> {
// 1. `let mut reader = stream.try_clone()?;` and `let (tx, rx) = mpsc::channel();`.
// 2. `std::thread::Builder::new().spawn(move || loop { ... })?`, where the loop body is: `let
// frame = read_frame(&mut reader); let last = frame.is_err(); if tx.send(frame).is_err() ||
// last { return; }`.
// 3. `Ok(rx)`.
todo!()
}
/// One connection: post, then wait for the answer, looking at the table every tick.
fn attempt(
broker: &Broker,
socket: &Path,
info: &PendingApproval,
stop: &AtomicBool,
timing: Timing,
warned: bool,
) -> Attempt {
let mut stream = match UnixStream::connect(socket) {
Ok(stream) => stream,
Err(e) => return Attempt::Unreachable(e.to_string()),
};
let rx = match frames(&stream) {
Ok(rx) => rx,
Err(e) => return Attempt::Unreachable(e.to_string()),
};
let ended = exchange(broker, &mut stream, &rx, info, stop, timing, warned);
let _ = stream.shutdown(std::net::Shutdown::Both);
ended
}
/// Frames 1 to 4 of section 4 on an open connection.
fn exchange(
broker: &Broker,
stream: &mut UnixStream,
rx: &Receiver<Result<Envelope, FrameError>>,
info: &PendingApproval,
stop: &AtomicBool,
timing: Timing,
warned: bool,
) -> Attempt {
let id = info.approval;
let request = Message::ApprovalRequest(ApprovalRequest {
approval: info.clone(),
});
if !send(stream, 1, true, request) {
return Attempt::Unreachable("the request could not be sent".to_string());
}
let mut posted = false;
loop {
let frame = match rx.recv_timeout(timing.tick) {
Ok(Ok(frame)) => frame,
Err(RecvTimeoutError::Timeout) => {
if finished(broker, id, stop) {
return Attempt::Done;
}
continue;
}
Ok(Err(_)) | Err(RecvTimeoutError::Disconnected) => {
return lost(posted, "gatewayd went away");
}
};
match (frame.id, frame.r#final, frame.msg) {
(1, false, Message::ApprovalPosted(_)) if !posted => {
posted = true;
if warned {
broker.log(&format!("brokerd: approval {id} posted in Mattermost"));
}
}
(1, true, Message::Error(e)) if !posted => {
return Attempt::Unreachable(format!("gatewayd could not post it: {}", e.detail));
}
(1, true, Message::ApprovalReply(reply)) if posted => {
if reply.approval != id {
return lost(posted, "gatewayd answered another approval");
}
if let Err(why) = reply.check() {
return lost(posted, &format!("gatewayd sent a bad answer ({why})"));
}
let outcome = answer(broker, id, reply);
let _ = send(
stream,
2,
true,
Message::ApprovalOutcome(ApprovalOutcome { outcome }),
);
return Attempt::Done;
}
_ => return lost(posted, "gatewayd broke the protocol"),
}
}
}
fn lost(posted: bool, why: &str) -> Attempt {
if posted {
Attempt::Lost(why.to_string())
} else {
Attempt::Unreachable(why.to_string())
}
}
/// Take the entry and answer it as `bxctl` would, with who answered and in which post.
fn answer(broker: &Broker, id: u64, reply: ApprovalReply) -> GatewayOutcome {
// 1. `let Some(entry) = broker.table().take(id) else { return GatewayOutcome::Gone {}; };`
// 2. `let (by, post) = (Some(reply.by), Some(reply.post));` and the `Answer`: `Answer::Approved
// { by, post }` when `reply.approved`, else `Answer::Refused { by, post, reason:
// reply.reason }`.
// 3. `match (reply.approved, admin::answer(broker, entry, answer, Timestamp::now()))`: (true,
// DecisionRecord::Denied { reason }) -> `GatewayOutcome::Denied { reason }`; (true, _) ->
// `GatewayOutcome::Runs {}`; (false, DecisionRecord::Denied { reason:
// DenyReason::ApprovalRefused }) -> `GatewayOutcome::Refused {}`; (false,
// DecisionRecord::Denied { reason }) -> `GatewayOutcome::Denied { reason }` (the refusal
// could not be recorded); (false, _) -> `GatewayOutcome::Refused {}`.
todo!()
}
@@ -0,0 +1,232 @@
//! A call an `ask` grant matched: the pending frame, the table entry, the wait, and a requester
//! that goes away. The tests answer entries by hand, as `admin` will. Do not edit.
#[path = "support/client.rs"]
mod client;
#[path = "support/rig.rs"]
mod rig;
#[path = "support/runtime.rs"]
mod runtime;
#[path = "support/sink.rs"]
mod sink;
#[path = "support/tmp.rs"]
mod tmp;
use std::os::unix::net::UnixStream;
use std::sync::Arc;
use std::time::{Duration, Instant};
use brokerd::broker::{self, Broker, GONE};
use brokerd::ledger::Answer;
use client::{Serve, next, open};
use proto::{
AuditEvent, DataClass, DecisionRecord, DenyReason, Message, ResultStatus, Timestamp,
ToolResponse,
};
use rig::{Rig, grant_text, request};
use runtime::Recording;
fn ask_notes(rig: &Rig, extra: &str) {
rig.grant(
"notes",
&grant_text("read_file", "ask", extra, "paths = [\"/n\"]"),
);
}
/// Sends a call and reads its pending frame. Returns the connection and the frame's values.
fn start(broker: &Arc<Broker>, arguments: &str) -> (UnixStream, u64, Timestamp) {
let req = request("s1", 9, "read_file", arguments);
let mut stream = open(broker, broker::handle, 9, Message::ToolRequest(req));
let env = next(&mut stream);
assert_eq!((env.id, env.r#final), (9, false), "{env:?}");
match env.msg {
Message::ToolResponse(ToolResponse::PendingApproval { approval, expires }) => {
(stream, approval, expires)
}
other => panic!("{other:?}"),
}
}
/// What `admin` does for an entry it took: record the answer, send the verdict.
fn answer_by_hand(broker: &Broker, id: u64, answer: Answer) -> DecisionRecord {
let entry = broker.table().take(id).expect("the entry is pending");
answer_entry(broker, entry, answer)
}
fn answer_entry(
broker: &Broker,
entry: brokerd::approvals::Entry,
answer: Answer,
) -> DecisionRecord {
let grants = broker.grants();
let done = broker.ledger().answer(
entry.ask,
entry.info.approval,
answer,
&grants,
Timestamp::now(),
);
entry.reply.send(done.verdict).unwrap();
done.outcome
}
fn approved() -> Answer {
Answer::Approved {
by: Some("bxctl".to_string()),
post: None,
}
}
fn final_answer(stream: &mut UnixStream) -> ToolResponse {
let env = next(stream);
assert!(env.r#final, "{env:?}");
match env.msg {
Message::ToolResponse(r) => r,
other => panic!("{other:?}"),
}
}
fn eventually(what: &str, mut done: impl FnMut() -> bool) {
let until = Instant::now() + Duration::from_secs(10);
while !done() {
assert!(Instant::now() < until, "never happened: {what}");
std::thread::sleep(Duration::from_millis(20));
}
}
#[test]
fn an_ask_call_waits_is_listed_and_runs_when_approved() {
let rig = Rig::new("pending-approve");
ask_notes(&rig, "");
let rt = Recording::answering("the notes");
let broker = rig.broker(&rt);
let before = Timestamp::now();
// Spaced JSON: the table shows the arguments as policy parsed them, not as sent.
let (mut stream, approval, expires) = start(&broker, r#"{ "path" : "/n/a" }"#);
assert_eq!(approval, 0, "the approval id is the decision's seq");
let items = broker.table().list();
assert_eq!(items.len(), 1);
let item = &items[0];
assert_eq!(item.approval, 0);
assert_eq!((item.session.as_str(), item.call.0), ("s1", 9));
assert_eq!(item.tool, "read_file");
assert_eq!(item.arguments, r#"{"path":"/n/a"}"#);
assert_eq!(
(item.grant.as_str(), item.taint),
("notes", DataClass::Private)
);
assert!(item.created >= before);
assert_eq!(item.expires, expires);
assert_eq!(
expires.unix_millis() - item.created.unix_millis(),
900_000,
"now plus ttl_ms"
);
assert_eq!(rt.count(), 0, "nothing runs while it waits");
assert_eq!(
answer_by_hand(&broker, 0, approved()),
DecisionRecord::Ask {}
);
assert!(matches!(
final_answer(&mut stream),
ToolResponse::Result { .. }
));
assert_eq!(rt.count(), 1);
let events = rig.events();
assert!(
matches!(
events.as_slice(),
[
AuditEvent::Decision { .. },
AuditEvent::Approval { decision: 0, .. },
AuditEvent::Result { decision: 0, .. }
]
),
"{events:?}"
);
}
#[test]
fn the_expiry_is_the_grants_when_that_is_earlier() {
let rig = Rig::new("pending-grant-expiry");
let soon = Timestamp::from_unix_millis(Timestamp::now().unix_millis() + 60_000).unwrap();
ask_notes(&rig, &format!("expires = \"{}\"", soon.to_rfc3339()));
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (_stream, _, expires) = start(&broker, r#"{"path":"/n/a"}"#);
assert_eq!(expires, soon);
}
#[test]
fn a_denied_verdict_reaches_the_requester_and_nothing_runs() {
let rig = Rig::new("pending-refuse");
ask_notes(&rig, "");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (mut stream, approval, _) = start(&broker, r#"{"path":"/n/a"}"#);
let refused = Answer::Refused {
by: Some("bxctl".to_string()),
post: None,
reason: None,
};
answer_by_hand(&broker, approval, refused);
assert_eq!(
final_answer(&mut stream),
ToolResponse::Denied {
reason: DenyReason::ApprovalRefused
}
);
assert_eq!(rt.count(), 0);
}
#[test]
fn a_requester_that_leaves_takes_its_own_entry_and_nothing_is_written() {
let rig = Rig::new("pending-leave");
ask_notes(&rig, "");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (stream, _, _) = start(&broker, r#"{"path":"/n/a"}"#);
drop(stream);
// The waiting thread looks at its connection every second.
eventually("the entry is removed", || broker.table().list().is_empty());
std::thread::sleep(Duration::from_millis(200));
assert_eq!(rig.events().len(), 1, "only the decision");
let report = brokerd::audit::verify_dir(&rig.cfg.audit_dir()).unwrap();
assert_eq!(report.abandoned, [0]);
assert!(report.failure.is_none());
assert_eq!(rt.count(), 0);
}
#[test]
fn a_requester_gone_at_the_last_look_runs_nothing_and_closes_the_call() {
let rig = Rig::new("pending-lastlook");
ask_notes(&rig, "");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (stream, approval, _) = start(&broker, r#"{"path":"/n/a"}"#);
// Take the entry first, so the waiting thread cannot take it back when it sees the
// connection gone; then approve.
let entry = broker.table().take(approval).unwrap();
drop(stream);
answer_entry(&broker, entry, approved());
eventually("the call is closed", || rig.events().len() == 3);
match &rig.events()[2] {
AuditEvent::Result {
status,
decision,
sha256,
bytes,
..
} => {
assert_eq!((*status, *decision), (ResultStatus::Failed, 0));
assert_eq!(*sha256, proto::sha256(GONE.as_bytes()).unwrap());
assert_eq!(*bytes, GONE.len() as u64);
}
other => panic!("{other:?}"),
}
assert_eq!(GONE, "the requester went away");
assert_eq!(rt.count(), 0);
let report = brokerd::audit::verify_dir(&rig.cfg.audit_dir()).unwrap();
assert!(report.unfinished.is_empty() && report.abandoned.is_empty());
}
@@ -0,0 +1,242 @@
//! Properties over sequences of calls, through `broker::handle` with many threads at once:
//! the log verifies and no `seq` repeats; taint never goes down; every `Result` follows the
//! record that let its call run; the runtime sees a call only after `allowed` or an approval.
//! Do not edit.
#[path = "support/client.rs"]
mod client;
#[path = "support/rig.rs"]
mod rig;
#[path = "support/runtime.rs"]
mod runtime;
#[path = "support/sink.rs"]
mod sink;
#[path = "support/tmp.rs"]
mod tmp;
use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use brokerd::ledger::Answer;
use client::{Serve, call};
use proto::{
AuditEvent, AuditRecord, CallId, DataClass, DecisionRecord, Message, SessionId, Timestamp,
ToolResponse,
};
use rig::{Rig, grant_text, request};
use runtime::Recording;
fn grants(rig: &Rig) {
let with = |class: &str, max: &str| {
grant_text("read_file", "auto", "", "")
.replace(
"result_class = \"private\"",
&format!("result_class = \"{class}\""),
)
.replace("max_taint = \"secret\"", &format!("max_taint = \"{max}\""))
};
rig.grant("notes", &(with("private", "secret") + "paths = [\"/n\"]\n"));
rig.grant("keys", &(with("secret", "secret") + "paths = [\"/k\"]\n"));
// Stops applying once a session has read a secret.
rig.grant(
"public",
&(with("public", "private") + "paths = [\"/p\"]\n"),
);
rig.grant(
"never",
&grant_text("read_file", "deny", "", "paths = [\"/d\"]"),
);
rig.grant(
"asked",
&grant_text("read_file", "ask", "", "paths = [\"/a\"]"),
);
}
/// Which call ran on what authority: (session, call) of every Decision that allowed and every
/// Approval whose re-decision let the call run, keyed by the decision's seq.
fn check_sequence(records: &[AuditRecord]) -> usize {
let mut may_run: BTreeMap<u64, (SessionId, CallId)> = BTreeMap::new();
let mut taint: BTreeMap<String, DataClass> = BTreeMap::new();
let mut results = 0;
for (i, r) in records.iter().enumerate() {
assert_eq!(r.seq, i as u64, "no seq repeats or skips");
match &r.event {
AuditEvent::Decision {
session,
call,
outcome: DecisionRecord::Allowed {},
..
} => {
may_run.insert(r.seq, (session.clone(), *call));
}
AuditEvent::Approval {
session,
call,
decision,
outcome: DecisionRecord::Allowed {} | DecisionRecord::Ask {},
..
} => {
may_run.insert(*decision, (session.clone(), *call));
}
AuditEvent::Result {
session,
call,
decision,
taint_after,
..
} => {
results += 1;
let allowed = may_run.remove(decision);
assert_eq!(
allowed,
Some((session.clone(), *call)),
"seq {}: a Result with no record letting its call run",
r.seq
);
let before = taint.insert(session.as_str().to_string(), *taint_after);
assert!(
before.is_none_or(|b| b <= *taint_after),
"seq {}: taint went down",
r.seq
);
}
_ => {}
}
}
assert!(
may_run.is_empty(),
"calls allowed but never finished: {may_run:?}"
);
results
}
#[test]
fn eight_threads_two_sessions_fifty_calls_each() {
let rig = Rig::new("sequence-many");
grants(&rig);
let rt = Recording::answering("content");
let broker = rig.broker(&rt);
let paths = ["/n/x", "/k/x", "/p/x", "/d/x", "/none/x"];
let threads: Vec<_> = (0..8u64)
.map(|t| {
let broker = Arc::clone(&broker);
std::thread::spawn(move || {
let session = if t % 2 == 0 { "s-even" } else { "s-odd" };
for i in 0..50u64 {
let path = paths[((t + i) % 5) as usize];
let args = format!(r#"{{"path":"{path}"}}"#);
let req = request(session, t * 1000 + i, "read_file", &args);
let frames = call(&broker, req);
assert_eq!(frames.len(), 1);
}
})
})
.collect();
for t in threads {
t.join().unwrap();
}
let report = brokerd::audit::verify_dir(&rig.cfg.audit_dir()).unwrap();
assert!(report.failure.is_none(), "{:?}", report.failure);
assert!(report.unfinished.is_empty());
let records = rig.records();
let decisions = records
.iter()
.filter(|r| matches!(r.event, AuditEvent::Decision { .. }))
.count();
assert_eq!(decisions, 400);
let results = check_sequence(&records);
assert_eq!(
rt.count(),
results,
"the runtime saw exactly the allowed calls"
);
assert!(results > 0 && results < 400);
// Both sessions read a secret.
for s in ["s-even", "s-odd"] {
let state = rig.state().read(&SessionId::new(s).unwrap()).unwrap();
assert_eq!(state.taint, DataClass::Secret);
}
}
#[test]
fn approved_and_refused_calls_run_only_after_an_approval() {
let rig = Rig::new("sequence-ask");
grants(&rig);
let rt = Recording::answering("content");
let broker = rig.broker(&rt);
// An owner who approves even ids and refuses odd ones, as fast as they appear.
let stop = Arc::new(AtomicBool::new(false));
let owner = {
let (broker, stop) = (Arc::clone(&broker), Arc::clone(&stop));
std::thread::spawn(move || {
while !stop.load(Ordering::SeqCst) {
for item in broker.table().list() {
let Some(entry) = broker.table().take(item.approval) else {
continue;
};
let answer = if item.approval % 2 == 0 {
Answer::Approved {
by: None,
post: None,
}
} else {
Answer::Refused {
by: None,
post: None,
reason: None,
}
};
let grants = broker.grants();
let done = broker.ledger().answer(
entry.ask,
item.approval,
answer,
&grants,
Timestamp::now(),
);
entry.reply.send(done.verdict).unwrap();
}
std::thread::sleep(Duration::from_millis(2));
}
})
};
let threads: Vec<_> = (0..4u64)
.map(|t| {
let broker = Arc::clone(&broker);
std::thread::spawn(move || {
for i in 0..10u64 {
let path = if i % 2 == 0 { "/a/x" } else { "/n/x" };
let args = format!(r#"{{"path":"{path}"}}"#);
let frames = call(&broker, request("s1", t * 100 + i, "read_file", &args));
let last = frames.last().unwrap();
assert!(
matches!(
&last.msg,
Message::ToolResponse(
ToolResponse::Result { .. } | ToolResponse::Denied { .. }
)
),
"{last:?}"
);
}
})
})
.collect();
for t in threads {
t.join().unwrap();
}
stop.store(true, Ordering::SeqCst);
owner.join().unwrap();
let records = rig.records();
let approvals = records
.iter()
.filter(|r| matches!(r.event, AuditEvent::Approval { .. }))
.count();
assert_eq!(approvals, 20, "one Approval for each of the twenty asks");
let results = check_sequence(&records);
assert_eq!(rt.count(), results);
let report = brokerd::audit::verify_dir(&rig.cfg.audit_dir()).unwrap();
assert!(report.failure.is_none() && report.abandoned.is_empty());
}
@@ -0,0 +1,43 @@
//! `[gateway]` in `brokerd.toml`: absent means Mattermost approvals are off; an empty socket is
//! the default under `home` (M4b spec, section 3). Do not edit.
use std::path::PathBuf;
use brokerd::config::Config;
fn parse(text: &str) -> Config {
Config::parse(&format!("[paths]\nhome = \"/srv/bx\"\n{text}")).unwrap()
}
#[test]
fn without_the_section_approvals_stay_in_bxctl() {
let config = parse("");
assert_eq!(config.gateway, None);
assert_eq!(config.gateway_socket(), None);
}
#[test]
fn an_empty_section_or_socket_is_the_default() {
let default = Some(PathBuf::from("/srv/bx/run/gateway/gateway.sock"));
assert_eq!(parse("[gateway]\n").gateway_socket(), default);
assert_eq!(
parse("[gateway]\nsocket = \"\"\n").gateway_socket(),
default
);
}
#[test]
fn a_socket_path_is_used_as_given() {
let config = parse("[gateway]\nsocket = \"/run/bx/gw.sock\"\n");
assert_eq!(
config.gateway_socket(),
Some(PathBuf::from("/run/bx/gw.sock"))
);
}
#[test]
fn unknown_keys_are_errors() {
assert!(Config::parse("[gateway]\nsockets = \"/x\"\n").is_err());
assert!(Config::parse("[gateway]\nsocket = \"/x\"\nenabled = true\n").is_err());
assert!(Config::parse("[gatewy]\nsocket = \"/x\"\n").is_err());
}
@@ -0,0 +1,447 @@
//! The courier carries a pending approval to `gatewayd` and its answer back, against a fake
//! `gatewayd` on a real socket (M4b spec, section 5). Do not edit.
#[path = "support/client.rs"]
mod client;
#[path = "support/fake_gateway.rs"]
mod fake_gateway;
#[path = "support/rig.rs"]
mod rig;
#[path = "support/runtime.rs"]
mod runtime;
#[path = "support/sink.rs"]
mod sink;
#[path = "support/tmp.rs"]
mod tmp;
use std::os::unix::net::UnixStream;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::time::{Duration, Instant};
use brokerd::broker::{self, Broker};
use brokerd::config::Gateway;
use brokerd::courier::{self, Timing};
use brokerd::ledger::Answer;
use client::{Serve, next, open};
use fake_gateway::{FakeGateway, KYLE, POST};
use proto::{
ApprovalAnswer, ApprovalReply, AuditEvent, DenyReason, GatewayOutcome, Message, Timestamp,
ToolResponse,
};
use rig::{Rig, grant_text, request};
use runtime::Recording;
const WAIT: Duration = Duration::from_secs(5);
/// A rig whose `[gateway]` is a fake gatewayd in its home, and an `ask` grant for /n.
fn rig_with_gateway(tag: &str) -> (Rig, FakeGateway) {
let mut rig = Rig::new(tag);
let socket = rig.dir.path().join("gw.sock");
let fake = FakeGateway::start(&socket);
rig.cfg.gateway = Some(Gateway { socket });
rig.grant(
"notes",
&grant_text("read_file", "ask", "", "paths = [\"/n\"]"),
);
(rig, fake)
}
/// A call that waits for approval; its connection and the approval id.
fn ask(broker: &Arc<Broker>) -> (UnixStream, u64) {
let mut stream = open(
broker,
broker::handle,
9,
Message::ToolRequest(request("s1", 9, "read_file", r#"{"path":"/n/a"}"#)),
);
match next(&mut stream).msg {
Message::ToolResponse(ToolResponse::PendingApproval { approval, .. }) => (stream, approval),
other => panic!("{other:?}"),
}
}
fn final_answer(stream: &mut UnixStream) -> ToolResponse {
let env = next(stream);
assert!(env.r#final, "{env:?}");
match env.msg {
Message::ToolResponse(r) => r,
other => panic!("{other:?}"),
}
}
/// The `Approval` record's (answer, by, post, reason).
fn approval_record(
rig: &Rig,
) -> (
ApprovalAnswer,
Option<String>,
Option<String>,
Option<String>,
) {
let events = rig.events();
let found = events.iter().find_map(|e| match e {
AuditEvent::Approval {
answer,
by,
post,
reason,
..
} => Some((*answer, by.clone(), post.clone(), reason.clone())),
_ => None,
});
found.expect("an Approval record")
}
fn some(s: &str) -> Option<String> {
Some(s.to_string())
}
/// Sets the courier's stop flag when dropped, so a failing assertion ends the courier too.
struct StopOnDrop<'a>(&'a AtomicBool);
impl Drop for StopOnDrop<'_> {
fn drop(&mut self) {
self.0.store(true, std::sync::atomic::Ordering::SeqCst);
}
}
/// The courier must end by itself within five seconds.
fn ends_by_itself<T>(courier: &std::thread::ScopedJoinHandle<'_, T>) {
let until = Instant::now() + WAIT;
while !courier.is_finished() {
assert!(Instant::now() < until, "the courier did not stop");
std::thread::sleep(Duration::from_millis(20));
}
}
#[test]
fn approved_in_mattermost_it_runs_and_the_record_names_who_and_where() {
let (rig, fake) = rig_with_gateway("courier-approve");
let rt = Recording::answering("the notes");
let broker = rig.broker(&rt);
let (mut stream, id) = ask(&broker);
let mut conn = fake.next(WAIT).expect("the approval reaches gatewayd");
assert_eq!(
(conn.request.approval, conn.request.tool.as_str()),
(id, "read_file")
);
assert_eq!(conn.request.arguments, r#"{"path":"/n/a"}"#);
conn.posted();
conn.approve();
assert_eq!(conn.outcome(), Some(GatewayOutcome::Runs {}));
assert!(matches!(
final_answer(&mut stream),
ToolResponse::Result { .. }
));
assert_eq!(rt.count(), 1);
assert_eq!(
approval_record(&rig),
(ApprovalAnswer::Approved, some(KYLE), some(POST), None)
);
}
#[test]
fn refused_in_mattermost_it_is_denied_with_the_reason() {
let (rig, fake) = rig_with_gateway("courier-refuse");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (mut stream, _) = ask(&broker);
let mut conn = fake.next(WAIT).unwrap();
conn.posted();
conn.refuse("not on a Friday");
assert_eq!(conn.outcome(), Some(GatewayOutcome::Refused {}));
assert_eq!(
final_answer(&mut stream),
ToolResponse::Denied {
reason: DenyReason::ApprovalRefused
}
);
assert_eq!(rt.count(), 0);
assert_eq!(
approval_record(&rig),
(
ApprovalAnswer::Refused,
some(KYLE),
some(POST),
some("not on a Friday")
)
);
}
#[test]
fn approved_after_the_grant_is_gone_it_is_denied_on_the_recheck() {
let (rig, fake) = rig_with_gateway("courier-recheck");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (mut stream, _) = ask(&broker);
let mut conn = fake.next(WAIT).unwrap();
conn.posted();
rig.remove_grant("notes");
conn.approve();
assert_eq!(
conn.outcome(),
Some(GatewayOutcome::Denied {
reason: DenyReason::NoGrant
})
);
assert_eq!(
final_answer(&mut stream),
ToolResponse::Denied {
reason: DenyReason::NoGrant
}
);
assert_eq!(rt.count(), 0);
}
#[test]
fn answered_in_bxctl_the_courier_closes_without_an_outcome() {
let (rig, fake) = rig_with_gateway("courier-bxctl");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (mut stream, id) = ask(&broker);
let mut conn = fake.next(WAIT).unwrap();
conn.posted();
let entry = broker.table().take(id).unwrap();
brokerd::admin::answer(
&broker,
entry,
Answer::Approved {
by: some("bxctl"),
post: None,
},
Timestamp::now(),
);
assert!(
conn.closed_within(WAIT),
"the courier closes, and sends nothing"
);
assert!(matches!(
final_answer(&mut stream),
ToolResponse::Result { .. }
));
assert_eq!(approval_record(&rig).1, some("bxctl"));
}
#[test]
fn an_answer_after_it_ended_is_gone() {
let rig = Rig::new("courier-gone");
rig.grant(
"notes",
&grant_text("read_file", "ask", "", "paths = [\"/n\"]"),
);
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (_stream, id) = ask(&broker);
// The courier is run by hand here, so that the answer can arrive after the entry is taken.
let socket = rig.dir.path().join("gw.sock");
let fake = FakeGateway::start(&socket);
let info = broker.table().list().remove(0);
let stop = AtomicBool::new(false);
std::thread::scope(|scope| {
let courier = scope.spawn(|| {
courier::carry(
&broker,
&socket,
&info,
&stop,
Timing {
retry: Duration::from_millis(100),
tick: Duration::from_secs(5),
},
)
});
let _guard = StopOnDrop(&stop);
let mut conn = fake.next(WAIT).unwrap();
conn.posted();
let entry = broker.table().take(id).unwrap();
brokerd::admin::answer(&broker, entry, Answer::Expired, Timestamp::now());
conn.approve();
assert_eq!(conn.outcome(), Some(GatewayOutcome::Gone {}));
ends_by_itself(&courier);
});
}
#[test]
fn if_gatewayd_goes_away_the_approval_is_posted_again() {
let (rig, fake) = rig_with_gateway("courier-again");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (mut stream, id) = ask(&broker);
let mut first = fake.next(WAIT).unwrap();
first.posted();
first.close();
let mut second = fake.next(Duration::from_secs(10)).expect("posted again");
assert_eq!(second.request.approval, id);
second.posted();
second.approve();
assert_eq!(second.outcome(), Some(GatewayOutcome::Runs {}));
assert!(matches!(
final_answer(&mut stream),
ToolResponse::Result { .. }
));
assert!(
rig.lines.all().iter().any(|l| l.contains(&format!(
"approval {id}: gatewayd went away; posting it again"
))),
"{:?}",
rig.lines.all()
);
}
#[test]
fn a_bad_answer_answers_nothing() {
let (rig, fake) = rig_with_gateway("courier-bad");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (_stream, id) = ask(&broker);
for bad in [
ApprovalReply {
approval: id + 1,
approved: true,
by: KYLE.to_string(),
post: POST.to_string(),
reason: None,
},
ApprovalReply {
approval: id,
approved: true,
by: "kyle".to_string(),
post: POST.to_string(),
reason: None,
},
ApprovalReply {
approval: id,
approved: false,
by: KYLE.to_string(),
post: POST.to_string(),
reason: Some("x".repeat(501)),
},
] {
let mut conn = fake.next(Duration::from_secs(10)).unwrap();
conn.posted();
conn.reply(bad);
assert_eq!(conn.outcome(), None, "no outcome for a bad answer");
assert!(broker.table().contains(id), "still pending");
}
assert_eq!(rt.count(), 0);
}
#[test]
fn unreachable_at_first_then_posted_and_said_once() {
let rig = Rig::new("courier-late");
rig.grant(
"notes",
&grant_text("read_file", "ask", "", "paths = [\"/n\"]"),
);
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (_stream, id) = ask(&broker);
let socket = rig.dir.path().join("gw.sock");
let info = broker.table().list().remove(0);
let stop = AtomicBool::new(false);
let timing = Timing {
retry: Duration::from_millis(100),
tick: Duration::from_millis(20),
};
std::thread::scope(|scope| {
let courier = scope.spawn(|| courier::carry(&broker, &socket, &info, &stop, timing));
let _guard = StopOnDrop(&stop);
std::thread::sleep(Duration::from_millis(500));
let fake = FakeGateway::start(&socket);
let mut conn = fake.next(WAIT).unwrap();
conn.posted();
let until = Instant::now() + WAIT;
while !rig
.lines
.all()
.iter()
.any(|l| l.contains("posted in Mattermost"))
{
assert!(Instant::now() < until, "{:?}", rig.lines.all());
std::thread::sleep(Duration::from_millis(20));
}
broker.table().take(id);
ends_by_itself(&courier);
});
let lines = rig.lines.all();
let unreachable: Vec<&String> = lines
.iter()
.filter(|l| l.contains("gatewayd is not reachable"))
.collect();
assert_eq!(
unreachable.len(),
1,
"said once, not every retry: {lines:?}"
);
assert!(
unreachable[0].starts_with(&format!(
"brokerd: approval {id}: gatewayd is not reachable at "
)),
"{lines:?}"
);
assert!(
unreachable[0]
.ends_with("; bxctl can still answer it\nsee docs/runbook.md#gateway-unreachable"),
"{lines:?}"
);
assert!(
lines.contains(&format!("brokerd: approval {id} posted in Mattermost")),
"{lines:?}"
);
}
#[test]
fn if_gatewayd_cannot_post_it_is_tried_again() {
let (rig, fake) = rig_with_gateway("courier-cannot");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (mut stream, _) = ask(&broker);
fake.next(WAIT).unwrap().cannot_post();
let mut conn = fake.next(Duration::from_secs(10)).expect("tried again");
conn.posted();
conn.approve();
assert_eq!(conn.outcome(), Some(GatewayOutcome::Runs {}));
assert!(matches!(
final_answer(&mut stream),
ToolResponse::Result { .. }
));
assert!(
rig.lines
.all()
.iter()
.any(|l| l.contains("gatewayd could not post it: Mattermost is down"))
);
}
#[test]
fn without_a_gateway_nothing_is_carried() {
let rig = Rig::new("courier-none");
rig.grant(
"notes",
&grant_text("read_file", "ask", "", "paths = [\"/n\"]"),
);
let socket = rig.dir.path().join("gw.sock");
let fake = FakeGateway::start(&socket);
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (mut stream, id) = ask(&broker);
assert!(
fake.next(Duration::from_millis(500)).is_none(),
"no [gateway]: no connection"
);
let entry = broker.table().take(id).unwrap();
brokerd::admin::answer(
&broker,
entry,
Answer::Approved {
by: some("bxctl"),
post: None,
},
Timestamp::now(),
);
assert!(matches!(
final_answer(&mut stream),
ToolResponse::Result { .. }
));
}
@@ -0,0 +1,264 @@
//! The ledger's second step: an approval decides again and is recorded; a refusal and an expiry
//! are recorded as denials. Do not edit.
#[path = "support/build.rs"]
mod build;
#[path = "support/rig.rs"]
mod rig;
#[path = "support/sink.rs"]
mod sink;
#[path = "support/tmp.rs"]
mod tmp;
use brokerd::approvals::Verdict;
use brokerd::ledger::{Answer, Answered, Decided, Grants, Ledger};
use brokerd::policy::{Ask, Label};
use build::{grant, now, read, set};
use proto::{
ApprovalAnswer, AuditEvent, CallId, DataClass, DecisionRecord, DenyReason, Mode, SessionId,
};
use rig::Rig;
fn asking() -> Grants {
Ok(set(vec![grant("n", "read_file", Mode::Ask).paths(&["/n"])]))
}
fn pending(ledger: &Ledger) -> Ask {
match ledger.decide(read("/n/deep/a"), &asking(), now()) {
Decided::Ask { ask, seq: 0, .. } => ask,
other => panic!("{other:?}"),
}
}
fn bxctl() -> Answer {
Answer::Approved {
by: Some("bxctl".to_string()),
post: None,
}
}
fn denied(reason: DenyReason) -> DecisionRecord {
DecisionRecord::Denied { reason }
}
fn verdict_reason(answered: &Answered) -> Option<DenyReason> {
match &answered.verdict {
Verdict::Run(_) => None,
Verdict::Denied(reason) => Some(*reason),
}
}
/// The one `Approval` record, as (answer, by, reason, outcome, grant).
type Row = (
ApprovalAnswer,
Option<String>,
Option<String>,
DecisionRecord,
Option<String>,
);
fn approval(rig: &Rig) -> Row {
let events = rig.events();
assert_eq!(events.len(), 2, "a decision and one approval: {events:?}");
match &events[1] {
AuditEvent::Approval {
session,
call,
decision,
answer,
by,
post,
reason,
outcome,
grant,
..
} => {
assert_eq!(session, &SessionId::new("s1").unwrap());
assert_eq!((*call, *decision, post), (CallId(1), 0, &None));
(
*answer,
by.clone(),
reason.clone(),
outcome.clone(),
grant.clone(),
)
}
other => panic!("{other:?}"),
}
}
#[test]
fn an_approval_that_still_asks_lets_the_call_run() {
let rig = Rig::new("answer-ask");
let ledger = rig.ledger();
let ask = pending(&ledger);
let answered = ledger.answer(ask, 0, bxctl(), &asking(), now());
match &answered.verdict {
Verdict::Run(decision) => assert_eq!(decision.grant(), "n"),
other => panic!("{other:?}"),
}
assert_eq!(answered.outcome, DecisionRecord::Ask {});
let row = approval(&rig);
assert_eq!(
row,
(
ApprovalAnswer::Approved,
Some("bxctl".to_string()),
None,
DecisionRecord::Ask {},
Some("n".to_string())
)
);
}
#[test]
fn an_approval_under_a_grant_that_is_now_auto_records_allowed() {
let rig = Rig::new("answer-auto");
let ledger = rig.ledger();
let ask = pending(&ledger);
let now_auto = Ok(set(vec![
grant("n", "read_file", Mode::Auto).paths(&["/n"]),
]));
let answered = ledger.answer(ask, 0, bxctl(), &now_auto, now());
assert!(matches!(answered.verdict, Verdict::Run(_)));
assert_eq!(answered.outcome, DecisionRecord::Allowed {});
}
#[test]
fn the_approval_names_the_grant_matched_now() {
let rig = Rig::new("answer-grant");
let ledger = rig.ledger();
let ask = pending(&ledger);
let wider = Ok(set(vec![
grant("n", "read_file", Mode::Ask).paths(&["/n"]),
grant("z-deep", "read_file", Mode::Ask).paths(&["/n/deep"]),
]));
let answered = ledger.answer(ask, 0, bxctl(), &wider, now());
match &answered.verdict {
Verdict::Run(decision) => assert_eq!(decision.grant(), "z-deep"),
other => panic!("{other:?}"),
}
assert_eq!(approval(&rig).4.as_deref(), Some("z-deep"));
}
#[test]
fn an_approval_after_the_grant_is_gone_is_denied() {
let rig = Rig::new("answer-gone");
let ledger = rig.ledger();
let ask = pending(&ledger);
let answered = ledger.answer(ask, 0, bxctl(), &Ok(set(Vec::new())), now());
assert_eq!(verdict_reason(&answered), Some(DenyReason::NoGrant));
assert_eq!(answered.outcome, denied(DenyReason::NoGrant));
let row = approval(&rig);
assert_eq!(
(row.0, row.3, row.4),
(ApprovalAnswer::Approved, denied(DenyReason::NoGrant), None)
);
}
#[test]
fn an_approval_after_the_taint_rose_is_denied() {
let rig = Rig::new("answer-taint");
let ledger = rig.ledger();
let low = || {
Ok(set(vec![
grant("n", "read_file", Mode::Ask)
.paths(&["/n"])
.max_taint(DataClass::Private),
]))
};
let ask = match ledger.decide(read("/n/a"), &low(), now()) {
Decided::Ask { ask, .. } => ask,
other => panic!("{other:?}"),
};
// Another call of the session read a secret while this one waited.
let s1 = SessionId::new("s1").unwrap();
let label = Label {
class: DataClass::Secret,
untrusted: false,
};
rig.state()
.raise(&s1, rig.state().read(&s1).unwrap(), label)
.unwrap();
let answered = ledger.answer(ask, 0, bxctl(), &low(), now());
assert_eq!(verdict_reason(&answered), Some(DenyReason::TaintTooHigh));
match &rig.events()[1] {
AuditEvent::Approval { taint, outcome, .. } => {
assert_eq!(*taint, DataClass::Secret, "the state at the re-decision");
assert_eq!(*outcome, denied(DenyReason::TaintTooHigh));
}
other => panic!("{other:?}"),
}
}
#[test]
fn an_approval_with_invalid_grants_is_denied() {
let rig = Rig::new("answer-invalid");
let ledger = rig.ledger();
let ask = pending(&ledger);
let answered = ledger.answer(ask, 0, bxctl(), &Err(Vec::new()), now());
assert_eq!(verdict_reason(&answered), Some(DenyReason::GrantsInvalid));
}
#[test]
fn a_refusal_is_recorded_with_the_owners_reason() {
let rig = Rig::new("answer-refuse");
let ledger = rig.ledger();
let ask = pending(&ledger);
let refused = Answer::Refused {
by: Some("bxctl".to_string()),
post: None,
reason: Some("not now".to_string()),
};
let answered = ledger.answer(ask, 0, refused, &asking(), now());
assert_eq!(verdict_reason(&answered), Some(DenyReason::ApprovalRefused));
assert_eq!(
approval(&rig),
(
ApprovalAnswer::Refused,
Some("bxctl".to_string()),
Some("not now".to_string()),
denied(DenyReason::ApprovalRefused),
None
)
);
}
#[test]
fn an_expiry_is_recorded_by_nobody() {
let rig = Rig::new("answer-expire");
let ledger = rig.ledger();
let ask = pending(&ledger);
let answered = ledger.answer(ask, 0, Answer::Expired, &asking(), now());
assert_eq!(verdict_reason(&answered), Some(DenyReason::ApprovalExpired));
assert_eq!(
approval(&rig),
(
ApprovalAnswer::Expired,
None,
None,
denied(DenyReason::ApprovalExpired),
None
)
);
}
#[test]
fn an_approval_that_cannot_be_recorded_does_not_run() {
let rig = Rig::new("answer-norecord");
let ledger = rig.ledger();
let ask = pending(&ledger);
rig.switch.fail(true);
let answered = ledger.answer(ask, 0, bxctl(), &asking(), now());
assert_eq!(
verdict_reason(&answered),
Some(DenyReason::AuditUnavailable)
);
assert_eq!(answered.outcome, denied(DenyReason::AuditUnavailable));
assert_eq!(rig.events().len(), 1, "only the decision");
assert!(
!rig.lines
.with("see docs/runbook.md#audit-unavailable")
.is_empty()
);
}
@@ -0,0 +1,118 @@
//! An answer from Mattermost records who answered and in which post, approved or refused; the
//! table says whether an approval is still pending (M4b spec, section 5). Do not edit.
#[path = "support/build.rs"]
mod build;
#[path = "support/rig.rs"]
mod rig;
#[path = "support/sink.rs"]
mod sink;
#[path = "support/tmp.rs"]
mod tmp;
use brokerd::approvals::Table;
use brokerd::ledger::{Answer, Decided, Grants, Ledger};
use brokerd::policy::Ask;
use build::{grant, now, read, set};
use proto::{ApprovalAnswer, AuditEvent, CallId, DataClass, Mode, PendingApproval, SessionId};
use rig::Rig;
const KYLE: &str = "k0000000000000000000000000";
const POST: &str = "c0000000000000000000000001";
fn asking() -> Grants {
Ok(set(vec![grant("n", "read_file", Mode::Ask).paths(&["/n"])]))
}
fn pending(ledger: &Ledger) -> Ask {
match ledger.decide(read("/n/deep/a"), &asking(), now()) {
Decided::Ask { ask, seq: 0, .. } => ask,
other => panic!("{other:?}"),
}
}
/// The one `Approval` record's (answer, by, post, reason).
fn recorded(
rig: &Rig,
) -> (
ApprovalAnswer,
Option<String>,
Option<String>,
Option<String>,
) {
let events = rig.events();
match &events[1] {
AuditEvent::Approval {
answer,
by,
post,
reason,
..
} => (*answer, by.clone(), post.clone(), reason.clone()),
other => panic!("{other:?}"),
}
}
#[test]
fn an_approval_from_mattermost_names_the_user_and_the_post() {
let rig = Rig::new("post-approve");
let ledger = rig.ledger();
let answer = Answer::Approved {
by: Some(KYLE.to_string()),
post: Some(POST.to_string()),
};
ledger.answer(pending(&ledger), 0, answer, &asking(), now());
assert_eq!(
recorded(&rig),
(
ApprovalAnswer::Approved,
Some(KYLE.to_string()),
Some(POST.to_string()),
None
)
);
}
#[test]
fn a_refusal_from_mattermost_names_them_and_keeps_the_reason() {
let rig = Rig::new("post-refuse");
let ledger = rig.ledger();
let answer = Answer::Refused {
by: Some(KYLE.to_string()),
post: Some(POST.to_string()),
reason: Some("not on a Friday".to_string()),
};
ledger.answer(pending(&ledger), 0, answer, &asking(), now());
assert_eq!(
recorded(&rig),
(
ApprovalAnswer::Refused,
Some(KYLE.to_string()),
Some(POST.to_string()),
Some("not on a Friday".to_string())
)
);
}
#[test]
fn the_table_says_what_is_pending() {
let rig = Rig::new("post-table");
let ledger = rig.ledger();
let table = Table::new();
assert!(!table.contains(0));
let info = PendingApproval {
approval: 0,
session: SessionId::new("s1").unwrap(),
call: CallId(1),
tool: "read_file".to_string(),
arguments: "{}".to_string(),
grant: "n".to_string(),
taint: DataClass::Private,
created: now(),
expires: now(),
};
let _verdict = table.insert(info, pending(&ledger));
assert!(table.contains(0) && !table.contains(1));
assert!(table.take(0).is_some());
assert!(!table.contains(0));
}
@@ -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(),
}
}
@@ -0,0 +1,3 @@
//! The text-escape helpers, from `proto` (moved there in M4b so `gatewayd` shows the same thing).
pub use proto::escape::{escape_json_text, escape_model_text};
@@ -0,0 +1,42 @@
//! `bxctl deny` is another name for `bxctl refuse`: every argument list means the same under both
//! (M4b spec, section 2). Do not edit.
use bxctl::cli::{USAGE, parse};
use std::path::Path;
fn under(command: &str, rest: &[&str]) -> Vec<String> {
std::iter::once(command)
.chain(rest.iter().copied())
.map(str::to_string)
.collect()
}
#[test]
fn deny_parses_as_refuse() {
let cases: &[&[&str]] = &[
&["41"],
&["41", "--reason", "not on a Friday"],
&["--reason", "no", "41"],
&["41", "--admin-socket", "/tmp/a.sock"],
&["41", "--reason", "x", "--admin-socket", "/tmp/a.sock"],
&[],
&["x41"],
&["41", "--reason"],
&["41", "42"],
&["41", "--bogus"],
];
for rest in cases {
let home = Path::new("/srv/bx");
assert_eq!(
parse(&under("deny", rest), home),
parse(&under("refuse", rest), home),
"{rest:?}"
);
}
}
#[test]
fn usage_names_deny() {
assert!(USAGE.contains("bxctl deny <id>"), "{USAGE}");
assert!(USAGE.contains("bxctl refuse <id>"), "{USAGE}");
}
@@ -0,0 +1,105 @@
//! Approvals in Mattermost (M4b spec, sections 6 and 7): the text of the posts, and the
//! approvals this `gatewayd` has posted and the commands that answer them.
pub mod text;
use std::collections::HashMap;
use std::sync::mpsc::Sender;
use std::sync::{Mutex, MutexGuard};
use proto::ApprovalReply;
use crate::sessions::ApprovalCommand;
pub const NOT_APPROVER: &str = "only an approver can answer approvals";
pub const USAGE: &str = "use !approve <id> or !deny <id> <reason>";
/// One approval posted here and not yet answered.
#[derive(Debug)]
pub struct Posted {
pub approval: u64,
/// The session whose turn waits for it.
pub session: String,
/// The thread it is answered in.
pub channel: String,
pub root: String,
/// The last post's `create_at`: only a command after it counts.
pub shown_at: i64,
/// False when the arguments were too long to show: only `bxctl` answers it.
pub answerable: bool,
/// Where an accepted answer goes: the thread of its `gateway.sock` connection.
pub answer: Sender<ApprovalReply>,
}
/// What a command came to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Judged {
/// The answer went to `brokerd`'s connection; the approval post will show the outcome.
Sent,
/// Refused: this line goes in the thread.
Reply(String),
}
/// The approvals posted here, by id.
#[derive(Debug, Default)]
pub struct Registry {
posted: Mutex<HashMap<u64, Posted>>,
}
impl Registry {
pub fn new() -> Registry {
Registry::default()
}
fn lock(&self) -> MutexGuard<'_, HashMap<u64, Posted>> {
self.posted.lock().unwrap_or_else(|p| p.into_inner())
}
pub fn add(&self, posted: Posted) {
// `self.lock().insert(posted.approval, posted);`
todo!()
}
/// Take it out: it ended, or its connection is done with it.
pub fn remove(&self, id: u64) -> Option<Posted> {
// `self.lock().remove(&id)`
todo!()
}
pub fn holds(&self, id: u64) -> bool {
// `self.lock().contains_key(&id)`
todo!()
}
/// The sessions with an approval waiting here: their turns are waiting, not working.
pub fn waiting_sessions(&self) -> Vec<String> {
// The `session` of every entry: `self.lock().values().map(|p|
// p.session.clone()).collect()`.
todo!()
}
/// Judge a command from `user`, posted as `post` at `at` in the thread `root`, in the order of
/// the spec's section 7. An accepted answer is taken out and sent; nothing else changes.
pub fn judge(
&self,
command: &ApprovalCommand,
user: &str,
root: &str,
post: &str,
at: i64,
approvers: &[String],
) -> Judged {
// In this order; each refusal is `Judged::Reply(<text>)` and changes nothing:
// 1. `user` not in `approvers`: NOT_APPROVER.
// 2. The command: Approve(id) is (id, approved true, reason None); Deny(id, reason) is (id,
// false, reason.clone()); Malformed: USAGE.
// 3. `let mut posted = self.lock();` Not in `posted`: "approval {id} is not pending here".
// 4. `!entry.answerable`: "approval {id} can only be answered with bxctl approvals".
// 5. `entry.root != root`: "approval {id} is answered in its own thread".
// 6. `at <= entry.shown_at`: "approval {id} was answered before it was shown in full".
// 7. Take it out (`posted.remove(&id)`) and send `ApprovalReply { approval: id, approved,
// by: user, post, reason }` on its `answer`: Ok -> `Judged::Sent`; Err (the connection
// is gone) -> "approval {id} is not pending here".
todo!()
}
}
@@ -0,0 +1,4 @@
//! Approvals in Mattermost (M4b spec, sections 6 and 7): the text of the posts, and the
//! approvals this `gatewayd` has posted and the commands that answer them.
pub mod text;
@@ -0,0 +1,157 @@
//! The text of an approval's posts, and of the line added when it ends (M4b spec, section 6).
//! Nothing here is shortened: arguments too long to show whole are not shown at all.
use proto::escape::escape_json_text;
use proto::{DenyReason, PendingApproval, Timestamp, sha256};
/// Arguments are split into parts of at most this many characters.
pub const PART_CHARS: usize = 15_000;
/// At most this many parts; more are answerable only in `bxctl`.
pub const MAX_PARTS: usize = 4;
/// The longest a part's post may be, leaving room for the line added when it ends.
pub const MAX_PART_POST: usize = 15_500;
/// An approval's posts, in order. Only an answer after the last counts; when `answerable` is
/// false, no command answers it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Shown {
pub posts: Vec<String>,
pub answerable: bool,
}
/// How an approval ended, for the line added to its last post.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Ended {
Runs { by: String },
Denied { by: String, reason: DenyReason },
Refused { by: String, reason: Option<String> },
NoLonger,
Restarted,
}
/// `text` as inline code, with a backtick run longer than any inside it.
pub fn code(text: &str) -> String {
// `let ticks = "`".repeat(longest_run(text) + 1);`, and a pad of one space on both sides when
// `text` starts or ends with a backtick (CommonMark needs it), else none:
// `format!("{ticks}{pad}{text}{pad}{ticks}")`.
todo!()
}
/// `text` as a fenced block, with a fence longer than any backtick run inside it (at least 3).
pub fn fence(text: &str) -> String {
// `let ticks = "`".repeat((longest_run(text) + 1).max(3));` then
// `format!("{ticks}\n{text}\n{ticks}")`.
todo!()
}
fn longest_run(text: &str) -> usize {
// The longest run of consecutive backticks in `text`: walk `text.chars()`, counting up on a
// backtick and back to 0 on anything else, keeping the most seen.
todo!()
}
/// `2026-09-24 14:47 UTC`.
fn minute(at: Timestamp) -> String {
// `let rfc = at.to_rfc3339();` ("2026-09-24T14:47:10.000Z"); then `format!("{} {} UTC",
// rfc.get(..10).unwrap_or_default(), rfc.get(11..16).unwrap_or_default())`.
todo!()
}
/// A serde name (`private`, `no_grant`), or the debug form if there is none.
fn name<T: serde::Serialize + std::fmt::Debug>(value: &T) -> String {
// `match serde_json::to_value(value)`: `Ok(serde_json::Value::String(s))` -> s; anything else
// -> `format!("{value:?}")`.
todo!()
}
fn reply_line(id: u64) -> String {
// `format!("Reply in this thread with `!approve {id}` or `!deny {id} <reason>`.")`.
todo!()
}
/// The posts that show `info`.
pub fn render(info: &PendingApproval) -> Shown {
let id = info.approval;
let args = escape_json_text(&info.arguments);
let header = format!(
"**Approval {id}** · tool {} · grant {} · taint {} · expires {}",
code(&escape_json_text(&info.tool)),
code(&escape_json_text(&info.grant)),
name(&info.taint),
minute(info.expires),
);
let chars: Vec<char> = args.chars().collect();
let parts: Vec<String> = if chars.is_empty() {
vec![String::new()]
} else {
chars
.chunks(PART_CHARS)
.map(|c| c.iter().collect())
.collect()
};
let count = parts.len();
let posts: Vec<String> = if count == 1 {
vec![format!("{header}\n{}\n{}", fence(&args), reply_line(id))]
} else {
let hash = sha256(args.as_bytes())
.map(|h| h.to_hex())
.unwrap_or_default();
parts
.iter()
.enumerate()
.map(|(i, part)| {
let n = i + 1;
let top = if n == 1 {
format!("{header}\nPart 1 of {count} of the arguments:")
} else {
format!("**Approval {id}**, part {n} of {count} of the arguments:")
};
let mut post = format!("{top}\n{}", fence(part));
if n == count {
post.push_str(&format!(
"\nApproval {id}: {count} parts, {} characters, sha256 {hash} (of the arguments as shown).\n{}",
chars.len(),
reply_line(id)
));
}
post
})
.collect()
};
if count > MAX_PARTS || posts.iter().any(|p| p.chars().count() > MAX_PART_POST) {
let hash = sha256(args.as_bytes())
.map(|h| h.to_hex())
.unwrap_or_default();
let post = format!(
"{header}\nThe arguments are {} characters (sha256 {hash}), too long to show here; answer it with `bxctl approvals`.",
chars.len()
);
return Shown {
posts: vec![post],
answerable: false,
};
}
Shown {
posts,
answerable: true,
}
}
/// The line added to the last post when the approval ends. `by` is `@username`, or the id.
pub fn ended_line(ended: &Ended) -> String {
// A `match ended`, one line each, exactly (`by` as given):
// 1. Runs: "✅ Approved by {by}: the call runs."
// 2. Denied: "Approved by {by}, but denied on the re-check: {name(reason)}."
// 3. Refused with Some(reason): "❌ Refused by {by}: {code(&escape_json_text(reason))}."
// 4. Refused with None: "❌ Refused by {by}: no reason given."
// 5. NoLonger: "No longer pending (answered elsewhere, expired, or its turn ended)."
// 6. Restarted: "No longer answerable here: gatewayd restarted. If it is still pending it is
// posted again."
todo!()
}
/// The last post's text with the ending line added.
pub fn with_ending(last: &str, ended: &Ended) -> String {
// `format!("{last}\n\n{}", ended_line(ended))`.
todo!()
}
@@ -0,0 +1,224 @@
//! `gateway.sock`: the listener, and one thread per `brokerd` connection (M4b spec, sections 4
//! and 6). A connection thread speaks the protocol only; everything that touches Mattermost or the
//! state file is asked of the serve loop, which alone owns them.
use std::os::unix::fs::{DirBuilderExt, FileTypeExt, PermissionsExt};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::Path;
use std::sync::Arc;
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender, TryRecvError};
use std::time::Duration;
use proto::{
ApprovalPosted, ApprovalReply, Envelope, ErrorCode, FrameError, GatewayOutcome, Message,
PROTOCOL_VERSION, PendingApproval, WireError, read_frame, write_frame,
};
/// A log line printer, shared by the threads.
pub type Log = Arc<dyn Fn(&str) + Send + Sync>;
/// What a connection thread asks of the serve loop.
#[derive(Debug)]
pub enum ToLoop {
/// Post this approval; an accepted answer comes back on `answer`, and the post ids (or why
/// it could not be posted) on `shown`.
Show {
info: PendingApproval,
answer: Sender<ApprovalReply>,
shown: Sender<Result<Vec<String>, String>>,
},
/// It ended: edit its last post, and forget it.
Ended { approval: u64, ending: Ending },
}
/// How an approval ended, as far as this `gatewayd` knows.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Ending {
/// Answered here, by `by`, and `brokerd` said what came of it.
Answered {
by: String,
reason: Option<String>,
outcome: GatewayOutcome,
},
/// `brokerd` closed first, or said nothing after the answer.
NoLonger,
}
/// How long to wait: for the first frame, for the loop to post, for `brokerd`'s outcome; and how
/// often to look for an answer meanwhile.
#[derive(Debug, Clone, Copy)]
pub struct Timing {
pub first_frame: Duration,
pub posting: Duration,
pub outcome: Duration,
pub tick: Duration,
}
impl Default for Timing {
fn default() -> Self {
Timing {
first_frame: Duration::from_secs(10),
posting: Duration::from_secs(120),
outcome: Duration::from_secs(30),
tick: Duration::from_millis(100),
}
}
}
/// Listen on `socket`: its directory made 0700, a stale socket removed, the socket made 0600.
pub fn listen(socket: &Path) -> Result<UnixListener, String> {
// 1. The directory: `socket.parent()`, which must not be empty, else "cannot prepare <socket>:
// a socket needs a directory".
// 2. `std::fs::DirBuilder::new().recursive(true).mode(0o700).create(dir)`, then
// `set_permissions(dir, 0o700)`; an error is "cannot prepare <dir>: <e>".
// 3. `std::fs::symlink_metadata(socket)`: a socket (`file_type().is_socket()`) is removed
// (stale); anything else there is "cannot listen on <socket>: something else is there";
// nothing there is fine.
// 4. `UnixListener::bind(socket)`, then `set_permissions(socket, 0o600)`; an error is "cannot
// listen on <socket>: <e>".
// 5. Ok(listener). Paths are shown with `.display()`.
todo!()
}
/// Accept connections on a thread of their own, each served on its own thread.
pub fn serve(
listener: UnixListener,
to_loop: Sender<ToLoop>,
timing: Timing,
log: Log,
) -> std::io::Result<()> {
// Spawn (with `std::thread::Builder`, `?`) a thread that loops over `listener.incoming()`,
// skipping errors. For each stream: clone `to_loop` and `log`, and spawn a thread running
// `connection(stream, &to_loop, timing, &log)`; if that spawn fails, log "gatewayd: cannot
// start a thread for an approval: <e>". Then Ok(()).
todo!()
}
fn frame(id: u64, last: bool, msg: Message) -> Envelope {
Envelope {
v: PROTOCOL_VERSION,
id,
r#final: last,
msg,
}
}
/// Every frame `brokerd` sends, read whole on a thread of its own; shutting the stream down ends it.
fn frames(stream: &UnixStream) -> std::io::Result<Receiver<Result<Envelope, FrameError>>> {
// As in `brokerd`'s courier: `let mut reader = stream.try_clone()?;`, a channel, and a thread
// (`std::thread::Builder`, `?`) looping `let frame = read_frame(&mut reader); let last =
// frame.is_err(); if tx.send(frame).is_err() || last { return; }`. Then `Ok(rx)`.
todo!()
}
/// One `brokerd` connection, frames 1 to 4 of the spec's section 4.
pub fn connection(mut stream: UnixStream, to_loop: &Sender<ToLoop>, timing: Timing, log: &Log) {
// 1. The request, within `first_frame`.
let _ = stream.set_read_timeout(Some(timing.first_frame));
let info = match read_frame(&mut stream) {
Ok(Envelope {
id: 1,
r#final: true,
msg: Message::ApprovalRequest(req),
..
}) => req.approval,
_ => return,
};
let _ = stream.set_read_timeout(None);
let id = info.approval;
// 2. The loop posts it.
let (answer_tx, answer_rx) = mpsc::channel();
let (shown_tx, shown_rx) = mpsc::channel();
if to_loop
.send(ToLoop::Show {
info,
answer: answer_tx,
shown: shown_tx,
})
.is_err()
{
return;
}
let posts = match shown_rx.recv_timeout(timing.posting) {
Ok(Ok(posts)) => posts,
Ok(Err(why)) => {
let e = WireError {
code: ErrorCode::Internal,
detail: why,
};
let _ = write_frame(&mut stream, &frame(1, true, Message::Error(e)));
return;
}
Err(_) => {
let e = WireError {
code: ErrorCode::Internal,
detail: "gatewayd did not post it in time".to_string(),
};
let _ = write_frame(&mut stream, &frame(1, true, Message::Error(e)));
return;
}
};
let rx = match frames(&stream) {
Ok(rx) => rx,
Err(_) => return end(to_loop, id, Ending::NoLonger),
};
if write_frame(
&mut stream,
&frame(1, false, Message::ApprovalPosted(ApprovalPosted { posts })),
)
.is_err()
{
return end(to_loop, id, Ending::NoLonger);
}
// 3. An answer from an approver, or brokerd closing first.
let mut answers = Some(answer_rx);
let reply = loop {
if let Some(answers_rx) = &answers {
match answers_rx.recv_timeout(timing.tick) {
Ok(reply) => break reply,
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => answers = None,
}
} else {
std::thread::sleep(timing.tick);
}
match rx.try_recv() {
Err(TryRecvError::Empty) => {}
// brokerd closed (the approval ended elsewhere), or sent what it may not.
_ => {
let _ = stream.shutdown(std::net::Shutdown::Both);
return end(to_loop, id, Ending::NoLonger);
}
}
};
let (by, reason) = (reply.by.clone(), reply.reason.clone());
if write_frame(&mut stream, &frame(1, true, Message::ApprovalReply(reply))).is_err() {
return end(to_loop, id, Ending::NoLonger);
}
// 4. What came of it.
let ending = match rx.recv_timeout(timing.outcome) {
Ok(Ok(Envelope {
id: 2,
r#final: true,
msg: Message::ApprovalOutcome(o),
..
})) => Ending::Answered {
by,
reason,
outcome: o.outcome,
},
_ => {
(log)(&format!(
"gatewayd: approval {id}: brokerd said nothing after the answer"
));
Ending::NoLonger
}
};
let _ = stream.shutdown(std::net::Shutdown::Both);
end(to_loop, id, ending);
}
fn end(to_loop: &Sender<ToLoop>, approval: u64, ending: Ending) {
// `let _ = to_loop.send(ToLoop::Ended { approval, ending });`
todo!()
}
@@ -0,0 +1,143 @@
//! The serve loop's part in approvals (M4b spec, sections 6 and 8): posting what a `gateway.sock`
//! connection brings, marking it when it ends, and marking what a restart left behind. The loop
//! alone touches Mattermost and the state file; connection threads only ask.
use std::sync::mpsc::Sender;
use proto::{ApprovalReply, GatewayOutcome, PendingApproval};
use crate::approvals::Posted;
use crate::approvals::text::{self, Ended};
use crate::gateway::{Ending, ToLoop};
use crate::serve::{Gateway, Stop};
use crate::state::ApprovalPosts;
/// An approval's last post, the one that is edited when it ends.
#[derive(Debug)]
pub(super) struct Open {
pub(super) last_post: String,
pub(super) last_text: String,
}
impl Gateway {
/// Everything the connection threads asked since the last look, never blocking.
pub(super) fn gateway_requests(&mut self) -> Result<(), Stop> {
while let Ok(asked) = self.gateway_rx.try_recv() {
match asked {
ToLoop::Show {
info,
answer,
shown,
} => {
let result = self.show(&info, answer)?;
let _ = shown.send(result);
}
ToLoop::Ended { approval, ending } => self.ended(approval, ending)?,
}
}
Ok(())
}
/// Where an approval goes: its session's turn in flight, or the first approver's direct
/// messages as a new thread (an empty root).
fn place(&self, info: &PendingApproval) -> Result<(String, String), String> {
// 1. `self.state.turn_of(info.session.as_str())`: if Some(turn), `Ok((turn.channel,
// turn.root))`.
// 2. Otherwise the first approver's direct messages, as a new thread:
// `self.config.first_approver()` (None is Err("no approver is configured")), then
// `self.client.direct_channel(&self.me.id, first)` (an Err(e) is Err(format!("no direct
// channel with the first approver: {e}"))). `Ok((channel, String::new()))`.
todo!()
}
/// Post an approval's parts in order, record them, and register it for answers. An `Err`
/// inside is why it could not be posted; the outer one stops `gatewayd` (the state file).
fn show(
&mut self,
info: &PendingApproval,
answer: Sender<ApprovalReply>,
) -> Result<Result<Vec<String>, String>, Stop> {
let id = info.approval;
let (channel, mut root) = match self.place(info) {
Ok(place) => place,
Err(why) => return Ok(Err(why)),
};
let shown = text::render(info);
let mut posts = Vec::new();
let mut shown_at = 0;
for body in &shown.posts {
match self.client.create_post(&channel, &root, body) {
Ok(post) => {
if root.is_empty() {
root = post.id.clone();
}
shown_at = post.create_at;
posts.push(post.id);
}
Err(e) => {
// Parts already posted must not look answerable.
if let (Some(last), Some(text)) =
(posts.last(), shown.posts.get(posts.len().wrapping_sub(1)))
{
self.mark(id, last, &text::with_ending(text, &Ended::NoLonger));
}
return Ok(Err(format!("cannot post approval {id}: {e}")));
}
}
}
self.state.add_approval(ApprovalPosts {
approval: id,
channel: channel.clone(),
posts: posts.clone(),
})?;
self.registry.add(Posted {
approval: id,
session: info.session.as_str().to_string(),
channel,
root,
shown_at,
answerable: shown.answerable,
answer,
});
if let (Some(last_post), Some(last_text)) = (posts.last(), shown.posts.last()) {
self.open.insert(
id,
Open {
last_post: last_post.clone(),
last_text: last_text.clone(),
},
);
}
Ok(Ok(posts))
}
/// An approval ended: forget it, and add how it ended to its last post.
fn ended(&mut self, id: u64, ending: Ending) -> Result<(), Stop> {
// 1. `self.registry.remove(id);` and `self.state.remove_approval(id)?;`.
// 2. `let Some(open) = self.open.remove(&id) else { return Ok(()); };`
// 3. The `Ended`: Ending::NoLonger -> Ended::NoLonger. Ending::Answered { by, reason,
// outcome } -> `by` becomes "@<username>" from `self.client.username(&by)`, or stays the
// id if that fails; then outcome Runs -> Ended::Runs { by }, Denied { reason } ->
// Ended::Denied { by, reason }, Refused -> Ended::Refused { by, reason }, Gone ->
// Ended::NoLonger.
// 4. `self.mark(id, &open.last_post, &text::with_ending(&open.last_text, &ended));` and
// Ok(()).
todo!()
}
/// At the first connection after a start: the approval posts the last run left are no longer
/// answerable. Their last post is replaced by its title and the restart line.
pub(super) fn mark_left_behind(&mut self) -> Result<(), Stop> {
// For each `left` in `self.state.take_approvals()?`: if it has a last post,
// `self.mark(left.approval, last, &body)` with `body = format!("**Approval {}**\n\n{}",
// left.approval, text::ended_line(&Ended::Restarted))`. Then Ok(()).
todo!()
}
/// Edit a post; a failure is logged, not retried.
fn mark(&self, id: u64, post: &str, body: &str) {
// `if let Err(e) = self.client.patch_post(post, body)`: log "gatewayd: cannot mark approval
// {id} as ended: {e}" with `(self.log)(&line)`.
todo!()
}
}
@@ -0,0 +1,166 @@
//! Which commands answer an approval, in the order of the M4b spec, section 7; everything else is
//! answered with its reason and changes nothing. Do not edit.
use std::sync::mpsc::{self, Receiver};
use gatewayd::approvals::{Judged, NOT_APPROVER, Posted, Registry, USAGE};
use gatewayd::sessions::ApprovalCommand::{self, Approve, Deny, Malformed};
use proto::ApprovalReply;
const KYLE: &str = "k0000000000000000000000000";
const HERMES: &str = "h0000000000000000000000000";
const DM: &str = "d0000000000000000000000000";
const ROOT: &str = "r0000000000000000000000000";
const ELSEWHERE: &str = "e0000000000000000000000000";
const CMD: &str = "c0000000000000000000000001";
fn approvers() -> Vec<String> {
vec![KYLE.to_string()]
}
/// A registry with approval 42 posted in ROOT, shown at 100.
fn posted(answerable: bool) -> (Registry, Receiver<ApprovalReply>) {
let registry = Registry::new();
let (tx, rx) = mpsc::channel();
registry.add(Posted {
approval: 42,
session: format!("mm-{ROOT}"),
channel: DM.to_string(),
root: ROOT.to_string(),
shown_at: 100,
answerable,
answer: tx,
});
(registry, rx)
}
fn judge(r: &Registry, command: &ApprovalCommand, user: &str, root: &str, at: i64) -> Judged {
r.judge(command, user, root, CMD, at, &approvers())
}
#[test]
fn an_approver_in_the_thread_after_the_post_answers_it() {
let (r, rx) = posted(true);
assert_eq!(judge(&r, &Approve(42), KYLE, ROOT, 101), Judged::Sent);
assert_eq!(
rx.try_recv().unwrap(),
ApprovalReply {
approval: 42,
approved: true,
by: KYLE.to_string(),
post: CMD.to_string(),
reason: None
}
);
assert!(!r.holds(42), "answered once");
assert_eq!(
judge(&r, &Approve(42), KYLE, ROOT, 102),
Judged::Reply("approval 42 is not pending here".to_string())
);
assert!(rx.try_recv().is_err(), "a second answer goes nowhere");
}
#[test]
fn a_refusal_carries_its_reason() {
let (r, rx) = posted(true);
assert_eq!(
judge(
&r,
&Deny(42, Some("not on a Friday".to_string())),
KYLE,
ROOT,
101
),
Judged::Sent
);
let got = rx.try_recv().unwrap();
assert_eq!(
(got.approved, got.reason.as_deref()),
(false, Some("not on a Friday"))
);
let (r, rx) = posted(true);
assert_eq!(judge(&r, &Deny(42, None), KYLE, ROOT, 101), Judged::Sent);
assert_eq!(rx.try_recv().unwrap().reason, None);
}
#[test]
fn everything_else_is_refused_with_its_reason_in_order() {
let cases: &[(&ApprovalCommand, &str, &str, i64, &str)] = &[
(&Approve(42), HERMES, ROOT, 101, NOT_APPROVER),
(&Malformed, HERMES, ROOT, 101, NOT_APPROVER),
(&Malformed, KYLE, ROOT, 101, USAGE),
(
&Approve(7),
KYLE,
ROOT,
101,
"approval 7 is not pending here",
),
(
&Approve(42),
KYLE,
ELSEWHERE,
101,
"approval 42 is answered in its own thread",
),
(
&Approve(42),
KYLE,
ROOT,
100,
"approval 42 was answered before it was shown in full",
),
(
&Approve(42),
KYLE,
ROOT,
50,
"approval 42 was answered before it was shown in full",
),
(
&Approve(42),
KYLE,
ELSEWHERE,
50,
"approval 42 is answered in its own thread",
),
];
for (command, user, root, at, want) in cases {
let (r, rx) = posted(true);
assert_eq!(
judge(&r, command, user, root, *at),
Judged::Reply(want.to_string()),
"{command:?} {user} {root} {at}"
);
assert!(r.holds(42), "still pending after {command:?}");
assert!(rx.try_recv().is_err(), "nothing sent for {command:?}");
}
}
#[test]
fn too_long_to_show_is_answered_only_in_bxctl() {
let (r, rx) = posted(false);
assert_eq!(
judge(&r, &Approve(42), KYLE, ROOT, 101),
Judged::Reply("approval 42 can only be answered with bxctl approvals".to_string())
);
assert!(r.holds(42) && rx.try_recv().is_err());
}
#[test]
fn an_answer_whose_connection_is_gone_is_not_pending() {
let (r, rx) = posted(true);
drop(rx);
assert_eq!(
judge(&r, &Approve(42), KYLE, ROOT, 101),
Judged::Reply("approval 42 is not pending here".to_string())
);
}
#[test]
fn the_waiting_sessions_and_removal() {
let (r, _rx) = posted(true);
assert_eq!(r.waiting_sessions(), [format!("mm-{ROOT}")]);
assert_eq!(r.remove(42).map(|p| p.approval), Some(42));
assert!(r.waiting_sessions().is_empty() && !r.holds(42) && r.remove(42).is_none());
}
@@ -0,0 +1,197 @@
//! The text of an approval's posts and of its ending line (M4b spec, section 6). Do not edit.
use gatewayd::approvals::text::{
Ended, MAX_PART_POST, MAX_PARTS, PART_CHARS, code, ended_line, fence, render, with_ending,
};
use proto::{CallId, DataClass, DenyReason, PendingApproval, SessionId, Timestamp, sha256};
/// The escape for one code point, built here so this file holds no such character itself.
fn esc(code: u32) -> String {
format!("{}u{code:04x}", char::from(92))
}
fn info(arguments: &str) -> PendingApproval {
PendingApproval {
approval: 42,
session: SessionId::new("mm-r0000000000000000000000001").unwrap(),
call: CallId(3),
tool: "shell".to_string(),
arguments: arguments.to_string(),
grant: "ops-shell".to_string(),
taint: DataClass::Private,
created: Timestamp::parse("2026-09-24T14:32:10.000Z").unwrap(),
expires: Timestamp::parse("2026-09-24T14:47:10.000Z").unwrap(),
}
}
#[test]
fn one_part_exactly() {
let shown = render(&info(
r#"{"command":"ls -la /srv/backups","timeout_ms":10000}"#,
));
assert!(shown.answerable);
assert_eq!(
shown.posts,
[
"**Approval 42** · tool `shell` · grant `ops-shell` · taint private · expires 2026-09-24 14:47 UTC\n```\n{\"command\":\"ls -la /srv/backups\",\"timeout_ms\":10000}\n```\nReply in this thread with `!approve 42` or `!deny 42 <reason>`."
]
);
}
#[test]
fn fences_and_code_outrun_backticks() {
assert_eq!(fence("a"), "```\na\n```");
assert_eq!(fence("a ``` b ```` c"), "`````\na ``` b ```` c\n`````");
assert_eq!(code("ops"), "`ops`");
assert_eq!(code("a`b"), "``a`b``");
assert_eq!(code("`x"), "`` `x ``");
let shown = render(&info(r#"{"command":"echo ```` done"}"#));
assert!(
shown.posts[0].contains("\n`````\n{\"command\":\"echo ```` done\"}\n`````\n"),
"{}",
shown.posts[0]
);
}
#[test]
fn what_must_not_reach_the_reader_raw_is_escaped() {
let raw = "a\u{7}b\u{202e}c\u{200b}d\u{2028}e\u{feff}f\nnewline";
let shown = render(&info(&format!("{{\"command\":\"{raw}\"}}")));
let post = &shown.posts[0];
for bad in ['\u{7}', '\u{202e}', '\u{200b}', '\u{2028}', '\u{feff}'] {
assert!(!post.contains(bad), "{bad:?} reached the post");
}
let want = format!(
"a{}b{}c{}d{}e{}f{}newline",
esc(0x7),
esc(0x202e),
esc(0x200b),
esc(0x2028),
esc(0xfeff),
esc(0xa)
);
assert!(post.contains(&want), "{post}");
let mut odd = info("{}");
odd.grant = "g`rant\u{202e}".to_string();
assert!(
render(&odd).posts[0].contains(&format!("grant ``g`rant{}``", esc(0x202e))),
"{}",
render(&odd).posts[0]
);
}
#[test]
fn long_arguments_come_in_parts_with_a_hash() {
let args = format!("{{\"content\":\"{}\"}}", "é".repeat(PART_CHARS * 2));
let shown = render(&info(&args));
assert!(shown.answerable);
assert_eq!(shown.posts.len(), 3);
let total = args.chars().count();
assert!(shown.posts[0].starts_with("**Approval 42** · tool `shell` · grant `ops-shell` · taint private · expires 2026-09-24 14:47 UTC\nPart 1 of 3 of the arguments:\n```\n{\"content\":\""));
assert!(shown.posts[1].starts_with("**Approval 42**, part 2 of 3 of the arguments:\n```\n"));
let hash = sha256(args.as_bytes()).unwrap().to_hex();
let last = &shown.posts[2];
assert!(last.ends_with(&format!("\nApproval 42: 3 parts, {total} characters, sha256 {hash} (of the arguments as shown).\nReply in this thread with `!approve 42` or `!deny 42 <reason>`.")), "{last}");
assert!(
!shown.posts[0].contains("Reply in this thread")
&& !shown.posts[1].contains("Reply in this thread")
);
let shown_again: String = shown
.posts
.iter()
.map(|p| {
let body = p.split_once("\n```\n").unwrap().1;
body.split_once("\n```").unwrap().0.to_string()
})
.collect();
assert_eq!(
shown_again, args,
"the parts, joined, are the arguments: nothing lost"
);
assert!(
shown
.posts
.iter()
.all(|p| p.chars().count() <= MAX_PART_POST)
);
}
#[test]
fn too_long_to_show_is_answerable_only_in_bxctl() {
let args = "x".repeat(PART_CHARS * MAX_PARTS + 1);
let shown = render(&info(&args));
assert!(!shown.answerable);
let hash = sha256(args.as_bytes()).unwrap().to_hex();
assert_eq!(
shown.posts,
[format!(
"**Approval 42** · tool `shell` · grant `ops-shell` · taint private · expires 2026-09-24 14:47 UTC\nThe arguments are {} characters (sha256 {hash}), too long to show here; answer it with `bxctl approvals`.",
args.len()
)]
);
let exactly = "x".repeat(PART_CHARS * MAX_PARTS);
assert!(
render(&info(&exactly)).answerable,
"four full parts still show"
);
let ticks = format!("{}{}", "`".repeat(PART_CHARS - 10), "x".repeat(20));
assert!(
!render(&info(&ticks)).answerable,
"a fence that would overflow the post is not shown"
);
}
#[test]
fn ending_lines() {
let kyle = "@kyle".to_string();
assert_eq!(
ended_line(&Ended::Runs { by: kyle.clone() }),
"✅ Approved by @kyle: the call runs."
);
assert_eq!(
ended_line(&Ended::Denied {
by: kyle.clone(),
reason: DenyReason::GrantExpired
}),
"Approved by @kyle, but denied on the re-check: grant_expired."
);
assert_eq!(
ended_line(&Ended::Refused {
by: kyle.clone(),
reason: None
}),
"❌ Refused by @kyle: no reason given."
);
assert_eq!(
ended_line(&Ended::Refused {
by: kyle.clone(),
reason: Some("not @channel `now`".to_string())
}),
"❌ Refused by @kyle: `` not @channel `now` ``."
);
assert_eq!(
ended_line(&Ended::NoLonger),
"No longer pending (answered elsewhere, expired, or its turn ended)."
);
assert_eq!(
ended_line(&Ended::Restarted),
"No longer answerable here: gatewayd restarted. If it is still pending it is posted again."
);
assert_eq!(
with_ending("post", &Ended::NoLonger),
"post\n\nNo longer pending (answered elsewhere, expired, or its turn ended)."
);
let longest = render(&info(&"y".repeat(PART_CHARS))).posts.remove(0);
let ended = with_ending(
&longest,
&Ended::Refused {
by: format!("@{}", "u".repeat(64)),
reason: Some("r".repeat(500)),
},
);
assert!(
ended.chars().count() <= 16_383,
"an ended post still fits: {}",
ended.chars().count()
);
}
@@ -0,0 +1,68 @@
//! `[approvals]` in `gatewayd.toml`: who may answer, the first approver, and `gateway.sock`
//! (M4b spec, section 3). Do not edit.
use std::path::PathBuf;
use gatewayd::config::Config;
const KYLE: &str = "k0000000000000000000000000";
const HERMES: &str = "h0000000000000000000000000";
const OTHER: &str = "o0000000000000000000000000";
fn text(approvals: &str) -> String {
format!(
"[mattermost]\nurl = \"https://chat.example\"\n[secrets.mattermost_token]\nenv = \"T\"\n[allow]\nusers = [\"{KYLE}\", \"{HERMES}\"]\n[paths]\nhome = \"/srv/bx\"\n{approvals}"
)
}
fn config(approvals: &str) -> Config {
let c = Config::parse(&text(approvals)).unwrap();
assert_eq!(c.problem(), None);
c
}
#[test]
fn without_the_section_everyone_allowed_may_answer() {
let c = config("");
assert_eq!(c.approvers(), [KYLE, HERMES]);
assert_eq!(c.first_approver(), Some(KYLE));
assert_eq!(
c.gateway_socket(),
PathBuf::from("/srv/bx/run/gateway/gateway.sock")
);
assert_eq!(
config("[approvals]\nusers = []\n").approvers(),
[KYLE, HERMES]
);
}
#[test]
fn a_list_narrows_who_may_answer() {
let c = config(&format!(
"[approvals]\nusers = [\"{HERMES}\"]\nsocket = \"/run/gw.sock\"\n"
));
assert_eq!(c.approvers(), [HERMES]);
assert_eq!(c.first_approver(), Some(HERMES));
assert_eq!(c.gateway_socket(), PathBuf::from("/run/gw.sock"));
}
#[test]
fn an_approver_must_be_heard() {
let c = Config::parse(&text(&format!(
"[approvals]\nusers = [\"{KYLE}\", \"{OTHER}\"]\n"
)))
.unwrap();
let why = c.problem().expect("refused");
assert_eq!(
why,
format!(
"[approvals] \"{OTHER}\" is not in [allow] users: an approver who is not heard could never answer"
)
);
}
#[test]
fn unknown_keys_are_errors() {
assert!(Config::parse(&text("[approvals]\napprovers = []\n")).is_err());
assert!(Config::parse(&text("[approvals]\nusers = []\nttl = 5\n")).is_err());
}
@@ -0,0 +1,313 @@
//! A turn on `loop.sock` and its answer in the thread, against a fake `loopd`: the answer, long
//! answers, approvals, errors, an unknown session, and a loop that is not there (M4a spec, section
//! 8; M4b spec, section 7, for the notice). Do not edit.
#[path = "support/fake_loop.rs"]
mod fake_loop;
#[path = "support/tmp.rs"]
mod tmp;
use std::sync::Mutex;
use std::time::Duration;
use fake_loop::{Reply, done, error, event, serve_loop};
use gatewayd::deliver::{EMPTY_ANSWER, LOOP_DOWN, MAX_POST, Notice, Poster, deliver, split_answer};
use gatewayd::mm::MmError;
use gatewayd::sessions::{Batch, Thread};
use proto::{DataClass, ErrorCode, SessionId, Timestamp, TurnEvent};
use tmp::TempDir;
const DM: &str = "d0000000000000000000000000";
const ROOT: &str = "r0000000000000000000000000";
#[derive(Default)]
struct Record {
posts: Mutex<Vec<(String, String, String)>>,
fail: bool,
}
impl Poster for Record {
fn post(&self, channel: &str, root: &str, text: &str) -> Result<(), MmError> {
if self.fail {
return Err(MmError::Status(500, "down".to_string()));
}
self.posts
.lock()
.unwrap()
.push((channel.to_string(), root.to_string(), text.to_string()));
Ok(())
}
}
impl Record {
fn texts(&self) -> Vec<String> {
let posts = self.posts.lock().unwrap();
assert!(
posts.iter().all(|(c, r, _)| c == DM && r == ROOT),
"every post in the thread"
);
posts.iter().map(|(_, _, t)| t.clone()).collect()
}
}
fn batch(resume: bool, text: &str) -> Batch {
Batch {
session: SessionId::new(&format!("mm-{ROOT}")).unwrap(),
thread: Thread {
channel: DM.to_string(),
root: ROOT.to_string(),
},
resume,
text: text.to_string(),
}
}
fn run(dir: &TempDir, poster: &Record, batch: &Batch) -> Vec<String> {
let log = Mutex::new(Vec::new());
let never = |_: u64| false;
let notice = Notice {
posted: &never,
wait: Duration::ZERO,
};
deliver(
poster,
&dir.path().join("loop.sock"),
batch,
&|line| log.lock().unwrap().push(line.to_string()),
&notice,
);
log.into_inner().unwrap()
}
#[test]
fn the_answer_is_posted_and_nothing_else() {
let dir = TempDir::new("deliver-answer");
let turns = serve_loop(&dir.path().join("loop.sock"), |_, _| {
vec![
event(TurnEvent::Progress {
total: 10,
cache: 0,
processed: 10,
}),
event(TurnEvent::Reasoning {
text: "private thoughts".to_string(),
}),
event(TurnEvent::ToolCallStarted {
name: "read_file".to_string(),
}),
event(TurnEvent::ToolResult {
name: "read_file".to_string(),
class: DataClass::Private,
truncated: false,
}),
event(TurnEvent::Content {
text: "The ans".to_string(),
}),
done("The answer."),
]
});
let poster = Record::default();
let log = run(&dir, &poster, &batch(true, "one\n\ntwo"));
assert_eq!(poster.texts(), ["The answer."]);
assert!(log.is_empty(), "{log:?}");
let turn = turns.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(
(turn.session.as_str(), turn.content.as_str(), turn.resume),
(format!("mm-{ROOT}").as_str(), "one\n\ntwo", true)
);
}
#[test]
fn an_approval_is_announced_once_before_the_answer() {
let dir = TempDir::new("deliver-approval");
let expires = Timestamp::from_unix_millis(1_758_650_000_000).unwrap();
let _turns = serve_loop(&dir.path().join("loop.sock"), move |_, _| {
vec![
event(TurnEvent::ApprovalPending {
approval: 42,
tool: "shell".to_string(),
expires,
}),
done("done"),
]
});
let poster = Record::default();
run(&dir, &poster, &batch(false, "go"));
assert_eq!(
poster.texts(),
[
"waiting for approval 42: brokerd has not sent it here; answer it with bxctl approvals",
"done"
]
);
}
#[test]
fn errors_are_posted_with_their_code() {
let dir = TempDir::new("deliver-error");
let _turns = serve_loop(&dir.path().join("loop.sock"), |_, _| {
vec![error(
ErrorCode::Inference,
"the model server failed\nsee docs/runbook.md#loopd-selftest-failed",
)]
});
let poster = Record::default();
run(&dir, &poster, &batch(false, "go"));
assert_eq!(
poster.texts(),
["Error: inference: the model server failed\nsee docs/runbook.md#loopd-selftest-failed"]
);
}
#[test]
fn a_reply_in_a_thread_loopd_does_not_know_creates_the_session() {
let dir = TempDir::new("deliver-unknown");
let turns = serve_loop(&dir.path().join("loop.sock"), |n, _| {
if n == 0 {
vec![error(ErrorCode::NoSuchSession, "no such session")]
} else {
vec![done("hello")]
}
});
let poster = Record::default();
run(&dir, &poster, &batch(true, "hi"));
assert_eq!(poster.texts(), ["hello"]);
let first = turns.recv_timeout(Duration::from_secs(5)).unwrap();
let second = turns.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(
(first.resume, second.resume, second.content.as_str()),
(true, false, "hi")
);
}
#[test]
fn a_new_session_is_not_retried() {
let dir = TempDir::new("deliver-noretry");
let turns = serve_loop(&dir.path().join("loop.sock"), |_, _| {
vec![error(ErrorCode::NoSuchSession, "odd")]
});
let poster = Record::default();
run(&dir, &poster, &batch(false, "hi"));
assert_eq!(poster.texts(), ["Error: no_such_session: odd"]);
assert!(turns.recv_timeout(Duration::from_secs(5)).is_ok());
assert!(
turns.recv_timeout(Duration::from_millis(200)).is_err(),
"one turn only"
);
}
#[test]
fn a_loop_that_is_not_there_or_goes_away() {
let dir = TempDir::new("deliver-down");
let poster = Record::default();
let log = run(&dir, &poster, &batch(false, "hi"));
assert_eq!(poster.texts(), [LOOP_DOWN]);
assert_eq!(log.len(), 1, "{log:?}");
assert!(
log[0].starts_with(&format!("gatewayd: mm-{ROOT}: cannot connect to ")),
"{log:?}"
);
for (n, replies) in [
vec![
event(TurnEvent::Content {
text: "x".to_string(),
}),
Reply::Close,
],
vec![Reply::Bytes(b"\x00\x00\x00\x05{bad}".to_vec())],
vec![
Reply::Frame(proto::Envelope {
v: 1,
id: 2,
r#final: true,
msg: proto::Message::Ok(proto::Empty {}),
}),
done("late"),
],
]
.into_iter()
.enumerate()
{
let dir = TempDir::new(&format!("deliver-early-{n}"));
let replies = Mutex::new(Some(replies));
let _turns = serve_loop(&dir.path().join("loop.sock"), move |_, _| {
replies.lock().unwrap().take().unwrap_or_default()
});
let poster = Record::default();
let log = run(&dir, &poster, &batch(false, "hi"));
assert_eq!(poster.texts(), [LOOP_DOWN], "case {n}");
assert_eq!(log.len(), 1, "case {n}: {log:?}");
}
}
#[test]
fn a_post_that_fails_is_logged() {
let dir = TempDir::new("deliver-postfail");
let _turns = serve_loop(&dir.path().join("loop.sock"), |_, _| vec![done("lost")]);
let poster = Record {
fail: true,
..Record::default()
};
let log = run(&dir, &poster, &batch(false, "hi"));
assert_eq!(
log,
[format!(
"gatewayd: cannot post in {DM} (thread {ROOT}): status 500: \"down\""
)]
);
}
#[test]
fn long_answers_are_split_at_newlines() {
let short = "a".repeat(MAX_POST);
assert_eq!(split_answer(&short), [short.as_str()]);
let over = "a".repeat(MAX_POST + 1);
assert_eq!(split_answer(&over), ["a".repeat(MAX_POST), "a".to_string()]);
let lines = format!(
"{}\n{}\n{}",
"a".repeat(10_000),
"b".repeat(5_000),
"c".repeat(2_000)
);
assert_eq!(
split_answer(&lines),
[
format!("{}\n{}", "a".repeat(10_000), "b".repeat(5_000)),
"c".repeat(2_000)
]
);
let wide = "é".repeat(MAX_POST + 5);
let parts = split_answer(&wide);
assert_eq!(
parts.iter().map(|p| p.chars().count()).collect::<Vec<_>>(),
[MAX_POST, 5],
"characters, not bytes"
);
let leading = format!("\n{}", "x".repeat(MAX_POST + 1));
let parts = split_answer(&leading);
assert!(
parts
.iter()
.all(|p| !p.is_empty() && p.chars().count() <= MAX_POST),
"{:?}",
parts.iter().map(|p| p.len()).collect::<Vec<_>>()
);
assert_eq!(parts.concat(), leading, "a hard cut drops nothing");
assert_eq!(split_answer(""), [EMPTY_ANSWER]);
assert_eq!(split_answer(" \n "), [EMPTY_ANSWER]);
}
#[test]
fn a_long_answer_is_posted_in_order() {
let dir = TempDir::new("deliver-long");
let answer = format!("{}\n{}", "a".repeat(MAX_POST - 1), "b".repeat(MAX_POST));
let sent = answer.clone();
let _turns = serve_loop(&dir.path().join("loop.sock"), move |_, _| vec![done(&sent)]);
let poster = Record::default();
run(&dir, &poster, &batch(false, "hi"));
assert_eq!(
poster.texts(),
["a".repeat(MAX_POST - 1), "b".repeat(MAX_POST)]
);
}
@@ -0,0 +1,95 @@
//! The notice for an approval that `brokerd` did not bring: posted only when it has not arrived
//! within the wait (M4b spec, section 7). Do not edit.
#[path = "support/fake_loop.rs"]
mod fake_loop;
#[path = "support/tmp.rs"]
mod tmp;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use fake_loop::{done, event, serve_loop};
use gatewayd::deliver::{Notice, Poster, deliver};
use gatewayd::mm::MmError;
use gatewayd::sessions::{Batch, Thread};
use proto::{SessionId, Timestamp, TurnEvent};
use tmp::TempDir;
const DM: &str = "d0000000000000000000000000";
const ROOT: &str = "r0000000000000000000000000";
const NOTICE: &str =
"waiting for approval 42: brokerd has not sent it here; answer it with bxctl approvals";
#[derive(Default)]
struct Record(Mutex<Vec<String>>);
impl Poster for Record {
fn post(&self, _: &str, _: &str, text: &str) -> Result<(), MmError> {
self.0.lock().unwrap().push(text.to_string());
Ok(())
}
}
/// A turn whose loop says approval 42 is pending, then answers; what was posted, and how long it took.
fn turn(tag: &str, posted: &dyn Fn(u64) -> bool, wait: Duration) -> (Vec<String>, Duration) {
let dir = TempDir::new(tag);
let socket = dir.path().join("loop.sock");
let expires = Timestamp::parse("2026-09-24T08:20:00.000Z").unwrap();
let _turns = serve_loop(&socket, move |_, _| {
vec![
event(TurnEvent::ApprovalPending {
approval: 42,
tool: "read_file".to_string(),
expires,
}),
done("done"),
]
});
let batch = Batch {
session: SessionId::new(&format!("mm-{ROOT}")).unwrap(),
thread: Thread {
channel: DM.to_string(),
root: ROOT.to_string(),
},
resume: false,
text: "go".to_string(),
};
let poster = Record::default();
let started = Instant::now();
deliver(&poster, &socket, &batch, &|_| {}, &Notice { posted, wait });
let took = started.elapsed();
(poster.0.into_inner().unwrap(), took)
}
#[test]
fn brought_already_no_notice_and_no_wait() {
let (posts, took) = turn("notice-there", &|id| id == 42, Duration::from_secs(5));
assert_eq!(posts, ["done"]);
assert!(took < Duration::from_secs(1), "{took:?}");
}
#[test]
fn brought_during_the_wait_no_notice() {
let started = Instant::now();
let later = move |id: u64| id == 42 && started.elapsed() > Duration::from_millis(300);
let (posts, took) = turn("notice-later", &later, Duration::from_secs(5));
assert_eq!(posts, ["done"]);
assert!(took < Duration::from_secs(2), "{took:?}");
}
#[test]
fn never_brought_the_notice_after_the_wait() {
let (posts, took) = turn("notice-never", &|_| false, Duration::from_millis(400));
assert_eq!(posts, [NOTICE, "done"]);
assert!(
took >= Duration::from_millis(400) && took < Duration::from_secs(3),
"{took:?}"
);
}
#[test]
fn another_approval_brought_does_not_count() {
let (posts, _) = turn("notice-other", &|id| id == 7, Duration::from_millis(200));
assert_eq!(posts, [NOTICE, "done"]);
}
@@ -0,0 +1,288 @@
//! `gateway.sock` from `gatewayd`'s side: the socket's permissions, and one connection's frames,
//! with the test playing both the serve loop and `brokerd` (M4b spec, sections 4 and 6). Do not
//! edit.
#[path = "support/tmp.rs"]
mod tmp;
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::UnixStream;
use std::path::Path;
use std::sync::mpsc::{self, Receiver};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use gatewayd::gateway::{Ending, Timing, ToLoop, listen, serve};
use proto::{
ApprovalOutcome, ApprovalReply, ApprovalRequest, CallId, DataClass, Envelope, ErrorCode,
GatewayOutcome, Message, PROTOCOL_VERSION, PendingApproval, SessionId, Timestamp, read_frame,
write_frame,
};
use tmp::TempDir;
const KYLE: &str = "k0000000000000000000000000";
const POST: &str = "p0000000000000000000000001";
const CMD: &str = "c0000000000000000000000001";
const WAIT: Duration = Duration::from_secs(5);
fn info(approval: u64) -> PendingApproval {
PendingApproval {
approval,
session: SessionId::new("mm-r0000000000000000000000001").unwrap(),
call: CallId(3),
tool: "shell".to_string(),
arguments: "{}".to_string(),
grant: "g".to_string(),
taint: DataClass::Private,
created: Timestamp::parse("2026-09-24T08:05:00.000Z").unwrap(),
expires: Timestamp::parse("2026-09-24T08:20:00.000Z").unwrap(),
}
}
fn frame(id: u64, last: bool, msg: Message) -> Envelope {
Envelope {
v: PROTOCOL_VERSION,
id,
r#final: last,
msg,
}
}
fn timing() -> Timing {
Timing {
first_frame: Duration::from_millis(500),
posting: WAIT,
outcome: Duration::from_millis(500),
tick: Duration::from_millis(20),
}
}
/// A listening gatewayd side: the socket and what it asks of the loop.
fn start(
dir: &TempDir,
) -> (
std::path::PathBuf,
Receiver<ToLoop>,
Arc<Mutex<Vec<String>>>,
) {
let socket = dir.path().join("run/gateway/gateway.sock");
let listener = listen(&socket).unwrap();
let (tx, rx) = mpsc::channel();
let lines = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&lines);
serve(
listener,
tx,
timing(),
Arc::new(move |l: &str| sink.lock().unwrap().push(l.to_string())),
)
.unwrap();
(socket, rx, lines)
}
/// brokerd: connect and send the request.
fn request(socket: &Path, approval: u64) -> UnixStream {
let mut s = UnixStream::connect(socket).unwrap();
s.set_read_timeout(Some(WAIT)).unwrap();
write_frame(
&mut s,
&frame(
1,
true,
Message::ApprovalRequest(ApprovalRequest {
approval: info(approval),
}),
),
)
.unwrap();
s
}
fn reply(approval: u64) -> ApprovalReply {
ApprovalReply {
approval,
approved: true,
by: KYLE.to_string(),
post: CMD.to_string(),
reason: None,
}
}
#[test]
fn the_socket_is_private_and_a_stale_one_is_replaced() {
let dir = TempDir::new("gw-perms");
let socket = dir.path().join("run/gateway/gateway.sock");
drop(listen(&socket).unwrap());
let listener = listen(&socket).expect("a stale socket is replaced");
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(
(mode(&socket), mode(socket.parent().unwrap())),
(0o600, 0o700)
);
drop(listener);
let file = dir.path().join("run/gateway/not-a-socket");
std::fs::write(&file, "x").unwrap();
let why = listen(&file).unwrap_err();
assert!(
why.starts_with(&format!("cannot listen on {}", file.display())),
"{why}"
);
assert!(
dir.path().join("run/gateway/not-a-socket").exists(),
"a file that is not a socket is left alone"
);
}
#[test]
fn posted_answered_and_ended() {
let dir = TempDir::new("gw-answer");
let (socket, to_loop, _) = start(&dir);
let mut brokerd = request(&socket, 42);
let (answer, shown) = match to_loop.recv_timeout(WAIT).unwrap() {
ToLoop::Show {
info: got,
answer,
shown,
} => {
assert_eq!(got, info(42));
(answer, shown)
}
other => panic!("{other:?}"),
};
shown.send(Ok(vec![POST.to_string()])).unwrap();
let posted = read_frame(&mut brokerd).unwrap();
assert_eq!((posted.id, posted.r#final), (1, false));
assert!(
matches!(posted.msg, Message::ApprovalPosted(ref p) if p.posts == [POST]),
"{posted:?}"
);
answer.send(reply(42)).unwrap();
let answered = read_frame(&mut brokerd).unwrap();
assert_eq!(answered, frame(1, true, Message::ApprovalReply(reply(42))));
write_frame(
&mut brokerd,
&frame(
2,
true,
Message::ApprovalOutcome(ApprovalOutcome {
outcome: GatewayOutcome::Runs {},
}),
),
)
.unwrap();
match to_loop.recv_timeout(WAIT).unwrap() {
ToLoop::Ended { approval, ending } => {
assert_eq!(approval, 42);
assert_eq!(
ending,
Ending::Answered {
by: KYLE.to_string(),
reason: None,
outcome: GatewayOutcome::Runs {}
}
);
}
other => panic!("{other:?}"),
}
}
#[test]
fn brokerd_closing_first_ends_it_as_no_longer_pending() {
let dir = TempDir::new("gw-closed");
let (socket, to_loop, _) = start(&dir);
let mut brokerd = request(&socket, 7);
let ToLoop::Show { shown, answer, .. } = to_loop.recv_timeout(WAIT).unwrap() else {
panic!()
};
shown.send(Ok(vec![POST.to_string()])).unwrap();
read_frame(&mut brokerd).unwrap();
drop(brokerd);
match to_loop.recv_timeout(WAIT).unwrap() {
ToLoop::Ended { approval, ending } => assert_eq!((approval, ending), (7, Ending::NoLonger)),
other => panic!("{other:?}"),
}
assert!(
answer.send(reply(7)).is_err(),
"nobody listens for an answer any more"
);
}
#[test]
fn an_approval_that_cannot_be_posted_is_an_error_to_brokerd() {
let dir = TempDir::new("gw-cannot");
let (socket, to_loop, _) = start(&dir);
let mut brokerd = request(&socket, 8);
let ToLoop::Show { shown, .. } = to_loop.recv_timeout(WAIT).unwrap() else {
panic!()
};
shown.send(Err("Mattermost is down".to_string())).unwrap();
let got = read_frame(&mut brokerd).unwrap();
assert_eq!((got.id, got.r#final), (1, true));
match got.msg {
Message::Error(e) => assert_eq!(
(e.code, e.detail.as_str()),
(ErrorCode::Internal, "Mattermost is down")
),
other => panic!("{other:?}"),
}
assert!(
to_loop.recv_timeout(Duration::from_millis(300)).is_err(),
"nothing more for the loop"
);
}
#[test]
fn no_outcome_after_the_answer_is_no_longer_pending_and_logged() {
let dir = TempDir::new("gw-silent");
let (socket, to_loop, lines) = start(&dir);
let mut brokerd = request(&socket, 9);
let ToLoop::Show { shown, answer, .. } = to_loop.recv_timeout(WAIT).unwrap() else {
panic!()
};
shown.send(Ok(vec![POST.to_string()])).unwrap();
read_frame(&mut brokerd).unwrap();
answer.send(reply(9)).unwrap();
read_frame(&mut brokerd).unwrap();
match to_loop.recv_timeout(WAIT).unwrap() {
ToLoop::Ended { approval, ending } => assert_eq!((approval, ending), (9, Ending::NoLonger)),
other => panic!("{other:?}"),
}
assert_eq!(
*lines.lock().unwrap(),
["gatewayd: approval 9: brokerd said nothing after the answer"]
);
}
#[test]
fn anything_but_a_request_first_is_dropped() {
let dir = TempDir::new("gw-bad");
let (socket, to_loop, _) = start(&dir);
for first in [
frame(1, true, Message::ApprovalReply(reply(1))),
frame(
2,
true,
Message::ApprovalRequest(ApprovalRequest { approval: info(1) }),
),
frame(
1,
false,
Message::ApprovalRequest(ApprovalRequest { approval: info(1) }),
),
] {
let mut s = UnixStream::connect(&socket).unwrap();
s.set_read_timeout(Some(WAIT)).unwrap();
write_frame(&mut s, &first).unwrap();
assert!(
read_frame(&mut s).is_err(),
"closed without an answer: {first:?}"
);
}
let silent = UnixStream::connect(&socket).unwrap();
std::thread::sleep(Duration::from_millis(700));
drop(silent);
assert!(
to_loop.recv_timeout(Duration::from_millis(300)).is_err(),
"nothing reached the loop"
);
}
@@ -0,0 +1,129 @@
//! Editing a post and looking up a username, against a scripted server (M4b spec, sections 6 and
//! 7). Do not edit.
#[path = "support/http_server.rs"]
mod http_server;
#[path = "support/tls_server.rs"]
mod tls_server;
use std::net::SocketAddr;
use std::time::Duration;
use gatewayd::config::ServerUrl;
use gatewayd::mm::MmError;
use gatewayd::mm::rest::Client;
use gatewayd::net::Connector;
use gatewayd::secrets::Secret;
use http_server::{reply, serve_http};
use serde_json::json;
const BOT: &str = "b0000000000000000000000000";
const KYLE: &str = "k0000000000000000000000000";
const DM: &str = "d0000000000000000000000000";
const POST: &str = "p0000000000000000000000001";
const WAIT: Duration = Duration::from_secs(5);
fn client(addr: SocketAddr) -> Client {
let url = ServerUrl {
tls: false,
host: "localhost".to_string(),
port: addr.port(),
};
Client::new(
Connector::new(url, None).unwrap(),
Secret::new("TOKEN".to_string()),
WAIT,
)
}
#[test]
fn a_post_is_patched_with_its_new_text() {
let (addr, rx) = serve_http(None, |_, r| {
let text = r.json()["message"].as_str().unwrap().to_string();
let post = json!({"id": POST, "user_id": BOT, "channel_id": DM, "root_id": "", "message": text, "create_at": 5, "is_pinned": false});
reply(200, "", &post.to_string())
});
client(addr)
.patch_post(
POST,
"**Approval 42**\n✅ Approved by @kyle: the call runs.",
)
.unwrap();
let r = rx.recv_timeout(WAIT).unwrap();
assert_eq!(
(r.method.as_str(), r.path.as_str()),
("PUT", format!("/api/v4/posts/{POST}/patch").as_str())
);
assert!(
r.head.contains("Authorization: Bearer TOKEN\r\n"),
"{}",
r.head
);
assert_eq!(
r.json(),
json!({"message": "**Approval 42**\n✅ Approved by @kyle: the call runs."})
);
}
#[test]
fn a_post_that_is_not_an_id_is_never_sent() {
let (addr, rx) = serve_http(None, |_, _| reply(200, "", "{}"));
for bad in ["../users/me", "", "P0000000000000000000000001"] {
assert!(
matches!(client(addr).patch_post(bad, "x"), Err(MmError::Json(_))),
"{bad:?}"
);
assert!(
matches!(client(addr).username(bad), Err(MmError::Json(_))),
"{bad:?}"
);
}
std::thread::sleep(Duration::from_millis(100));
assert!(rx.try_recv().is_err(), "nothing was sent");
}
#[test]
fn a_refused_edit_is_an_error() {
let (addr, _rx) = serve_http(None, |_, _| {
reply(
403,
"",
r#"{"id":"api.post.update_post.permissions_details.app_error"}"#,
)
});
assert!(matches!(
client(addr).patch_post(POST, "x"),
Err(MmError::Auth(403))
));
}
#[test]
fn the_username_of_a_user() {
let (addr, rx) = serve_http(None, |_, _| {
reply(
200,
"",
&json!({"id": KYLE, "username": "kyle", "first_name": "K", "roles": "system_user"})
.to_string(),
)
});
assert_eq!(client(addr).username(KYLE).unwrap(), "kyle");
let r = rx.recv_timeout(WAIT).unwrap();
assert_eq!(
(r.method.as_str(), r.path.as_str()),
("GET", format!("/api/v4/users/{KYLE}").as_str())
);
for bad in [
json!({"id": KYLE}),
json!({"id": KYLE, "username": ""}),
json!({"id": KYLE, "username": "Kyle\n@channel"}),
json!({"id": KYLE, "username": "x".repeat(65)}),
] {
let body = bad.to_string();
let (addr, _rx) = serve_http(None, move |_, _| reply(200, "", &body));
assert!(
matches!(client(addr).username(KYLE), Err(MmError::Json(_))),
"{bad}"
);
}
}
@@ -0,0 +1,214 @@
//! `gatewayd` end to end, against a fake Mattermost and a fake `loopd`: who is answered, where,
//! and how (M4a spec, sections 7 and 8). Do not edit.
#[path = "support/fake_loop.rs"]
mod fake_loop;
#[path = "support/fake_mm.rs"]
mod fake_mm;
#[path = "support/gateway.rs"]
mod gateway;
#[path = "support/tmp.rs"]
mod tmp;
use std::sync::{Mutex, mpsc};
use std::time::Duration;
use fake_loop::{done, serve_loop};
use fake_mm::{BOT, BOT_NAME, DM, EVE, EVE_DM, FakeMm, KYLE, id, post};
use gateway::{OTHER, SHARED, WAIT, config, loop_dir, read_state, start, up};
use gatewayd::serve::Stop;
use gatewayd::sessions::BUSY;
use tmp::TempDir;
#[test]
fn a_direct_message_is_answered_in_its_thread_while_typing() {
let home = TempDir::new("serve-dm");
let (mm, running, turns, mut ws) = up(&home, Duration::from_millis(600));
let p1 = id('p', 1);
ws.posted(&post(&p1, KYLE, DM, "", "hello there", 5), "D");
let turn = turns.recv_timeout(WAIT).unwrap();
assert_eq!(
(turn.session.as_str(), turn.content.as_str(), turn.resume),
(format!("mm-{p1}").as_str(), "hello there", false)
);
let typing = ws.typing_within(Duration::from_millis(500));
assert!(typing.len() >= 2, "{typing:?}");
assert!(
typing.iter().all(|(c, p)| c == DM && *p == p1),
"{typing:?}"
);
let posts = mm.wait_posts(1, WAIT);
assert_eq!(
posts,
[(
DM.to_string(),
p1.clone(),
"answer to hello there".to_string()
)]
);
// Typing sent just before the answer may still be on its way; after that, it stops.
ws.typing_within(Duration::from_millis(300));
assert!(
ws.typing_within(Duration::from_millis(400)).is_empty(),
"typing stops after the answer"
);
let log = running.log();
assert!(
log.iter()
.any(|l| l == &format!("gatewayd: connected to {} as {BOT_NAME}", mm.url())),
"{log:?}"
);
assert!(matches!(running.finish(), Stop::Asked));
}
#[test]
fn anyone_else_gets_nothing_at_all() {
let home = TempDir::new("serve-stranger");
let (mm, running, turns, mut ws) = up(&home, Duration::ZERO);
let p1 = id('p', 1);
ws.posted(&post(&p1, EVE, EVE_DM, "", "secret words", 5), "D");
let mut own = post(&id('p', 2), BOT, DM, "", "my own post", 6);
own["user_id"] = serde_json::json!(BOT);
ws.posted(&own, "D");
let log = running.wait_log("not allowed");
assert!(turns.recv_timeout(Duration::from_millis(300)).is_err());
assert!(ws.typing_within(Duration::from_millis(200)).is_empty());
assert!(mm.posts().is_empty());
assert!(
log.contains(&format!(
"gatewayd: ignored post {p1} from {EVE}: not allowed"
)),
"{log:?}"
);
assert!(!log.iter().any(|l| l.contains("secret words")), "{log:?}");
}
#[test]
fn messages_during_a_turn_go_together_in_the_next() {
let home = TempDir::new("serve-burst");
loop_dir(&home);
let (release_tx, release_rx) = mpsc::channel::<()>();
let release = Mutex::new(release_rx);
let turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |n, turn| {
if n == 0 {
let _ = release.lock().unwrap().recv_timeout(WAIT);
}
vec![done(&format!("answer to {}", turn.content))]
});
let mm = FakeMm::start();
let _running = start(config(&home, &mm.url(), ""));
let mut ws = mm.next_ws(WAIT);
let p1 = id('p', 1);
ws.posted(&post(&p1, KYLE, DM, "", "one", 5), "D");
assert_eq!(turns.recv_timeout(WAIT).unwrap().content, "one");
let saved = read_state(&home);
assert_eq!(
saved["in_flight"],
serde_json::json!([{"session": format!("mm-{p1}"), "channel": DM, "root": p1}])
);
ws.posted(&post(&id('p', 2), KYLE, DM, &p1, "two", 6), "D");
ws.posted(&post(&id('p', 3), KYLE, DM, &p1, "three", 7), "D");
std::thread::sleep(Duration::from_millis(200));
release_tx.send(()).unwrap();
let second = turns.recv_timeout(WAIT).unwrap();
assert_eq!(
(
second.session.as_str(),
second.content.as_str(),
second.resume
),
(format!("mm-{p1}").as_str(), "two\n\nthree", true)
);
let posts = mm.wait_posts(2, WAIT);
let texts: Vec<&str> = posts.iter().map(|(_, _, t)| t.as_str()).collect();
assert_eq!(texts, ["answer to one", "answer to two\n\nthree"]);
}
#[test]
fn a_full_queue_says_busy() {
let home = TempDir::new("serve-busy");
loop_dir(&home);
let (release_tx, release_rx) = mpsc::channel::<()>();
let release = Mutex::new(release_rx);
let _turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |_, _| {
let _ = release.lock().unwrap().recv_timeout(WAIT);
vec![done("ok")]
});
let mm = FakeMm::start();
let _running = start(config(&home, &mm.url(), "queue = 1"));
let mut ws = mm.next_ws(WAIT);
let p1 = id('p', 1);
for (n, text) in ["run", "waits", "too many"].iter().enumerate() {
let n = u32::try_from(n).unwrap();
let root = if n == 0 { String::new() } else { p1.clone() };
ws.posted(
&post(&id('p', n + 1), KYLE, DM, &root, text, i64::from(n) + 5),
"D",
);
}
let posts = mm.wait_posts(1, WAIT);
assert_eq!(posts, [(DM.to_string(), p1, BUSY.to_string())]);
release_tx.send(()).unwrap();
release_tx.send(()).unwrap();
}
#[test]
fn channels_are_answered_only_when_named_or_in_our_thread() {
let home = TempDir::new("serve-channel");
let (mm, _running, turns, mut ws) = up(&home, Duration::ZERO);
let p1 = id('p', 1);
ws.posted(
&post(&id('p', 9), KYLE, SHARED, "", "hello everyone", 4),
"O",
);
ws.posted(
&post(
&id('p', 8),
KYLE,
OTHER,
"",
"@boxmaker-straylight elsewhere",
4,
),
"O",
);
ws.posted(
&post(&p1, KYLE, SHARED, "", "@boxmaker-straylight start", 5),
"O",
);
assert_eq!(turns.recv_timeout(WAIT).unwrap().content, "start");
mm.wait_posts(1, WAIT);
ws.posted(
&post(&id('p', 2), KYLE, SHARED, &p1, "@hermes your turn", 6),
"O",
);
ws.posted(&post(&id('p', 3), KYLE, SHARED, &p1, "and more", 7), "O");
let next = turns.recv_timeout(WAIT).unwrap();
assert_eq!((next.content.as_str(), next.resume), ("and more", true));
let posts = mm.wait_posts(2, WAIT);
assert!(
posts.iter().all(|(c, r, _)| c == SHARED && *r == p1),
"{posts:?}"
);
assert!(turns.recv_timeout(Duration::from_millis(200)).is_err());
let saved = read_state(&home);
assert!(saved["channels"].get(OTHER).is_none(), "{saved}");
assert_eq!(saved["threads"], serde_json::json!([p1]));
}
#[test]
fn commands_are_answered_without_a_turn() {
let home = TempDir::new("serve-command");
let (mm, _running, turns, mut ws) = up(&home, Duration::ZERO);
let p1 = id('p', 1);
ws.posted(&post(&p1, KYLE, DM, "", "!approve 3", 5), "D");
assert_eq!(
mm.wait_posts(1, WAIT),
[(
DM.to_string(),
p1,
"approval 3 is not pending here".to_string()
)]
);
assert!(turns.recv_timeout(Duration::from_millis(200)).is_err());
}
@@ -0,0 +1,210 @@
//! `gatewayd` end to end, against a fake Mattermost and a fake `loopd`: who is answered, where,
//! and how (M4a spec, sections 7 and 8). Do not edit.
#[path = "support/fake_loop.rs"]
mod fake_loop;
#[path = "support/fake_mm.rs"]
mod fake_mm;
#[path = "support/gateway.rs"]
mod gateway;
#[path = "support/tmp.rs"]
mod tmp;
use std::sync::{Mutex, mpsc};
use std::time::Duration;
use fake_loop::{done, serve_loop};
use fake_mm::{BOT, BOT_NAME, DM, EVE, EVE_DM, FakeMm, KYLE, id, post};
use gateway::{OTHER, SHARED, WAIT, config, loop_dir, read_state, start, up};
use gatewayd::serve::Stop;
use gatewayd::sessions::{BUSY, M4B_COMMAND};
use tmp::TempDir;
#[test]
fn a_direct_message_is_answered_in_its_thread_while_typing() {
let home = TempDir::new("serve-dm");
let (mm, running, turns, mut ws) = up(&home, Duration::from_millis(600));
let p1 = id('p', 1);
ws.posted(&post(&p1, KYLE, DM, "", "hello there", 5), "D");
let turn = turns.recv_timeout(WAIT).unwrap();
assert_eq!(
(turn.session.as_str(), turn.content.as_str(), turn.resume),
(format!("mm-{p1}").as_str(), "hello there", false)
);
let typing = ws.typing_within(Duration::from_millis(500));
assert!(typing.len() >= 2, "{typing:?}");
assert!(
typing.iter().all(|(c, p)| c == DM && *p == p1),
"{typing:?}"
);
let posts = mm.wait_posts(1, WAIT);
assert_eq!(
posts,
[(
DM.to_string(),
p1.clone(),
"answer to hello there".to_string()
)]
);
// Typing sent just before the answer may still be on its way; after that, it stops.
ws.typing_within(Duration::from_millis(300));
assert!(
ws.typing_within(Duration::from_millis(400)).is_empty(),
"typing stops after the answer"
);
let log = running.log();
assert!(
log.iter()
.any(|l| l == &format!("gatewayd: connected to {} as {BOT_NAME}", mm.url())),
"{log:?}"
);
assert!(matches!(running.finish(), Stop::Asked));
}
#[test]
fn anyone_else_gets_nothing_at_all() {
let home = TempDir::new("serve-stranger");
let (mm, running, turns, mut ws) = up(&home, Duration::ZERO);
let p1 = id('p', 1);
ws.posted(&post(&p1, EVE, EVE_DM, "", "secret words", 5), "D");
let mut own = post(&id('p', 2), BOT, DM, "", "my own post", 6);
own["user_id"] = serde_json::json!(BOT);
ws.posted(&own, "D");
let log = running.wait_log("not allowed");
assert!(turns.recv_timeout(Duration::from_millis(300)).is_err());
assert!(ws.typing_within(Duration::from_millis(200)).is_empty());
assert!(mm.posts().is_empty());
assert!(
log.contains(&format!(
"gatewayd: ignored post {p1} from {EVE}: not allowed"
)),
"{log:?}"
);
assert!(!log.iter().any(|l| l.contains("secret words")), "{log:?}");
}
#[test]
fn messages_during_a_turn_go_together_in_the_next() {
let home = TempDir::new("serve-burst");
loop_dir(&home);
let (release_tx, release_rx) = mpsc::channel::<()>();
let release = Mutex::new(release_rx);
let turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |n, turn| {
if n == 0 {
let _ = release.lock().unwrap().recv_timeout(WAIT);
}
vec![done(&format!("answer to {}", turn.content))]
});
let mm = FakeMm::start();
let _running = start(config(&home, &mm.url(), ""));
let mut ws = mm.next_ws(WAIT);
let p1 = id('p', 1);
ws.posted(&post(&p1, KYLE, DM, "", "one", 5), "D");
assert_eq!(turns.recv_timeout(WAIT).unwrap().content, "one");
let saved = read_state(&home);
assert_eq!(
saved["in_flight"],
serde_json::json!([{"session": format!("mm-{p1}"), "channel": DM, "root": p1}])
);
ws.posted(&post(&id('p', 2), KYLE, DM, &p1, "two", 6), "D");
ws.posted(&post(&id('p', 3), KYLE, DM, &p1, "three", 7), "D");
std::thread::sleep(Duration::from_millis(200));
release_tx.send(()).unwrap();
let second = turns.recv_timeout(WAIT).unwrap();
assert_eq!(
(
second.session.as_str(),
second.content.as_str(),
second.resume
),
(format!("mm-{p1}").as_str(), "two\n\nthree", true)
);
let posts = mm.wait_posts(2, WAIT);
let texts: Vec<&str> = posts.iter().map(|(_, _, t)| t.as_str()).collect();
assert_eq!(texts, ["answer to one", "answer to two\n\nthree"]);
}
#[test]
fn a_full_queue_says_busy() {
let home = TempDir::new("serve-busy");
loop_dir(&home);
let (release_tx, release_rx) = mpsc::channel::<()>();
let release = Mutex::new(release_rx);
let _turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |_, _| {
let _ = release.lock().unwrap().recv_timeout(WAIT);
vec![done("ok")]
});
let mm = FakeMm::start();
let _running = start(config(&home, &mm.url(), "queue = 1"));
let mut ws = mm.next_ws(WAIT);
let p1 = id('p', 1);
for (n, text) in ["run", "waits", "too many"].iter().enumerate() {
let n = u32::try_from(n).unwrap();
let root = if n == 0 { String::new() } else { p1.clone() };
ws.posted(
&post(&id('p', n + 1), KYLE, DM, &root, text, i64::from(n) + 5),
"D",
);
}
let posts = mm.wait_posts(1, WAIT);
assert_eq!(posts, [(DM.to_string(), p1, BUSY.to_string())]);
release_tx.send(()).unwrap();
release_tx.send(()).unwrap();
}
#[test]
fn channels_are_answered_only_when_named_or_in_our_thread() {
let home = TempDir::new("serve-channel");
let (mm, _running, turns, mut ws) = up(&home, Duration::ZERO);
let p1 = id('p', 1);
ws.posted(
&post(&id('p', 9), KYLE, SHARED, "", "hello everyone", 4),
"O",
);
ws.posted(
&post(
&id('p', 8),
KYLE,
OTHER,
"",
"@boxmaker-straylight elsewhere",
4,
),
"O",
);
ws.posted(
&post(&p1, KYLE, SHARED, "", "@boxmaker-straylight start", 5),
"O",
);
assert_eq!(turns.recv_timeout(WAIT).unwrap().content, "start");
mm.wait_posts(1, WAIT);
ws.posted(
&post(&id('p', 2), KYLE, SHARED, &p1, "@hermes your turn", 6),
"O",
);
ws.posted(&post(&id('p', 3), KYLE, SHARED, &p1, "and more", 7), "O");
let next = turns.recv_timeout(WAIT).unwrap();
assert_eq!((next.content.as_str(), next.resume), ("and more", true));
let posts = mm.wait_posts(2, WAIT);
assert!(
posts.iter().all(|(c, r, _)| c == SHARED && *r == p1),
"{posts:?}"
);
assert!(turns.recv_timeout(Duration::from_millis(200)).is_err());
let saved = read_state(&home);
assert!(saved["channels"].get(OTHER).is_none(), "{saved}");
assert_eq!(saved["threads"], serde_json::json!([p1]));
}
#[test]
fn commands_are_answered_without_a_turn() {
let home = TempDir::new("serve-command");
let (mm, _running, turns, mut ws) = up(&home, Duration::ZERO);
let p1 = id('p', 1);
ws.posted(&post(&p1, KYLE, DM, "", "!approve 3", 5), "D");
assert_eq!(
mm.wait_posts(1, WAIT),
[(DM.to_string(), p1, M4B_COMMAND.to_string())]
);
assert!(turns.recv_timeout(Duration::from_millis(200)).is_err());
}
@@ -0,0 +1,248 @@
//! Approvals answered from Mattermost end to end: `!approve` and `!deny` from an approver in the
//! approval's thread reach `brokerd`, the post shows what came of it, typing pauses while the turn
//! waits, and everything else is answered with its reason; the notice comes only when `brokerd`
//! never brought the approval (M4b spec, section 7). Do not edit.
#[path = "support/brokerd_client.rs"]
mod brokerd_client;
#[path = "support/fake_loop.rs"]
mod fake_loop;
#[path = "support/fake_mm.rs"]
mod fake_mm;
#[path = "support/gateway.rs"]
mod gateway;
#[path = "support/tmp.rs"]
mod tmp;
use std::path::PathBuf;
use std::sync::{Mutex, mpsc};
use std::time::{Duration, Instant};
use brokerd_client::{Asking, ask, pending};
use fake_loop::{done, event, serve_loop};
use fake_mm::{DM, EVE, FakeMm, KYLE, WsPeer, id, post};
use gateway::{Running, WAIT, loop_dir, start};
use gatewayd::approvals::NOT_APPROVER;
use gatewayd::config::Config;
use proto::{GatewayOutcome, Timestamp, Turn, TurnEvent};
use tmp::TempDir;
/// KYLE and EVE may talk; only KYLE approves.
fn config(home: &TempDir, url: &str) -> Config {
let text = format!(
"[mattermost]\nurl = \"{url}\"\n[secrets.mattermost_token]\nenv = \"NOT_READ_BY_RUN\"\n[allow]\nusers = [\"{KYLE}\", \"{EVE}\"]\n[approvals]\nusers = [\"{KYLE}\"]\n[paths]\nhome = \"{}\"\n[limits]\ntyping_every_ms = 100\n",
home.path().display()
);
Config::parse(&text).unwrap()
}
/// A loop whose first turn waits for `go`, then sends `before` and answers "done".
fn held(home: &TempDir, before: Vec<TurnEvent>) -> (mpsc::Receiver<Turn>, mpsc::Sender<()>) {
loop_dir(home);
let (go_tx, go_rx) = mpsc::channel::<()>();
let go = Mutex::new(go_rx);
let before = Mutex::new(Some(before));
let turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |_, _| {
let _ = go.lock().unwrap().recv_timeout(Duration::from_secs(20));
let mut replies: Vec<_> = before
.lock()
.unwrap()
.take()
.unwrap_or_default()
.into_iter()
.map(event)
.collect();
replies.push(done("done"));
replies
});
(turns, go_tx)
}
struct Stage {
_home: TempDir,
mm: FakeMm,
_running: Running,
ws: WsPeer,
go: mpsc::Sender<()>,
socket: PathBuf,
root: String,
}
/// gatewayd up, a direct message from KYLE whose turn is held, and the approval's socket.
fn stage(tag: &str, before: Vec<TurnEvent>) -> Stage {
let home = TempDir::new(tag);
let (turns, go) = held(&home, before);
let mm = FakeMm::start();
let running = start(config(&home, &mm.url()));
let mut ws = mm.next_ws(WAIT);
running.wait_log("gatewayd: connected to ");
let root = id('p', 1);
ws.posted(&post(&root, KYLE, DM, "", "read my notes", 5), "D");
turns.recv_timeout(WAIT).expect("the turn started");
let socket = home.path().join("run/gateway/gateway.sock");
Stage {
_home: home,
mm,
_running: running,
ws,
go,
socket,
root,
}
}
impl Stage {
fn ask(&self, approval: u64) -> Asking {
ask(
&self.socket,
pending(approval, &format!("mm-{}", self.root), r#"{"path":"/n/a"}"#),
)
.unwrap()
}
fn command(&mut self, n: u32, user: &str, root: &str, text: &str, at: i64) {
self.ws
.posted(&post(&id('c', n), user, DM, root, text, at), "D");
}
/// The text posted in the thread after the approval, within five seconds.
fn replies(&self, after: usize) -> Vec<String> {
let posts = self.mm.wait_posts(after + 1, WAIT);
posts[after..].iter().map(|(_, _, t)| t.clone()).collect()
}
}
fn wait_until(what: &str, mut done: impl FnMut() -> bool) {
let until = Instant::now() + WAIT;
while !done() {
assert!(Instant::now() < until, "never happened: {what}");
std::thread::sleep(Duration::from_millis(20));
}
}
#[test]
fn approved_by_the_approver_the_post_says_it_runs_and_typing_pauses_meanwhile() {
let mut s = stage("ans-approve", vec![]);
s.ws.typing_within(Duration::from_millis(300));
let mut asking = s.ask(42);
s.ws.typing_within(Duration::from_millis(200));
assert!(
s.ws.typing_within(Duration::from_millis(400)).is_empty(),
"no typing while it waits for approval"
);
let root = s.root.clone();
s.command(1, KYLE, &root, "!approve 42", 5);
let reply = asking.answer(WAIT).expect("the answer reaches brokerd");
assert_eq!(
(
reply.approval,
reply.approved,
reply.by.as_str(),
reply.post.clone(),
reply.reason.clone()
),
(42, true, KYLE, id('c', 1), None)
);
asking.outcome(GatewayOutcome::Runs {});
let edits = s.mm.wait_edits(1, WAIT);
assert_eq!(edits[0].0, FakeMm::post_id(0));
assert!(
edits[0]
.1
.ends_with("\n\n✅ Approved by @kyle: the call runs."),
"{}",
edits[0].1
);
assert_eq!(s.mm.posts().len(), 1, "nothing else posted in the thread");
wait_until("typing again", || {
!s.ws.typing_within(Duration::from_millis(300)).is_empty()
});
s.go.send(()).unwrap();
}
#[test]
fn refused_with_a_reason_the_post_says_so() {
let mut s = stage("ans-deny", vec![]);
let mut asking = s.ask(42);
let root = s.root.clone();
s.command(1, KYLE, &root, "@boxmaker-straylight !deny 42 not now", 5);
let reply = asking.answer(WAIT).expect("the answer reaches brokerd");
assert_eq!(
(reply.approved, reply.reason.as_deref()),
(false, Some("not now"))
);
asking.outcome(GatewayOutcome::Refused {});
let edits = s.mm.wait_edits(1, WAIT);
assert!(
edits[0].1.ends_with("\n\n❌ Refused by @kyle: `not now`."),
"{}",
edits[0].1
);
s.go.send(()).unwrap();
}
#[test]
fn nobody_else_and_nothing_else_answers_it() {
let mut s = stage("ans-refused", vec![]);
let mut asking = s.ask(42);
let root = s.root.clone();
let elsewhere = id('p', 9);
s.command(1, EVE, &root, "!approve 42", 5);
s.command(2, KYLE, &elsewhere, "!approve 42", 6);
s.command(3, KYLE, &root, "!approve 42", 1);
s.command(4, KYLE, &root, "!approve 7", 7);
s.command(5, KYLE, &root, "!approve 42 please", 8);
assert_eq!(
s.replies(1)[..5],
[
NOT_APPROVER.to_string(),
"approval 42 is answered in its own thread".to_string(),
"approval 42 was answered before it was shown in full".to_string(),
"approval 7 is not pending here".to_string(),
"use !approve <id> or !deny <id> <reason>".to_string(),
]
);
assert_eq!(
asking.answer(Duration::from_millis(300)),
None,
"nothing reached brokerd"
);
s.command(6, KYLE, &root, "!approve 42", 9);
assert!(
asking.answer(WAIT).is_some(),
"the approver, in the thread, after the post"
);
asking.outcome(GatewayOutcome::Runs {});
s.go.send(()).unwrap();
}
#[test]
fn the_notice_only_when_brokerd_never_brought_it() {
let expires = Timestamp::parse("2026-09-24T08:20:00.000Z").unwrap();
let pending_event = TurnEvent::ApprovalPending {
approval: 42,
tool: "read_file".to_string(),
expires,
};
let s = stage("ans-notice", vec![pending_event.clone()]);
s.go.send(()).unwrap();
let posts = s.mm.wait_posts(2, WAIT);
let texts: Vec<&str> = posts.iter().map(|(_, _, t)| t.as_str()).collect();
assert_eq!(
texts,
[
"waiting for approval 42: brokerd has not sent it here; answer it with bxctl approvals",
"done"
]
);
let s = stage("ans-no-notice", vec![pending_event]);
let asking = s.ask(42);
s.go.send(()).unwrap();
let posts = s.mm.wait_posts(2, WAIT);
assert!(posts[0].2.starts_with("**Approval 42**"), "{posts:?}");
assert_eq!(posts[1].2, "done");
std::thread::sleep(Duration::from_millis(500));
assert_eq!(s.mm.posts().len(), 2, "no notice: {:?}", s.mm.posts());
asking.close();
}
@@ -0,0 +1,199 @@
//! The serve loop posts what `gateway.sock` brings, where it belongs, and marks it when it ends
//! or when a restart left it behind (M4b spec, sections 6 and 8). Do not edit.
#[path = "support/brokerd_client.rs"]
mod brokerd_client;
#[path = "support/fake_loop.rs"]
mod fake_loop;
#[path = "support/fake_mm.rs"]
mod fake_mm;
#[path = "support/gateway.rs"]
mod gateway;
#[path = "support/tmp.rs"]
mod tmp;
use std::path::PathBuf;
use std::sync::{Mutex, mpsc};
use std::time::Duration;
use brokerd_client::{ask, pending};
use fake_loop::{done, serve_loop};
use fake_mm::{DM, FakeMm, KYLE, id, post};
use gateway::{WAIT, config, loop_dir, read_state, start};
use tmp::TempDir;
fn socket(home: &TempDir) -> PathBuf {
home.path().join("run/gateway/gateway.sock")
}
/// A turn that waits until the returned sender says so.
fn held_loop(home: &TempDir) -> mpsc::Sender<()> {
loop_dir(home);
let (release_tx, release_rx) = mpsc::channel::<()>();
let release = Mutex::new(release_rx);
let _turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |_, _| {
let _ = release
.lock()
.unwrap()
.recv_timeout(Duration::from_secs(20));
vec![done("done")]
});
release_tx
}
#[test]
fn an_approval_goes_into_the_asking_thread_and_is_marked_when_it_ends() {
let home = TempDir::new("sa-thread");
let release = held_loop(&home);
let mm = FakeMm::start();
let _running = start(config(&home, &mm.url(), ""));
let mut ws = mm.next_ws(WAIT);
let p1 = id('p', 1);
ws.posted(&post(&p1, KYLE, DM, "", "read my notes", 5), "D");
let until = std::time::Instant::now() + WAIT;
let path = home.path().join("gateway/state.json");
let in_flight = || -> bool {
let Ok(text) = std::fs::read_to_string(&path) else {
return false;
};
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
!v["in_flight"].as_array().unwrap().is_empty()
};
while !in_flight() {
assert!(std::time::Instant::now() < until, "the turn never started");
std::thread::sleep(Duration::from_millis(20));
}
let asking = ask(
&socket(&home),
pending(42, &format!("mm-{p1}"), r#"{"path":"/n/a"}"#),
)
.unwrap();
let posts = mm.posts();
assert_eq!(posts.len(), 1, "{posts:?}");
let (channel, root, text) = &posts[0];
assert_eq!(
(channel.as_str(), root.as_str()),
(DM, p1.as_str()),
"in the asking thread"
);
assert!(text.starts_with("**Approval 42** · tool `read_file` · grant `notes` · taint private · expires 2026-09-24 08:20 UTC\n```\n{\"path\":\"/n/a\"}\n```\n"), "{text}");
assert_eq!(asking.posts, [FakeMm::post_id(0)]);
assert_eq!(
read_state(&home)["approvals"],
serde_json::json!([{"approval": 42, "channel": DM, "posts": [FakeMm::post_id(0)]}])
);
asking.close();
let edits = mm.wait_edits(1, WAIT);
assert_eq!(edits.len(), 1, "{edits:?}");
assert_eq!(edits[0].0, FakeMm::post_id(0));
assert_eq!(
edits[0].1,
format!("{text}\n\nNo longer pending (answered elsewhere, expired, or its turn ended).")
);
let until = std::time::Instant::now() + WAIT;
while !read_state(&home)["approvals"]
.as_array()
.unwrap()
.is_empty()
{
assert!(std::time::Instant::now() < until, "still recorded");
std::thread::sleep(Duration::from_millis(20));
}
release.send(()).unwrap();
}
#[test]
fn without_a_turn_in_flight_it_goes_to_the_first_approvers_direct_messages_in_parts() {
let home = TempDir::new("sa-dm");
loop_dir(&home);
let mm = FakeMm::start();
let _running = start(config(&home, &mm.url(), ""));
let _ws = mm.next_ws(WAIT);
let long = format!("{{\"content\":\"{}\"}}", "x".repeat(20_000));
let asking = ask(&socket(&home), pending(7, "chat-1789700000-1", &long)).unwrap();
let posts = mm.posts();
assert_eq!(posts.len(), 2, "two parts");
assert_eq!(
(posts[0].0.as_str(), posts[0].1.as_str()),
(DM, ""),
"a new thread in the direct channel"
);
assert_eq!(
(posts[1].0.as_str(), posts[1].1.clone()),
(DM, FakeMm::post_id(0)),
"the next part in its thread"
);
assert!(
posts[1]
.2
.contains("Reply in this thread with `!approve 7`")
);
assert_eq!(asking.posts, [FakeMm::post_id(0), FakeMm::post_id(1)]);
asking.close();
let edits = mm.wait_edits(1, WAIT);
assert_eq!(
edits[0].0,
FakeMm::post_id(1),
"only the last part is edited"
);
}
#[test]
fn an_approval_that_cannot_be_posted_is_refused_to_brokerd() {
let home = TempDir::new("sa-refused");
loop_dir(&home);
let mm = FakeMm::start();
let _running = start(config(&home, &mm.url(), ""));
let _ws = mm.next_ws(WAIT);
mm.refuse_posts(true);
let why = ask(&socket(&home), pending(8, "chat-1789700000-1", "{}"))
.err()
.expect("refused");
assert!(
why.starts_with("cannot post approval 8: status 400"),
"{why}"
);
assert!(
read_state(&home)["approvals"]
.as_array()
.unwrap()
.is_empty()
);
}
#[test]
fn a_restart_marks_the_posts_it_left_behind() {
let home = TempDir::new("sa-restart");
let (a, b) = (id('q', 1), id('q', 2));
let state = serde_json::json!({
"channels": {}, "recent": [], "threads": [], "in_flight": [],
"approvals": [{"approval": 5, "channel": DM, "posts": [a, b]}]
});
home.write("gateway/state.json", &state.to_string());
loop_dir(&home);
let mm = FakeMm::start();
let _running = start(config(&home, &mm.url(), ""));
let _ws = mm.next_ws(WAIT);
let edits = mm.wait_edits(1, WAIT);
assert_eq!(edits, [(b, "**Approval 5**\n\nNo longer answerable here: gatewayd restarted. If it is still pending it is posted again.".to_string())]);
assert!(
read_state(&home)["approvals"]
.as_array()
.unwrap()
.is_empty()
);
}
#[test]
fn the_socket_is_there_while_gatewayd_runs() {
let home = TempDir::new("sa-socket");
loop_dir(&home);
let mm = FakeMm::start();
let running = start(config(&home, &mm.url(), ""));
let _ws = mm.next_ws(WAIT);
use std::os::unix::fs::{FileTypeExt, PermissionsExt};
let meta = std::fs::metadata(socket(&home)).unwrap();
assert!(meta.file_type().is_socket());
assert_eq!(meta.permissions().mode() & 0o777, 0o600);
drop(running);
}
@@ -0,0 +1,314 @@
//! Which posts become turns, in which session, and how messages wait for a running turn (M4a spec,
//! section 7, including its table of examples). Do not edit.
use gatewayd::mm::Post;
use gatewayd::sessions::{
ApprovalCommand, BUSY, Batch, Ignored, Message, Pushed, Queues, Route, Router, Thread,
UNKNOWN_COMMAND, named,
};
use proto::SessionId;
const BOT: &str = "b0000000000000000000000000";
const KYLE: &str = "k0000000000000000000000000";
const EVE: &str = "e0000000000000000000000000";
const DM: &str = "d0000000000000000000000000";
const SHARED: &str = "c0000000000000000000000000";
const OTHER: &str = "o0000000000000000000000000";
const ROOT: &str = "r0000000000000000000000000";
const POST: &str = "p0000000000000000000000000";
fn router() -> Router {
Router::new(
BOT,
"boxmaker-straylight",
&[KYLE.to_string()],
&[SHARED.to_string()],
)
}
fn post(channel: &str, root: &str, message: &str) -> Post {
Post {
id: POST.to_string(),
user_id: KYLE.to_string(),
channel_id: channel.to_string(),
root_id: root.to_string(),
message: message.to_string(),
create_at: 5,
delete_at: 0,
kind: String::new(),
}
}
fn known(root: &str) -> bool {
root == ROOT
}
fn unknown(_: &str) -> bool {
false
}
fn queued(route: Route) -> Message {
match route {
Route::Queue(m) => m,
other => panic!("not queued: {other:?}"),
}
}
#[test]
fn the_examples_in_the_spec() {
let r = router();
let cases = [
("", "@boxmaker-straylight summarise the audit log", true),
(ROOT, "and the older files?", true),
(ROOT, "@hermes what do you think?", false),
("", "@boxmaker-straylight @hermes compare notes", true),
("", "@boxmaker-straylightx hello", false),
("", "@channel standup in five", false),
];
for (root, message, yes) in cases {
let got = r.route(&post(SHARED, root, message), "O", &known);
assert_eq!(matches!(got, Route::Queue(_)), yes, "{message}: {got:?}");
if !yes {
assert_eq!(got, Route::Ignore(Ignored::NotForUs), "{message}");
}
}
}
#[test]
fn naming() {
assert_eq!(named("@Boxmaker-Straylight, look"), ["boxmaker-straylight"]);
assert_eq!(named("ask @boxmaker-straylight."), ["boxmaker-straylight"]);
assert_eq!(named("@a.b_c-d... and @e"), ["a.b_c-d", "e"]);
assert_eq!(named("@ alone, @@x, trailing @"), ["x"]);
assert_eq!(named("ünïcødé @ʙob @bob"), ["bob"]);
assert!(named("no names here").is_empty());
let r = router();
for message in [
"hi @BOXMAKER-STRAYLIGHT",
"@boxmaker-straylight.",
"(@boxmaker-straylight)",
] {
assert!(
matches!(
r.route(&post(SHARED, "", message), "P", &unknown),
Route::Queue(_)
),
"{message}"
);
}
}
#[test]
fn replies_in_our_thread_that_name_everyone_are_still_ours() {
let r = router();
for message in ["@here any news?", "@all done", "thanks @channel"] {
assert!(
matches!(
r.route(&post(SHARED, ROOT, message), "O", &known),
Route::Queue(_)
),
"{message}"
);
}
let got = r.route(
&post(
SHARED,
"q0000000000000000000000000",
"a reply in someone else's thread",
),
"O",
&known,
);
assert_eq!(got, Route::Ignore(Ignored::NotForUs));
}
#[test]
fn who_and_where() {
let r = router();
let mut own = post(DM, "", "hello");
own.user_id = BOT.to_string();
assert_eq!(r.route(&own, "D", &unknown), Route::Ignore(Ignored::Own));
let mut system = post(DM, "", "joined");
system.kind = "system_join_channel".to_string();
assert_eq!(
r.route(&system, "D", &unknown),
Route::Ignore(Ignored::System)
);
let mut stranger = post(DM, "", "@boxmaker-straylight hello");
stranger.user_id = EVE.to_string();
assert_eq!(
r.route(&stranger, "D", &unknown),
Route::Ignore(Ignored::NotAllowed)
);
let mut stranger_cmd = post(DM, "", "!approve 1");
stranger_cmd.user_id = EVE.to_string();
assert_eq!(
r.route(&stranger_cmd, "D", &unknown),
Route::Ignore(Ignored::NotAllowed)
);
let naming = "@boxmaker-straylight hello";
assert_eq!(
r.route(&post(OTHER, "", naming), "O", &unknown),
Route::Ignore(Ignored::NotForUs)
);
assert_eq!(
r.route(&post(SHARED, "", naming), "X", &unknown),
Route::Ignore(Ignored::NotForUs)
);
assert!(matches!(
r.route(&post(SHARED, "", naming), "G", &unknown),
Route::Queue(_)
));
assert!(matches!(
r.route(&post(DM, "", "no name needed"), "D", &unknown),
Route::Queue(_)
));
}
#[test]
fn sessions_and_threads() {
let r = router();
let top = queued(r.route(&post(DM, "", "hello"), "D", &unknown));
assert_eq!(top.session.as_str(), format!("mm-{POST}"));
assert_eq!(
top.thread,
Thread {
channel: DM.to_string(),
root: POST.to_string()
}
);
assert!(!top.resume && !top.joins_thread);
assert_eq!(top.text, "hello");
let reply = queued(r.route(&post(DM, ROOT, "more"), "D", &unknown));
assert_eq!(reply.session.as_str(), format!("mm-{ROOT}"));
assert_eq!(reply.thread.root, ROOT);
assert!(reply.resume);
let channel = queued(r.route(&post(SHARED, "", "@boxmaker-straylight hi"), "O", &unknown));
assert!(channel.joins_thread && !channel.resume);
assert_eq!(channel.text, "hi", "the leading mention is stripped (M4b)");
}
#[test]
fn commands() {
let r = router();
let thread = Thread {
channel: DM.to_string(),
root: ROOT.to_string(),
};
for (message, command) in [
("!approve 42", ApprovalCommand::Approve(42)),
(
"!deny 42 not now",
ApprovalCommand::Deny(42, Some("not now".to_string())),
),
("!deny", ApprovalCommand::Malformed),
(
"@boxmaker-straylight !approve 7",
ApprovalCommand::Approve(7),
),
] {
assert_eq!(
r.route(&post(DM, ROOT, message), "D", &unknown),
Route::Approval {
thread: thread.clone(),
command,
user: KYLE.to_string(),
post: POST.to_string(),
at: 5,
},
"{message}"
);
}
for message in ["!approved", "!help", "!"] {
assert_eq!(
r.route(&post(DM, ROOT, message), "D", &unknown),
Route::Reply {
thread: thread.clone(),
text: UNKNOWN_COMMAND.to_string(),
},
"{message}"
);
}
assert_eq!(
queued(r.route(&post(DM, ROOT, "!!approve is a word"), "D", &unknown)).text,
"!approve is a word"
);
assert_eq!(
queued(r.route(&post(DM, ROOT, "!!"), "D", &unknown)).text,
"!"
);
assert_eq!(
queued(r.route(&post(DM, ROOT, " !help"), "D", &unknown)).text,
" !help"
);
}
fn message(root: &str, resume: bool, text: &str) -> Message {
Message {
session: SessionId::new(&format!("mm-{root}")).unwrap(),
thread: Thread {
channel: DM.to_string(),
root: root.to_string(),
},
resume,
text: text.to_string(),
joins_thread: false,
}
}
#[test]
fn messages_wait_for_the_running_turn_and_go_together() {
let mut q = Queues::new(3);
let first = message(ROOT, false, "one");
let Pushed::Start(batch) = q.push(first.clone()) else {
panic!("not started")
};
assert_eq!(
batch,
Batch {
session: first.session.clone(),
thread: first.thread.clone(),
resume: false,
text: "one".to_string()
}
);
assert_eq!(q.push(message(ROOT, true, "two")), Pushed::Waiting);
assert_eq!(q.push(message(ROOT, true, "three\nlines")), Pushed::Waiting);
let other = message(POST, false, "elsewhere");
assert!(
matches!(q.push(other.clone()), Pushed::Start(_)),
"another session starts at once"
);
assert_eq!(q.running(), 2);
let mut roots: Vec<String> = q.threads().into_iter().map(|t| t.root).collect();
roots.sort();
assert_eq!(roots, [POST, ROOT]);
let next = q.finish(&first.session).unwrap();
assert_eq!(
(next.text.as_str(), next.resume),
("two\n\nthree\nlines", true)
);
assert_eq!(q.finish(&first.session), None);
assert_eq!(q.finish(&other.session), None);
assert_eq!(q.running(), 0);
assert!(
matches!(q.push(message(ROOT, true, "later")), Pushed::Start(_)),
"idle again"
);
}
#[test]
fn a_full_queue_drops_the_message() {
let mut q = Queues::new(2);
let m = message(ROOT, false, "run");
assert!(matches!(q.push(m.clone()), Pushed::Start(_)));
assert_eq!(q.push(message(ROOT, true, "a")), Pushed::Waiting);
assert_eq!(q.push(message(ROOT, true, "b")), Pushed::Waiting);
assert_eq!(
q.push(message(ROOT, true, "c")),
Pushed::Full(m.thread.clone())
);
assert_eq!(q.finish(&m.session).unwrap().text, "a\n\nb");
assert!(!BUSY.is_empty());
assert_eq!(q.finish(&SessionId::new("mm-never").unwrap()), None);
}
@@ -0,0 +1,142 @@
//! A leading mention of this bot is stripped before anything reads a message, and approval
//! commands are recognised exactly (M4b spec, section 7). Do not edit.
use gatewayd::mm::Post;
use gatewayd::sessions::{
ApprovalCommand, MAX_REASON, Route, Router, approval_command, strip_mention,
};
const ME: &str = "boxmaker0";
const BOT: &str = "b0000000000000000000000000";
const KYLE: &str = "k0000000000000000000000000";
const SHARED: &str = "c0000000000000000000000000";
const ROOT: &str = "r0000000000000000000000000";
#[test]
fn a_leading_mention_is_stripped() {
assert_eq!(
strip_mention("@boxmaker0 summarise the log", ME),
"summarise the log"
);
assert_eq!(strip_mention("@BoxMaker0 !approve 42", ME), "!approve 42");
assert_eq!(
strip_mention("@boxmaker0 @hermes compare notes", ME),
"@hermes compare notes"
);
assert_eq!(strip_mention("@boxmaker0, look", ME), ", look");
assert_eq!(
strip_mention("@boxmaker0\nsecond line", ME),
"\nsecond line",
"only spaces go with it"
);
}
#[test]
fn anything_else_is_kept() {
for message in [
"@boxmaker0",
"@boxmaker0 ",
"@boxmaker0x hello",
"@hermes @boxmaker0 hello",
"hello @boxmaker0",
" @boxmaker0 hello",
"@ boxmaker0 hello",
"@@boxmaker0 hello",
"",
] {
assert_eq!(strip_mention(message, ME), message, "{message:?}");
}
assert_eq!(
strip_mention("@boxmaker0. hi", ME),
". hi",
"the trailing dot is not part of the name"
);
}
#[test]
fn approval_commands_exactly() {
use ApprovalCommand::{Approve, Deny, Malformed};
let cases: &[(&str, Option<ApprovalCommand>)] = &[
("!approve 42", Some(Approve(42))),
("!approve 0", Some(Approve(0))),
("!approve 18446744073709551615", Some(Approve(u64::MAX))),
("!deny 42", Some(Deny(42, None))),
(
"!deny 42 not on a Friday",
Some(Deny(42, Some("not on a Friday".to_string()))),
),
(
"!deny 42 two spaces",
Some(Deny(42, Some(" two spaces".to_string()))),
),
("!approve", Some(Malformed)),
("!approve 42", Some(Malformed)),
("!approve 42 ", Some(Malformed)),
("!approve 42 now", Some(Malformed)),
("!approve +42", Some(Malformed)),
("!approve 4x2", Some(Malformed)),
("!approve 18446744073709551616", Some(Malformed)),
("!deny", Some(Malformed)),
("!deny 42 ", Some(Malformed)),
("!deny x reason", Some(Malformed)),
("!approved 42", None),
("!denied 42", None),
("!help", None),
("approve 42", None),
("!Approve 42", None),
];
for (text, want) in cases {
assert_eq!(&approval_command(text), want, "{text:?}");
}
let long = format!("!deny 42 {}", "é".repeat(MAX_REASON));
assert!(
matches!(approval_command(&long), Some(Deny(42, Some(_)))),
"characters, not bytes"
);
let over = format!("!deny 42 {}", "x".repeat(MAX_REASON + 1));
assert_eq!(approval_command(&over), Some(Malformed));
}
fn post(message: &str, root: &str) -> Post {
Post {
id: "p0000000000000000000000001".to_string(),
user_id: KYLE.to_string(),
channel_id: SHARED.to_string(),
root_id: root.to_string(),
message: message.to_string(),
create_at: 9,
delete_at: 0,
kind: String::new(),
}
}
#[test]
fn in_a_shared_thread_the_mention_picks_the_bot() {
let me = Router::new(BOT, ME, &[KYLE.to_string()], &[SHARED.to_string()]);
let ours = |root: &str| root == ROOT;
let addressed = me.route(&post("@boxmaker0 !approve 42", ROOT), "O", &ours);
assert!(
matches!(
addressed,
Route::Approval {
command: ApprovalCommand::Approve(42),
..
}
),
"{addressed:?}"
);
let other = me.route(&post("@boxmaker1 !approve 42", ROOT), "O", &ours);
assert!(
matches!(other, Route::Ignore(_)),
"addressed to another bot: {other:?}"
);
let bare = me.route(&post("!approve 42", ROOT), "O", &ours);
assert!(
matches!(bare, Route::Approval { .. }),
"a bare command in our thread is ours too: {bare:?}"
);
match me.route(&post("@boxmaker0 hello there", ""), "O", &ours) {
Route::Queue(m) => assert_eq!(m.text, "hello there"),
other => panic!("{other:?}"),
}
}
@@ -0,0 +1,95 @@
//! The approval posts in the state file, for marking them at a restart; an M4a state file still
//! loads; and the turn of a session (M4b spec, sections 6 and 8). Do not edit.
#[path = "support/tmp.rs"]
mod tmp;
use gatewayd::state::{ApprovalPosts, InFlight, State, StateError};
use tmp::TempDir;
const DM: &str = "d0000000000000000000000000";
const ROOT: &str = "r0000000000000000000000000";
fn id(n: u32) -> String {
format!("p{n:025}")
}
fn shown(approval: u64, posts: &[u32]) -> ApprovalPosts {
ApprovalPosts {
approval,
channel: DM.to_string(),
posts: posts.iter().map(|n| id(*n)).collect(),
}
}
#[test]
fn approval_posts_survive_a_restart_and_are_taken_once() {
let dir = TempDir::new("state-approvals");
let path = dir.path().join("gateway/state.json");
let mut s = State::load(&path).unwrap();
s.add_approval(shown(42, &[1, 2])).unwrap();
s.add_approval(shown(43, &[3])).unwrap();
s.add_approval(shown(42, &[4])).unwrap();
s.remove_approval(43).unwrap();
s.remove_approval(99).unwrap();
let mut again = State::load(&path).unwrap();
assert_eq!(
again.take_approvals().unwrap(),
[shown(42, &[4])],
"the later posting of 42 replaced the first"
);
assert!(
State::load(&path)
.unwrap()
.take_approvals()
.unwrap()
.is_empty(),
"taking is saved"
);
}
#[test]
fn an_m4a_state_file_loads() {
let dir = TempDir::new("state-m4a");
let text = format!(r#"{{"channels":{{"{DM}":5}},"recent":[],"threads":[],"in_flight":[]}}"#);
let path = dir.write("state.json", &text);
let mut s = State::load(&path).unwrap();
assert_eq!(s.since(DM), Some(5));
assert!(s.take_approvals().unwrap().is_empty());
}
#[test]
fn bad_approval_posts_are_refused() {
let dir = TempDir::new("state-approvals-bad");
for (n, approvals) in [
r#"[{"approval":1,"channel":"../x","posts":["p0000000000000000000000001"]}]"#,
r#"[{"approval":1,"channel":"d0000000000000000000000000","posts":[]}]"#,
r#"[{"approval":1,"channel":"d0000000000000000000000000","posts":["short"]}]"#,
r#"[{"approval":1,"channel":"d0000000000000000000000000","posts":["p0000000000000000000000001"],"extra":1}]"#,
r#"[{"approval":-1,"channel":"d0000000000000000000000000","posts":["p0000000000000000000000001"]}]"#,
]
.iter()
.enumerate()
{
let text = format!(r#"{{"channels":{{}},"recent":[],"threads":[],"in_flight":[],"approvals":{approvals}}}"#);
let path = dir.write(&format!("s{n}.json"), &text);
assert!(matches!(State::load(&path), Err(StateError::Read(..))), "{approvals}");
}
}
#[test]
fn the_turn_of_a_session() {
let dir = TempDir::new("state-turn-of");
let mut s = State::load(&dir.path().join("state.json")).unwrap();
let turn = InFlight {
session: format!("mm-{ROOT}"),
channel: DM.to_string(),
root: ROOT.to_string(),
};
assert_eq!(s.turn_of(&turn.session), None);
s.start_turn(turn.clone()).unwrap();
assert_eq!(s.turn_of(&turn.session), Some(turn.clone()));
assert_eq!(s.turn_of("mm-other"), None);
s.end_turn(&turn.session).unwrap();
assert_eq!(s.turn_of(&turn.session), None);
}
@@ -0,0 +1,97 @@
//! `brokerd`'s side of one `gateway.sock` connection, for tests: ask, read what comes back,
//! answer with an outcome, or close. Do not edit.
#![allow(dead_code)] // each test file uses a different part of this module
use std::os::unix::net::UnixStream;
use std::path::Path;
use std::time::Duration;
use proto::{
ApprovalOutcome, ApprovalReply, ApprovalRequest, CallId, DataClass, Envelope, GatewayOutcome,
Message, PROTOCOL_VERSION, PendingApproval, SessionId, Timestamp, read_frame, write_frame,
};
pub struct Asking {
stream: UnixStream,
pub posts: Vec<String>,
}
/// An approval of `session`, with these arguments.
pub fn pending(approval: u64, session: &str, arguments: &str) -> PendingApproval {
PendingApproval {
approval,
session: SessionId::new(session).unwrap(),
call: CallId(3),
tool: "read_file".to_string(),
arguments: arguments.to_string(),
grant: "notes".to_string(),
taint: DataClass::Private,
created: Timestamp::parse("2026-09-24T08:05:00.000Z").unwrap(),
expires: Timestamp::parse("2026-09-24T08:20:00.000Z").unwrap(),
}
}
fn frame(id: u64, last: bool, msg: Message) -> Envelope {
Envelope {
v: PROTOCOL_VERSION,
id,
r#final: last,
msg,
}
}
/// Connect and ask; `Ok` with the posts, or `Err` with gatewayd's error detail.
pub fn ask(socket: &Path, info: PendingApproval) -> Result<Asking, String> {
let mut stream = UnixStream::connect(socket).map_err(|e| e.to_string())?;
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
write_frame(
&mut stream,
&frame(
1,
true,
Message::ApprovalRequest(ApprovalRequest { approval: info }),
),
)
.unwrap();
match read_frame(&mut stream).map_err(|e| e.to_string())?.msg {
Message::ApprovalPosted(p) => Ok(Asking {
stream,
posts: p.posts,
}),
Message::Error(e) => Err(e.detail),
other => panic!("{other:?}"),
}
}
impl Asking {
/// The answer, within `wait`, or `None`.
pub fn answer(&mut self, wait: Duration) -> Option<ApprovalReply> {
self.stream.set_read_timeout(Some(wait)).unwrap();
match read_frame(&mut self.stream) {
Ok(Envelope {
msg: Message::ApprovalReply(r),
..
}) => Some(r),
_ => None,
}
}
pub fn outcome(&mut self, outcome: GatewayOutcome) {
write_frame(
&mut self.stream,
&frame(
2,
true,
Message::ApprovalOutcome(ApprovalOutcome { outcome }),
),
)
.unwrap();
}
pub fn close(self) {
let _ = self.stream.shutdown(std::net::Shutdown::Both);
}
}
@@ -0,0 +1,381 @@
//! A fake Mattermost on 127.0.0.1, plain TCP: the four REST calls `gatewayd` makes, and the
//! WebSocket, whose events the test sends and whose requests it reads. Do not edit.
#![allow(dead_code)] // each test file uses a different part of this module
use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream};
use std::sync::{Arc, Mutex, mpsc};
use std::time::{Duration, Instant};
use gatewayd::ws::handshake::accept_for;
use serde_json::{Value, json};
pub const BOT: &str = "b0000000000000000000000000";
pub const BOT_NAME: &str = "boxmaker-straylight";
pub const KYLE: &str = "k0000000000000000000000000";
pub const EVE: &str = "e0000000000000000000000000";
/// The direct channel between the bot and Kyle, and between the bot and anyone else.
pub const DM: &str = "d0000000000000000000000000";
pub const EVE_DM: &str = "f0000000000000000000000000";
#[derive(Default)]
struct Inner {
/// Status for `users/me`: 200 unless a test sets another.
me_status: u16,
/// The body for `channels/<id>/posts?since=`, by channel.
since: HashMap<String, Value>,
/// Every post made: channel, root, message.
posts: Vec<(String, String, String)>,
/// Every REST call: method and path.
calls: Vec<(String, String)>,
/// Every edit: the post and its new text.
edits: Vec<(String, String)>,
/// When set, `POST /posts` is refused with 400.
refuse_posts: bool,
}
pub struct FakeMm {
pub addr: SocketAddr,
inner: Arc<Mutex<Inner>>,
sockets: Mutex<mpsc::Receiver<WsPeer>>,
}
/// One WebSocket connection from `gatewayd`.
pub struct WsPeer {
writer: TcpStream,
/// The text of every text frame `gatewayd` sends.
pub texts: mpsc::Receiver<String>,
}
/// A post as Mattermost sends it.
pub fn post(
id: &str,
user: &str,
channel: &str,
root: &str,
message: &str,
create_at: i64,
) -> Value {
json!({
"id": id, "create_at": create_at, "update_at": create_at, "delete_at": 0, "user_id": user,
"channel_id": channel, "root_id": root, "message": message, "type": "", "props": {}
})
}
pub fn id(prefix: char, n: u32) -> String {
format!("{prefix}{n:025}")
}
fn frame(opcode: u8, payload: &[u8]) -> Vec<u8> {
let mut out = vec![0x80 | opcode];
match payload.len() {
n if n < 126 => out.push(n as u8),
n => {
out.push(126);
out.extend_from_slice(&(n as u16).to_be_bytes());
}
}
out.extend_from_slice(payload);
out
}
impl WsPeer {
pub fn event(&mut self, value: &Value) {
let _ = self
.writer
.write_all(&frame(0x1, value.to_string().as_bytes()));
}
pub fn posted(&mut self, post: &Value, channel_type: &str) {
let data = json!({"post": post.to_string(), "channel_type": channel_type, "team_id": ""});
self.event(&json!({"event": "posted", "data": data, "broadcast": {}, "seq": 1}));
}
/// End the connection without a close frame.
pub fn drop_connection(self) {
let _ = self.writer.shutdown(Shutdown::Both);
}
/// The `user_typing` requests received within `wait`, as (channel, parent).
pub fn typing_within(&self, wait: Duration) -> Vec<(String, String)> {
let until = Instant::now() + wait;
let mut got = Vec::new();
while let Ok(text) = self
.texts
.recv_timeout(until.saturating_duration_since(Instant::now()))
{
let v: Value = serde_json::from_str(&text).unwrap();
if v["action"] == "user_typing" {
let data = &v["data"];
got.push((
data["channel_id"].as_str().unwrap().to_string(),
data["parent_id"].as_str().unwrap().to_string(),
));
}
}
got
}
}
fn read_head(stream: &mut TcpStream) -> Option<String> {
let mut head = Vec::new();
let mut byte = [0u8; 1];
while !head.ends_with(b"\r\n\r\n") {
if stream.read(&mut byte).ok()? == 0 {
return None;
}
head.push(byte[0]);
}
String::from_utf8(head).ok()
}
/// Unmask the client's frames and send each text on `tx`, until the connection ends.
fn read_frames(mut stream: TcpStream, tx: mpsc::Sender<String>) {
let mut exact = |n: usize| -> Option<Vec<u8>> {
let mut buf = vec![0u8; n];
stream.read_exact(&mut buf).ok().map(|()| buf)
};
loop {
let Some(head) = exact(2) else { return };
let len = match head[1] & 0x7F {
126 => u16::from_be_bytes(exact(2).unwrap().try_into().unwrap()) as usize,
127 => return,
n => n as usize,
};
let Some(mask) = exact(4) else { return };
let Some(raw) = exact(len) else { return };
let payload: Vec<u8> = raw
.iter()
.zip(mask.iter().cycle())
.map(|(b, m)| b ^ m)
.collect();
if head[0] & 0x0F == 0x1 {
let _ = tx.send(String::from_utf8(payload).unwrap());
}
}
}
impl FakeMm {
pub fn start() -> FakeMm {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let inner = Arc::new(Mutex::new(Inner {
me_status: 200,
..Inner::default()
}));
let (ws_tx, ws_rx) = mpsc::channel();
let shared = Arc::clone(&inner);
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(stream) = stream else { continue };
let (inner, ws_tx) = (Arc::clone(&shared), ws_tx.clone());
std::thread::spawn(move || connection(stream, &inner, &ws_tx));
}
});
FakeMm {
addr,
inner,
sockets: Mutex::new(ws_rx),
}
}
pub fn url(&self) -> String {
format!("http://127.0.0.1:{}", self.addr.port())
}
pub fn refuse_token(&self) {
self.inner.lock().unwrap().me_status = 401;
}
pub fn set_since(&self, channel: &str, posts: &[Value]) {
let order: Vec<Value> = posts.iter().map(|p| p["id"].clone()).collect();
let map: serde_json::Map<String, Value> = posts
.iter()
.map(|p| (p["id"].as_str().unwrap().to_string(), p.clone()))
.collect();
self.inner
.lock()
.unwrap()
.since
.insert(channel.to_string(), json!({"order": order, "posts": map}));
}
/// The next WebSocket `gatewayd` opens, after its hello.
pub fn next_ws(&self, wait: Duration) -> WsPeer {
self.sockets
.lock()
.unwrap()
.recv_timeout(wait)
.expect("no WebSocket connection")
}
pub fn posts(&self) -> Vec<(String, String, String)> {
self.inner.lock().unwrap().posts.clone()
}
/// Wait until at least `n` posts were made, for at most `wait`.
pub fn wait_posts(&self, n: usize, wait: Duration) -> Vec<(String, String, String)> {
let until = Instant::now() + wait;
while self.posts().len() < n && Instant::now() < until {
std::thread::sleep(Duration::from_millis(10));
}
self.posts()
}
pub fn calls(&self) -> Vec<(String, String)> {
self.inner.lock().unwrap().calls.clone()
}
/// The id of the n-th post made (from 0).
pub fn post_id(n: usize) -> String {
id('x', u32::try_from(n).unwrap())
}
pub fn edits(&self) -> Vec<(String, String)> {
self.inner.lock().unwrap().edits.clone()
}
/// Wait until at least `n` edits were made, for at most `wait`.
pub fn wait_edits(&self, n: usize, wait: Duration) -> Vec<(String, String)> {
let until = Instant::now() + wait;
while self.edits().len() < n && Instant::now() < until {
std::thread::sleep(Duration::from_millis(10));
}
self.edits()
}
pub fn refuse_posts(&self, refuse: bool) {
self.inner.lock().unwrap().refuse_posts = refuse;
}
}
fn connection(mut stream: TcpStream, inner: &Mutex<Inner>, ws_tx: &mpsc::Sender<WsPeer>) {
let Some(head) = read_head(&mut stream) else {
return;
};
let mut words = head.split_whitespace();
let (method, path) = (
words.next().unwrap_or("").to_string(),
words.next().unwrap_or("").to_string(),
);
if path == "/api/v4/websocket" {
let key = head
.lines()
.find_map(|l| l.strip_prefix("Sec-WebSocket-Key: "))
.unwrap_or("")
.trim()
.to_string();
let reply = format!(
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {}\r\n\r\n",
accept_for(&key)
);
let _ = stream.write_all(reply.as_bytes());
let _ = stream.write_all(&frame(
0x1,
br#"{"event":"hello","data":{},"broadcast":{},"seq":0}"#,
));
let (tx, texts) = mpsc::channel();
let reader = stream.try_clone().unwrap();
std::thread::spawn(move || read_frames(reader, tx));
let _ = ws_tx.send(WsPeer {
writer: stream,
texts,
});
return;
}
let length = head
.lines()
.find_map(|l| {
l.to_ascii_lowercase()
.strip_prefix("content-length:")
.map(|v| v.trim().parse::<usize>().unwrap_or(0))
})
.unwrap_or(0);
let mut body = vec![0u8; length];
let _ = stream.read_exact(&mut body);
let (status, answer) = rest(inner, &method, &path, &body);
let text = answer.to_string();
let reply = format!(
"HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{text}",
text.len()
);
let _ = stream.write_all(reply.as_bytes());
}
fn rest(inner: &Mutex<Inner>, method: &str, path: &str, body: &[u8]) -> (u16, Value) {
let mut inner = inner.lock().unwrap();
inner.calls.push((method.to_string(), path.to_string()));
match (method, path) {
("GET", "/api/v4/users/me") if inner.me_status == 200 => {
(200, json!({"id": BOT, "username": BOT_NAME}))
}
("GET", "/api/v4/users/me") => (
inner.me_status,
json!({"id": "api.context.session_expired.app_error"}),
),
("POST", "/api/v4/channels/direct") => {
let users: Vec<String> = serde_json::from_slice(body).unwrap();
let channel = if users.iter().any(|u| u == KYLE) {
DM
} else {
EVE_DM
};
(201, json!({"id": channel, "type": "D"}))
}
("POST", "/api/v4/posts") if inner.refuse_posts => {
(400, json!({"id": "api.post.create_post.refused"}))
}
("POST", "/api/v4/posts") => {
let p: Value = serde_json::from_slice(body).unwrap();
let n = u32::try_from(inner.posts.len()).unwrap();
let (channel, root, message) = (
p["channel_id"].as_str().unwrap(),
p["root_id"].as_str().unwrap(),
p["message"].as_str().unwrap(),
);
inner
.posts
.push((channel.to_string(), root.to_string(), message.to_string()));
(201, post(&id('x', n), BOT, channel, root, message, 1))
}
("GET", p) if p.contains("/posts?since=") => {
let channel = p
.trim_start_matches("/api/v4/channels/")
.split('/')
.next()
.unwrap_or("");
(
200,
inner
.since
.get(channel)
.cloned()
.unwrap_or(json!({"order": [], "posts": {}})),
)
}
("PUT", p) if p.starts_with("/api/v4/posts/") && p.ends_with("/patch") => {
let post_id = p
.trim_start_matches("/api/v4/posts/")
.trim_end_matches("/patch")
.to_string();
let message = serde_json::from_slice::<Value>(body).unwrap()["message"]
.as_str()
.unwrap()
.to_string();
inner.edits.push((post_id.clone(), message.clone()));
(200, post(&post_id, BOT, DM, "", &message, 1))
}
("GET", p) if p.starts_with("/api/v4/users/") => {
let user = p.trim_start_matches("/api/v4/users/");
let name = if user == KYLE {
"kyle".to_string()
} else {
format!("user-{}", &user[..4])
};
(200, json!({"id": user, "username": name}))
}
_ => (404, json!({"message": "not found"})),
}
}
@@ -0,0 +1,142 @@
//! Running `gatewayd`'s serve loop in a test, against the fake Mattermost and the fake `loopd`.
//! Do not edit.
#![allow(dead_code)] // each test file uses a different part of this module
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, mpsc};
use std::thread::JoinHandle;
use std::time::Duration;
use gatewayd::config::Config;
use gatewayd::secrets::Secret;
use gatewayd::serve::{Stop, Tuning, run};
use proto::Turn;
use crate::fake_loop::{done, serve_loop};
use crate::fake_mm::{FakeMm, KYLE, WsPeer};
use crate::tmp::TempDir;
pub const SHARED: &str = "c0000000000000000000000000";
pub const OTHER: &str = "o0000000000000000000000000";
pub const WAIT: Duration = Duration::from_secs(5);
pub struct Running {
pub stop: Arc<AtomicBool>,
pub log: Arc<Mutex<Vec<String>>>,
pub handle: Option<JoinHandle<Stop>>,
}
impl Running {
pub fn log(&self) -> Vec<String> {
self.log.lock().unwrap().clone()
}
pub fn wait_log(&self, part: &str) -> Vec<String> {
let until = std::time::Instant::now() + WAIT;
while !self.log().iter().any(|l| l.contains(part)) {
assert!(
std::time::Instant::now() < until,
"no log line with {part:?}: {:?}",
self.log()
);
std::thread::sleep(Duration::from_millis(10));
}
self.log()
}
/// The `Stop` `run` returns by itself within 5 s; after that it is stopped, and the test fails.
pub fn join_within(mut self) -> Stop {
let handle = self.handle.take().unwrap();
let until = std::time::Instant::now() + WAIT;
while !handle.is_finished() && std::time::Instant::now() < until {
std::thread::sleep(Duration::from_millis(10));
}
self.stop.store(true, Ordering::SeqCst);
let stop = handle.join().unwrap();
assert!(!matches!(stop, Stop::Asked), "run did not stop by itself");
stop
}
pub fn finish(mut self) -> Stop {
self.stop.store(true, Ordering::SeqCst);
self.handle.take().unwrap().join().unwrap()
}
}
impl Drop for Running {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
}
}
pub fn config(home: &TempDir, url: &str, extra: &str) -> Config {
let text = format!(
r#"
[mattermost]
url = "{url}"
[secrets.mattermost_token]
env = "NOT_READ_BY_RUN"
[allow]
users = ["{KYLE}"]
channels = ["{SHARED}"]
[paths]
home = "{}"
[limits]
typing_every_ms = 100
{extra}
"#,
home.path().display()
);
Config::parse(&text).unwrap()
}
pub fn start(config: Config) -> Running {
let stop = Arc::new(AtomicBool::new(false));
let log = Arc::new(Mutex::new(Vec::new()));
let tuning = Tuning {
backoff: vec![Duration::from_millis(50)],
poll: Duration::from_millis(20),
rest_timeout: WAIT,
notice: Duration::from_millis(300),
};
let (s, l) = (Arc::clone(&stop), Arc::clone(&log));
let handle = std::thread::spawn(move || {
let sink: gatewayd::serve::Log =
Arc::new(move |line: &str| l.lock().unwrap().push(line.to_string()));
run(config, Secret::new("TOKEN".to_string()), tuning, sink, &s)
});
Running {
stop,
log,
handle: Some(handle),
}
}
/// A fake loop that answers every turn with "answer to <content>", after `delay`.
pub fn answering(home: &TempDir, delay: Duration) -> mpsc::Receiver<Turn> {
serve_loop(&home.path().join("run/loop/loop.sock"), move |_, turn| {
std::thread::sleep(delay);
vec![done(&format!("answer to {}", turn.content))]
})
}
pub fn read_state(home: &TempDir) -> serde_json::Value {
let text = std::fs::read_to_string(home.path().join("gateway/state.json")).unwrap();
serde_json::from_str(&text).unwrap()
}
pub fn loop_dir(home: &TempDir) {
std::fs::create_dir_all(home.path().join("run/loop")).unwrap();
}
/// Start with a fake Mattermost and a fake loop; the first WebSocket is returned.
pub fn up(home: &TempDir, delay: Duration) -> (FakeMm, Running, mpsc::Receiver<Turn>, WsPeer) {
loop_dir(home);
let turns = answering(home, delay);
let mm = FakeMm::start();
let running = start(config(home, &mm.url(), ""));
let ws = mm.next_ws(WAIT);
running.wait_log("gatewayd: connected to ");
(mm, running, turns, ws)
}
@@ -0,0 +1,45 @@
//! Text from outside (model output, tool arguments, server errors) made safe to show: control
//! characters, zero-width and bidirectional marks, line separators and the BOM become `\uXXXX`.
//! Used by `bxctl` for the terminal and by `gatewayd` for Mattermost, so both show the same.
// The control bytes, the zero-width and Unicode line/paragraph separators, and the BOM. A code
// point in any of these ranges must never reach the terminal raw.
const ESCAPE_RANGES: &[(u32, u32)] = &[
(0x0000, 0x001f),
(0x007f, 0x009f),
(0x200b, 0x200f),
(0x2028, 0x202e),
(0x2060, 0x2069),
(0xfeff, 0xfeff),
];
pub fn escape_json_text(s: &str) -> String {
escape(s, false)
}
pub fn escape_model_text(s: &str) -> String {
escape(s, true)
}
fn escape(s: &str, model_text: bool) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
let cp = u32::from(c);
if should_escape(c, model_text) {
out.push_str(&format!("\\u{:04x}", cp));
} else {
out.push(c);
}
}
out
}
// A control point is escaped, except that model text keeps \n and \t.
fn should_escape(c: char, model_text: bool) -> bool {
let cp = u32::from(c);
if ESCAPE_RANGES.iter().any(|(lo, hi)| cp >= *lo && cp <= *hi) {
!(model_text && (cp == 0x09 || cp == 0x0a))
} else {
false
}
}
@@ -0,0 +1,76 @@
//! The messages on `gateway.sock`, between `brokerd` and `gatewayd`: one connection carries one
//! pending approval to Mattermost and its answer back (M4b spec, section 4).
use serde::{Deserialize, Serialize};
use crate::wire::DenyReason;
use crate::wire::PendingApproval;
/// The most characters a refusal's reason may have.
pub const MAX_REASON: usize = 500;
/// `brokerd` → `gatewayd`, first: the approval to post.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ApprovalRequest {
pub approval: PendingApproval,
}
/// `gatewayd` → `brokerd`: it is posted, in these posts, first part first.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ApprovalPosted {
pub posts: Vec<String>,
}
/// `gatewayd` → `brokerd`: an approver answered, with this post.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ApprovalReply {
pub approval: u64,
pub approved: bool,
pub by: String,
pub post: String,
pub reason: Option<String>,
}
/// `brokerd` → `gatewayd`, last: what came of the answer.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ApprovalOutcome {
pub outcome: GatewayOutcome,
}
// JSON: {"outcome":"denied","reason":"no_grant"} ; the tag sits beside the fields
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)]
pub enum GatewayOutcome {
/// Approved, and the grants as they are now still allow it.
Runs {},
/// Approved, but denied on the re-check, or the answer could not be recorded.
Denied { reason: DenyReason },
/// The refusal is recorded.
Refused {},
/// It was no longer pending when the answer came.
Gone {},
}
/// A Mattermost id: 26 characters of `a-z0-9`.
pub fn is_mattermost_id(id: &str) -> bool {
// `id.len() == 26` and every byte `b.is_ascii_lowercase() || b.is_ascii_digit()`.
todo!()
}
impl ApprovalReply {
/// `by` and `post` are Mattermost ids and the reason is at most `MAX_REASON` characters; the
/// first problem otherwise.
pub fn check(&self) -> Result<(), String> {
// In this order, each an Err with exactly this text:
// 1. `!is_mattermost_id(&self.by)`: "the answer's `by` is not a Mattermost id".
// 2. `!is_mattermost_id(&self.post)`: "the answer's `post` is not a Mattermost id".
// 3. a reason with more than `MAX_REASON` characters (`r.chars().count()`, not bytes):
// `format!("the reason is over {MAX_REASON} characters")`.
// 4. Ok(()).
todo!()
}
}
@@ -0,0 +1,150 @@
//! Tests for `proto::escape`: text the model wrote is printed as data. Do not edit.
//!
//! The expected escapes are built by `esc`, never spelled out, so that nothing that handles this
//! file can turn one into the character it stands for.
use proto::escape::{escape_json_text, escape_model_text};
const BACKSLASH: char = '\\';
/// The escape for one code point: a backslash, `u`, and four lowercase hex digits.
fn esc(code: u32) -> String {
format!("{BACKSLASH}u{code:04x}")
}
fn ch(code: u32) -> char {
char::from_u32(code).unwrap()
}
/// Every code point that must be escaped, as inclusive ranges.
const HIDDEN: [(u32, u32); 6] = [
(0x0000, 0x001f),
(0x007f, 0x009f),
(0x200b, 0x200f),
(0x2028, 0x202e),
(0x2060, 0x2069),
(0xfeff, 0xfeff),
];
fn hidden(code: u32) -> bool {
HIDDEN.iter().any(|(lo, hi)| (*lo..=*hi).contains(&code))
}
/// Walks every code point below U+11000, not a sample: each is either escaped exactly or left
/// exactly as it is.
#[test]
fn every_listed_code_point_is_escaped_and_no_other() {
let mut escaped = 0;
for code in 0..0x11000u32 {
let Some(c) = char::from_u32(code) else {
continue; // the surrogates are not characters
};
let text = format!("a{c}b");
let got = escape_json_text(&text);
if hidden(code) {
escaped += 1;
assert_eq!(got, format!("a{}b", esc(code)), "U+{code:04X}");
} else {
assert_eq!(got, text, "U+{code:04X} must pass through");
}
}
assert_eq!(escaped, 32 + 33 + 5 + 7 + 10 + 1, "the six ranges, in full");
}
#[test]
fn the_edges_of_each_range() {
for (lo, hi) in HIDDEN {
assert_eq!(escape_json_text(&ch(lo).to_string()), esc(lo));
assert_eq!(escape_json_text(&ch(hi).to_string()), esc(hi));
if lo > 0 {
let before = ch(lo - 1).to_string();
assert_eq!(escape_json_text(&before), before, "U+{:04X}", lo - 1);
}
let after = ch(hi + 1).to_string();
assert_eq!(escape_json_text(&after), after, "U+{:04X}", hi + 1);
}
}
#[test]
fn hex_digits_are_lowercase_and_there_are_always_four() {
assert_eq!(escape_json_text("\x1b"), esc(0x1b));
assert!(escape_json_text("\x1b").ends_with("001b"));
assert!(escape_json_text("\0").ends_with("0000"));
assert!(escape_json_text(&ch(0xfeff).to_string()).ends_with("feff"));
assert!(escape_json_text(&ch(0x202e).to_string()).ends_with("202e"));
}
#[test]
fn an_escape_sequence_cannot_reach_the_terminal() {
let text = "before\x1b[8mhidden\x1b[0m\x07after";
let got = escape_json_text(text);
assert!(!got.contains('\x1b') && !got.contains('\x07'), "{got:?}");
assert_eq!(
got,
format!(
"before{}[8mhidden{}[0m{}after",
esc(0x1b),
esc(0x1b),
esc(0x07)
)
);
}
#[test]
fn a_path_cannot_be_shown_backwards() {
// U+202E makes a terminal draw what follows from right to left.
let text = format!("/home/kyle/notes/{}dm.terces", ch(0x202e));
assert_eq!(
escape_json_text(&text),
format!("/home/kyle/notes/{}dm.terces", esc(0x202e))
);
let text = format!("a{}b{}c", ch(0x200b), ch(0x2066));
assert_eq!(
escape_json_text(&text),
format!("a{}b{}c", esc(0x200b), esc(0x2066))
);
}
#[test]
fn ordinary_text_is_unchanged() {
for text in [
"",
"plain",
r#"{"command":"ls -l","cwd":"/home/kyle"}"#,
"naïve café 日本語 🙂",
"a backslash \\ and a quote \" stay as they are",
] {
assert_eq!(escape_json_text(text), text);
assert_eq!(escape_model_text(text), text);
}
}
#[test]
fn json_text_escapes_newline_and_tab_but_model_text_keeps_them() {
let text = "one\n\ttwo\r\n";
assert_eq!(
escape_json_text(text),
format!("one{}{}two{}{}", esc(0x0a), esc(0x09), esc(0x0d), esc(0x0a))
);
assert_eq!(
escape_model_text(text),
format!("one\n\ttwo{}\n", esc(0x0d)),
"only newline and tab pass; a carriage return could overwrite the line"
);
}
#[test]
fn model_text_escapes_everything_else_the_same_way() {
for (lo, hi) in HIDDEN {
for code in lo..=hi {
if code == 0x0a || code == 0x09 {
continue;
}
assert_eq!(
escape_model_text(&ch(code).to_string()),
esc(code),
"U+{code:04X}"
);
}
}
}
@@ -0,0 +1 @@
{"v":1,"id":2,"final":true,"msg":{"kind":"approval_outcome","body":{"outcome":{"outcome":"denied","reason":"no_grant"}}}}
@@ -0,0 +1 @@
{"v":1,"id":2,"final":true,"msg":{"kind":"approval_outcome","body":{"outcome":{"outcome":"gone"}}}}
@@ -0,0 +1 @@
{"v":1,"id":2,"final":true,"msg":{"kind":"approval_outcome","body":{"outcome":{"outcome":"refused"}}}}
@@ -0,0 +1 @@
{"v":1,"id":2,"final":true,"msg":{"kind":"approval_outcome","body":{"outcome":{"outcome":"runs"}}}}
@@ -0,0 +1 @@
{"v":1,"id":1,"final":false,"msg":{"kind":"approval_posted","body":{"posts":["p0000000000000000000000001","p0000000000000000000000002"]}}}
@@ -0,0 +1 @@
{"v":1,"id":1,"final":true,"msg":{"kind":"approval_reply","body":{"approval":41,"approved":true,"by":"k0000000000000000000000000","post":"c0000000000000000000000001","reason":null}}}
@@ -0,0 +1 @@
{"v":1,"id":1,"final":true,"msg":{"kind":"approval_reply","body":{"approval":41,"approved":false,"by":"k0000000000000000000000000","post":"c0000000000000000000000001","reason":"not on a Friday"}}}
@@ -0,0 +1 @@
{"v":1,"id":1,"final":true,"msg":{"kind":"approval_request","body":{"approval":{"approval":41,"session":"mm-r0000000000000000000000001","call":3,"tool":"shell","arguments":"{\"command\":\"ls -la /srv\"}","grant":"shell-srv","taint":"private","created":"2026-09-24T08:05:00.000Z","expires":"2026-09-24T08:20:00.000Z"}}}}
@@ -0,0 +1,187 @@
//! The four messages of `gateway.sock` against byte-exact fixtures, every object in them refusing
//! an unknown key, and the checks on an answer (M4b spec, section 4). Do not edit these or the
//! fixtures.
use proto::{
ApprovalOutcome, ApprovalPosted, ApprovalReply, ApprovalRequest, CallId, DataClass, DenyReason,
Envelope, GatewayOutcome, MAX_REASON, Message, PendingApproval, SessionId, Timestamp,
is_mattermost_id,
};
use serde_json::Value;
const BY: &str = "k0000000000000000000000000";
const POST: &str = "c0000000000000000000000001";
fn fixture(name: &str) -> String {
let path = format!("{}/tests/fixtures/wire/{name}", env!("CARGO_MANIFEST_DIR"));
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}"));
text.trim_end_matches('\n').to_string()
}
/// The fixture must decode to `want`, and `want` must encode to exactly the fixture's bytes.
fn check(name: &str, id: u64, last: bool, msg: Message) {
let want = Envelope {
v: 1,
id,
r#final: last,
msg,
};
let text = fixture(name);
let got: Envelope = serde_json::from_str(&text).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(got, want, "{name}: decoded value");
assert_eq!(
serde_json::to_string(&want).unwrap(),
text,
"{name}: encoded bytes"
);
}
fn pending() -> PendingApproval {
PendingApproval {
approval: 41,
session: SessionId::new("mm-r0000000000000000000000001").unwrap(),
call: CallId(3),
tool: "shell".to_string(),
arguments: r#"{"command":"ls -la /srv"}"#.to_string(),
grant: "shell-srv".to_string(),
taint: DataClass::Private,
created: Timestamp::parse("2026-09-24T08:05:00.000Z").unwrap(),
expires: Timestamp::parse("2026-09-24T08:20:00.000Z").unwrap(),
}
}
fn reply(approved: bool, reason: Option<&str>) -> ApprovalReply {
ApprovalReply {
approval: 41,
approved,
by: BY.to_string(),
post: POST.to_string(),
reason: reason.map(str::to_string),
}
}
#[test]
fn the_four_messages() {
check(
"gateway_request.json",
1,
true,
Message::ApprovalRequest(ApprovalRequest {
approval: pending(),
}),
);
let posts = vec![
"p0000000000000000000000001".to_string(),
"p0000000000000000000000002".to_string(),
];
check(
"gateway_posted.json",
1,
false,
Message::ApprovalPosted(ApprovalPosted { posts }),
);
check(
"gateway_reply_approved.json",
1,
true,
Message::ApprovalReply(reply(true, None)),
);
check(
"gateway_reply_refused.json",
1,
true,
Message::ApprovalReply(reply(false, Some("not on a Friday"))),
);
for (name, outcome) in [
("gateway_outcome_runs.json", GatewayOutcome::Runs {}),
(
"gateway_outcome_denied.json",
GatewayOutcome::Denied {
reason: DenyReason::NoGrant,
},
),
("gateway_outcome_refused.json", GatewayOutcome::Refused {}),
("gateway_outcome_gone.json", GatewayOutcome::Gone {}),
] {
check(
name,
2,
true,
Message::ApprovalOutcome(ApprovalOutcome { outcome }),
);
}
}
/// Every copy of `value` with one extra key in one object.
fn with_one_unknown_key(value: &Value) -> Vec<Value> {
let mut out = Vec::new();
if let Value::Object(map) = value {
let mut extended = map.clone();
extended.insert("zz_unknown".to_string(), Value::Bool(true));
out.push(Value::Object(extended));
for (key, child) in map {
for changed in with_one_unknown_key(child) {
let mut copy = map.clone();
copy.insert(key.clone(), changed);
out.push(Value::Object(copy));
}
}
}
out
}
#[test]
fn every_object_refuses_an_unknown_key() {
for (name, objects) in [
("gateway_request.json", 4),
("gateway_posted.json", 3),
("gateway_reply_approved.json", 3),
("gateway_reply_refused.json", 3),
("gateway_outcome_runs.json", 4),
("gateway_outcome_denied.json", 4),
("gateway_outcome_refused.json", 4),
("gateway_outcome_gone.json", 4),
] {
let value: Value = serde_json::from_str(&fixture(name)).unwrap();
let variations = with_one_unknown_key(&value);
assert_eq!(variations.len(), objects, "{name}: objects walked");
for changed in variations {
assert!(
serde_json::from_value::<Envelope>(changed.clone()).is_err(),
"{name}: accepted {changed}"
);
}
}
}
#[test]
fn an_answer_is_checked() {
assert_eq!(reply(true, None).check(), Ok(()));
assert_eq!(reply(false, Some(&"x".repeat(MAX_REASON))).check(), Ok(()));
assert_eq!(
reply(false, Some(&"é".repeat(MAX_REASON))).check(),
Ok(()),
"characters, not bytes"
);
assert!(
reply(false, Some(&"x".repeat(MAX_REASON + 1)))
.check()
.is_err()
);
for bad in [
"",
"K0000000000000000000000000",
"k000000000000000000000000",
"k00000000000000000000000000",
"../../../../../../../../etc",
] {
let mut r = reply(true, None);
r.by = bad.to_string();
assert!(r.check().is_err(), "by {bad:?}");
let mut r = reply(true, None);
r.post = bad.to_string();
assert!(r.check().is_err(), "post {bad:?}");
assert!(!is_mattermost_id(bad), "{bad:?}");
}
assert!(is_mattermost_id(BY) && is_mattermost_id(POST));
}
@@ -0,0 +1,34 @@
## gateway-unreachable
**What you see.** `brokerd` prints `brokerd: approval <id>: gatewayd is not reachable at <socket>:
<reason>; bxctl can still answer it`, then this entry, once per approval. No approval post
appears in Mattermost. `bxctl approvals` lists the approval as usual.
**Why.** `brokerd.toml` has a `[gateway]` section, so each pending approval is carried to
`gatewayd` to be posted in Mattermost. The courier could not connect to `gateway.sock`, or
`gatewayd` answered that it could not post (Mattermost unreachable). The approval is not lost: it
stays pending, `bxctl` can answer it, and the courier tries again every 5 seconds until it is
posted or answered. When a later try works, `brokerd` prints `brokerd: approval <id> posted in
Mattermost`.
**Confirm.**
```sh
ls -l <socket> # gatewayd creates it at start; [approvals] socket in gatewayd.toml
pgrep -x gatewayd # is it running?
```
and `gatewayd`'s own output: `cannot reach …` there means Mattermost is the problem (see
[mattermost-unreachable](#mattermost-unreachable)).
**Fix.** Start `gatewayd`, or make `[gateway] socket` in `brokerd.toml` and `[approvals] socket`
in `gatewayd.toml` name the same path (both default to `<home>/run/gateway/gateway.sock`). To turn
Mattermost approvals off, remove `[gateway]` from `brokerd.toml` and restart `brokerd`.
**Check.** Trigger an `ask` call: its approval is posted in Mattermost, and `!approve <id>` in
its thread answers it.
Where two Boxmakers share a channel thread, address approval commands to one of them
(`@<bot> !approve <id>`): a bare `!approve <id>` is taken by every Boxmaker with that id pending
in that thread.