Add bxctl approvals, approve, refuse and grants check

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-20 23:25:05 -07:00
parent 469be2c0a1
commit 2d94067b52
12 changed files with 1780 additions and 79 deletions
+256
View File
@@ -0,0 +1,256 @@
//! The owner's admin commands over admin.sock: approvals, approve, refuse, grants check.
use std::io::{self, Write};
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use proto::{
Approve, DecisionRecord, DenyReason, Empty, Envelope, ErrorCode, FrameError, Message,
PROTOCOL_VERSION, PendingApproval, Refuse, Timestamp, WireError, read_frame, write_frame,
};
use crate::chat::code_name;
use crate::escape::escape_json_text;
// Ask brokerd for one message and read one answer.
pub fn request(socket: &Path, msg: Message) -> Result<Message, AdminError> {
let mut stream =
UnixStream::connect(socket).map_err(|e| AdminError::Connect(socket.to_path_buf(), e))?;
let env = Envelope {
v: PROTOCOL_VERSION,
id: 1,
r#final: true,
msg,
};
write_frame(&mut stream, &env).map_err(AdminError::Frame)?;
let answer = read_frame(&mut stream).map_err(AdminError::Frame)?;
if answer.id != 1 || !answer.r#final {
return Err(AdminError::Protocol(
"expected an answer for request 1".to_string(),
));
}
match answer.msg {
Message::Error(w) => Err(AdminError::Refused(w)),
other => Ok(other),
}
}
// Ask brokerd for the list of approvals waiting, or report an answer of the wrong kind.
pub fn list(socket: &Path) -> Result<Vec<PendingApproval>, AdminError> {
match request(socket, Message::Approvals(Empty {}))? {
Message::ApprovalList(list) => Ok(list.items),
_ => Err(AdminError::Protocol(
"expected an approval list".to_string(),
)),
}
}
#[derive(Debug)]
pub enum AdminError {
Connect(PathBuf, std::io::Error),
Frame(FrameError),
Refused(WireError),
Protocol(String),
}
impl std::fmt::Display for AdminError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AdminError::Connect(p, e) => write!(f, "cannot reach brokerd at {}: {e}", p.display()),
AdminError::Frame(e) => write!(f, "{e}"),
AdminError::Refused(w) => write!(f, "{}: {}", code_name(w.code), w.detail),
AdminError::Protocol(s) => write!(f, "{s}"),
}
}
}
// An error writing the output is reported like any other failure, with the message preserved.
impl From<std::io::Error> for AdminError {
fn from(e: std::io::Error) -> Self {
AdminError::Protocol(e.to_string())
}
}
impl std::error::Error for AdminError {}
pub fn cmd_approvals(
socket: &Path,
now: Timestamp,
out: &mut dyn Write,
) -> Result<bool, AdminError> {
let items = list(socket)?;
if items.is_empty() {
writeln!(out, "no pending approvals")?;
} else {
for item in &items {
write_block(out, item, now)?;
}
}
Ok(true)
}
pub fn cmd_approve(socket: &Path, approval: u64, out: &mut dyn Write) -> Result<bool, AdminError> {
let msg = match request(socket, Message::Approve(Approve { approval })) {
Ok(m) => m,
Err(AdminError::Refused(w)) if w.code == ErrorCode::NoSuchApproval => {
writeln!(
out,
"{approval}: no such approval (already answered or expired)"
)?;
return Ok(false);
}
Err(e) => return Err(e),
};
match msg {
Message::ApproveResult(result) => match result.outcome {
DecisionRecord::Allowed {} | DecisionRecord::Ask {} => {
writeln!(out, "approved {approval}: runs")?;
Ok(true)
}
DecisionRecord::Denied { reason } => {
writeln!(out, "approved {approval}: denied ({})", reason_name(reason))?;
Ok(false)
}
},
_ => Err(AdminError::Protocol("unexpected answer".to_string())),
}
}
pub fn cmd_refuse(
socket: &Path,
approval: u64,
reason: Option<&str>,
out: &mut dyn Write,
) -> Result<bool, AdminError> {
let msg = match request(
socket,
Message::Refuse(Refuse {
approval,
reason: reason.map(str::to_string),
}),
) {
Ok(m) => m,
Err(AdminError::Refused(w)) if w.code == ErrorCode::NoSuchApproval => {
writeln!(
out,
"{approval}: no such approval (already answered or expired)"
)?;
return Ok(false);
}
Err(e) => return Err(e),
};
match msg {
Message::Ok(_) => {
writeln!(out, "refused {approval}")?;
Ok(true)
}
_ => Err(AdminError::Protocol("unexpected answer".to_string())),
}
}
pub fn cmd_grants_check(socket: &Path, out: &mut dyn Write) -> Result<bool, AdminError> {
let msg = request(socket, Message::CheckGrants(Empty {}))?;
match msg {
Message::GrantsReport(report) => {
if report.problems.is_empty() {
writeln!(out, "grants: ok")?;
Ok(true)
} else {
for problem in &report.problems {
writeln!(
out,
"{}{}: {}",
escape_json_text(&problem.file),
match problem.line {
Some(line) => format!(":{line}"),
None => String::new(),
},
escape_json_text(&problem.problem)
)?;
}
Ok(false)
}
}
_ => Err(AdminError::Protocol("unexpected answer".to_string())),
}
}
pub fn reason_name(reason: DenyReason) -> &'static str {
match reason {
DenyReason::NoGrant => "no_grant",
DenyReason::GrantExpired => "grant_expired",
DenyReason::TaintTooHigh => "taint_too_high",
DenyReason::DeniedByGrant => "denied_by_grant",
DenyReason::ApprovalRefused => "approval_refused",
DenyReason::ApprovalExpired => "approval_expired",
DenyReason::GrantsInvalid => "grants_invalid",
DenyReason::AuditUnavailable => "audit_unavailable",
DenyReason::InvalidArguments => "invalid_arguments",
DenyReason::StateUnreadable => "state_unreadable",
}
}
// The approvals table is one block per item: a summary line, then the tool and arguments.
pub fn write_block(out: &mut dyn Write, item: &PendingApproval, now: Timestamp) -> io::Result<()> {
let age = now.unix_millis().saturating_sub(item.created.unix_millis());
let until = item.expires.unix_millis().saturating_sub(now.unix_millis());
writeln!(
out,
"{} {} ago {} session {} grant {} taint {}",
item.approval,
span(age),
expiry(until, now >= item.expires),
session_shown(item.session.as_str()),
escape_json_text(&item.grant),
taint_name(item.taint),
)?;
writeln!(
out,
" {} {}",
escape_json_text(&item.tool),
escape_json_text(&item.arguments)
)?;
Ok(())
}
// A span of whole seconds, minutes, or hours, rounded down.
fn span(ms: u64) -> String {
let secs = ms / 1000;
let mins = secs / 60;
let hours = mins / 60;
if mins == 0 {
format!("{secs} s")
} else if hours == 0 {
format!("{mins} min")
} else {
format!("{hours} h")
}
}
// Whether the approval has expired, or how long until it does.
fn expiry(until: u64, expired: bool) -> String {
if expired {
"expired".to_string()
} else {
format!("expires in {}", span(until))
}
}
// A session id longer than ten characters is cut to nine and an ellipsis.
fn session_shown(session: &str) -> String {
if session.chars().count() > 10 {
let head: String = session.chars().take(9).collect();
format!("{head}")
} else {
session.to_string()
}
}
// The taint of a decision is its wire name.
fn taint_name(taint: proto::DataClass) -> &'static str {
match taint {
proto::DataClass::Public => "public",
proto::DataClass::Private => "private",
proto::DataClass::Secret => "secret",
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ impl std::error::Error for ChatError {
}
// The ErrorCode name in snake_case, spelled out in words for the owner reading a failure.
fn code_name(code: ErrorCode) -> &'static str {
pub fn code_name(code: ErrorCode) -> &'static str {
match code {
ErrorCode::BadFrame => "bad frame",
ErrorCode::BadVersion => "bad version",
+290
View File
@@ -0,0 +1,290 @@
//! `bxctl` command-line parsing: the subcommands, the shared `USAGE` and `ChatOptions`.
use std::path::{Path, PathBuf};
use proto::SessionId;
// Shown on stderr for any usage error. It names every subcommand and the flags they share, so a
// single line tells the owner what they can type.
pub const USAGE: &str = "\
usage: bxctl <command> [options]
bxctl chat [--socket PATH] [--admin-socket PATH] [--session ID] [--say TEXT] [--no-thinking] [--json]
Talk to loopd.
bxctl approvals [--admin-socket PATH]
List the approvals waiting for a decision.
bxctl approve <id> [--admin-socket PATH]
Approve approval <id>.
bxctl refuse <id> [--reason TEXT] [--admin-socket PATH]
Refuse approval <id>, recording a reason.
bxctl grants check [--admin-socket PATH]
Check that the grants load.
bxctl audit verify [--home DIR]
Verify the audit log against the grants.";
// The options every `chat` call carries. `socket` is the loop socket; `admin_socket` is the admin
// socket the other subcommands use. Both default to the paths under `$BOXMAKER_HOME`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ChatOptions {
pub socket: PathBuf,
pub admin_socket: PathBuf,
pub session: Option<SessionId>,
pub show_thinking: bool,
pub say: Option<String>,
pub json: bool,
}
impl ChatOptions {
pub fn default(home: &Path) -> Self {
ChatOptions {
socket: home.join("run").join("loop").join("loop.sock"),
admin_socket: default_admin_socket(home),
session: None,
show_thinking: true,
say: None,
json: false,
}
}
}
// Why `parse` rejected the arguments. There is only one kind: the arguments were wrong.
#[derive(Debug, PartialEq, Eq)]
pub struct UsageError;
impl std::fmt::Display for UsageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "invalid arguments")
}
}
impl std::error::Error for UsageError {}
// What the owner asked for. `Chat` carries its options; every admin subcommand carries the admin
// socket to use (defaulted from `$BOXMAKER_HOME`) plus whatever only it needs.
#[derive(Debug, PartialEq, Eq)]
pub enum Command {
Chat(ChatOptions),
Approvals {
admin_socket: PathBuf,
},
Approve {
admin_socket: PathBuf,
approval: u64,
},
Refuse {
admin_socket: PathBuf,
approval: u64,
reason: Option<String>,
},
GrantsCheck {
admin_socket: PathBuf,
},
AuditVerify {
home: PathBuf,
},
}
pub fn parse(args: &[String], home: &Path) -> Result<Command, UsageError> {
let (command, rest) = match args.split_first() {
Some((c, r)) => (c.as_str(), r),
None => return Err(UsageError),
};
match command {
"chat" => parse_chat(rest, home),
"approvals" => parse_approvals(rest, home),
"approve" => parse_approve(rest, home),
"refuse" => parse_refuse(rest, home),
"grants" => parse_grants(rest, home),
"audit" => parse_audit(rest, home),
// A flag before the command, or any unknown command, is not valid.
_ => Err(UsageError),
}
}
fn default_admin_socket(home: &Path) -> PathBuf {
home.join("run").join("owner-broker").join("admin.sock")
}
fn parse_chat(args: &[String], home: &Path) -> Result<Command, UsageError> {
let mut opts = ChatOptions::default(home);
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--socket" => opts.socket = PathBuf::from(value_at(args, i)?),
"--admin-socket" => opts.admin_socket = PathBuf::from(value_at(args, i)?),
"--session" => opts.session = Some(parse_session(&value_at(args, i)?)?),
"--say" => opts.say = Some(value_at(args, i)?),
"--no-thinking" => {
opts.show_thinking = false;
i += 1;
continue;
}
"--json" => {
opts.json = true;
i += 1;
continue;
}
_ => return Err(UsageError),
}
i += 2;
}
Ok(Command::Chat(opts))
}
fn parse_approvals(args: &[String], home: &Path) -> Result<Command, UsageError> {
let mut admin_socket = default_admin_socket(home);
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--admin-socket" => admin_socket = PathBuf::from(value_at(args, i)?),
// Anything else is a stray argument or an unknown flag.
_ => return Err(UsageError),
}
i += 2;
}
Ok(Command::Approvals { admin_socket })
}
fn parse_approve(args: &[String], home: &Path) -> Result<Command, UsageError> {
let mut admin_socket = default_admin_socket(home);
let mut approval = None;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--admin-socket" => {
admin_socket = PathBuf::from(value_at(args, i)?);
i += 2;
continue;
}
other => {
if approval.is_some() {
return Err(UsageError);
}
approval = Some(parse_id(other)?);
i += 1;
}
}
}
match approval {
Some(approval) => Ok(Command::Approve {
admin_socket,
approval,
}),
None => Err(UsageError),
}
}
fn parse_refuse(args: &[String], home: &Path) -> Result<Command, UsageError> {
let mut admin_socket = default_admin_socket(home);
let mut approval = None;
let mut reason = None;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--admin-socket" => {
admin_socket = PathBuf::from(value_at(args, i)?);
i += 2;
continue;
}
"--reason" => {
if reason.is_some() {
return Err(UsageError);
}
reason = Some(verify_reason(&value_at(args, i)?)?);
i += 2;
continue;
}
other => {
if approval.is_some() {
return Err(UsageError);
}
approval = Some(parse_id(other)?);
i += 1;
}
}
}
match approval {
Some(approval) => Ok(Command::Refuse {
admin_socket,
approval,
reason,
}),
None => Err(UsageError),
}
}
fn parse_grants(args: &[String], home: &Path) -> Result<Command, UsageError> {
let rest = match args.first().map(String::as_str) {
Some("check") => &args[1..],
_ => return Err(UsageError),
};
let mut admin_socket = default_admin_socket(home);
let mut i = 0;
while i < rest.len() {
match rest[i].as_str() {
"--admin-socket" => admin_socket = PathBuf::from(value_at(rest, i)?),
_ => return Err(UsageError),
}
i += 2;
}
Ok(Command::GrantsCheck { admin_socket })
}
fn parse_audit(args: &[String], home: &Path) -> Result<Command, UsageError> {
let rest = match args.first().map(String::as_str) {
Some("verify") => &args[1..],
_ => return Err(UsageError),
};
let mut home = home.to_path_buf();
let mut i = 0;
while i < rest.len() {
match rest[i].as_str() {
"--home" => home = PathBuf::from(value_at(rest, i)?),
_ => return Err(UsageError),
}
i += 2;
}
Ok(Command::AuditVerify { home })
}
// The argument after the flag at `i`, taken as the value even when it looks like a flag, so a value
// is a value.
fn value_at(args: &[String], i: usize) -> Result<String, UsageError> {
args.get(i + 1).cloned().ok_or(UsageError)
}
// An approval id is ASCII digits that fit in u64 and nothing else; `str::parse::<u64>` alone would
// accept `+41`, so the digit check comes first.
fn parse_id(value: &str) -> Result<u64, UsageError> {
if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) {
return Err(UsageError);
}
value.parse::<u64>().map_err(|_| UsageError)
}
// A session id is a valid id, and one that is purely numeric must fit in u64 so it is never
// mistaken for an approval id.
fn parse_session(value: &str) -> Result<SessionId, UsageError> {
match SessionId::new(value) {
Ok(id) => {
if value.bytes().all(|b| b.is_ascii_digit()) && value.parse::<u64>().is_err() {
return Err(UsageError);
}
Ok(id)
}
Err(_) => Err(UsageError),
}
}
// A refuse reason must be present; its content is unvalidated on the client.
fn verify_reason(reason: &str) -> Result<String, UsageError> {
if reason.is_empty() {
return Err(UsageError);
}
Ok(reason.to_string())
}
+43
View File
@@ -0,0 +1,43 @@
//! The two text-escape helpers the owner's client needs.
// 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
}
}
+4
View File
@@ -1,3 +1,7 @@
//! The owner's command-line tool.
pub mod admin;
pub mod chat;
pub mod cli;
pub mod escape;
pub mod verify;
+68 -78
View File
@@ -4,85 +4,75 @@ use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use bxctl::admin::{self, AdminError};
use bxctl::chat::{ChatError, Printer, new_session_id, run_turn};
use proto::{ErrorCode, SessionId, TurnDone, TurnEvent};
const USAGE: &str =
"usage: bxctl chat [--socket <path>] [--session <id>] [--no-thinking] [--say <text>] [--json]";
struct Options {
socket: PathBuf,
session: Option<SessionId>,
show_thinking: bool,
say: Option<String>,
json: bool,
}
use bxctl::cli::{self, ChatOptions, Command, USAGE};
use proto::{ErrorCode, SessionId, Timestamp, TurnDone, TurnEvent};
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.first().map(String::as_str) != Some("chat") {
eprintln!("{USAGE}");
return ExitCode::from(2);
}
match parse_chat(&args[1..]) {
Ok(opts) => run(&opts),
Err(()) => {
eprintln!("{USAGE}");
ExitCode::from(2)
}
}
}
fn parse_chat(args: &[String]) -> Result<Options, ()> {
let mut opts = Options {
socket: default_socket(),
session: None,
show_thinking: true,
say: None,
json: false,
};
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--socket" => {
i += 1;
match args.get(i) {
Some(p) => opts.socket = PathBuf::from(p),
None => return Err(()),
}
}
"--session" => {
i += 1;
match args.get(i) {
Some(s) => match SessionId::new(s) {
Ok(id) => opts.session = Some(id),
Err(_) => return Err(()),
},
None => return Err(()),
}
}
"--say" => {
i += 1;
match args.get(i) {
Some(t) => opts.say = Some(t.to_string()),
None => return Err(()),
}
}
"--no-thinking" => opts.show_thinking = false,
"--json" => opts.json = true,
_ => return Err(()),
}
i += 1;
}
Ok(opts)
}
// The base directory: $BOXMAKER_HOME if set, otherwise the system location the brief fixes.
fn default_socket() -> PathBuf {
let base = std::env::var_os("BOXMAKER_HOME")
// $BOXMAKER_HOME defaults to /var/lib/boxmaker; defaults for the sockets are read from it.
let home = std::env::var_os("BOXMAKER_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/var/lib/boxmaker"));
base.join("run").join("loop").join("loop.sock")
// Parse before connecting, so a usage error exits 2 even without a broker.
let command = match cli::parse(&args, &home) {
Ok(command) => command,
Err(_) => {
eprintln!("{USAGE}");
return ExitCode::from(2);
}
};
match command {
Command::Chat(opts) => run(&opts),
Command::Approvals { admin_socket } => exit(|| cmd_approvals(&admin_socket)),
Command::Approve {
admin_socket,
approval,
} => exit(|| cmd_approve(&admin_socket, approval)),
Command::Refuse {
admin_socket,
approval,
reason,
} => exit(|| cmd_refuse(&admin_socket, approval, reason.as_deref())),
Command::GrantsCheck { admin_socket } => exit(|| cmd_grants_check(&admin_socket)),
Command::AuditVerify { home: _ } => {
eprintln!("bxctl: audit verify is not implemented yet");
ExitCode::from(1)
}
}
}
// Run a command, mapping its result to an exit code: success for true, 1 for false or an error.
fn exit<F: FnOnce() -> Result<bool, AdminError>>(f: F) -> ExitCode {
match f() {
Ok(true) => ExitCode::SUCCESS,
Ok(false) => ExitCode::from(1),
Err(e) => {
eprintln!("bxctl: {e}");
ExitCode::from(1)
}
}
}
fn cmd_approvals(admin: &Path) -> Result<bool, AdminError> {
let mut out = std::io::stdout().lock();
admin::cmd_approvals(admin, Timestamp::now(), &mut out)
}
fn cmd_approve(admin: &Path, id: u64) -> Result<bool, AdminError> {
let mut out = std::io::stdout().lock();
admin::cmd_approve(admin, id, &mut out)
}
fn cmd_refuse(admin: &Path, id: u64, reason: Option<&str>) -> Result<bool, AdminError> {
let mut out = std::io::stdout().lock();
admin::cmd_refuse(admin, id, reason, &mut out)
}
fn cmd_grants_check(admin: &Path) -> Result<bool, AdminError> {
let mut out = std::io::stdout().lock();
admin::cmd_grants_check(admin, &mut out)
}
// A writer that records the first io error it hits, so the on_event closure (which cannot return a
@@ -123,14 +113,14 @@ fn stream_turn(
}
}
fn run(opts: &Options) -> ExitCode {
fn run(opts: &ChatOptions) -> ExitCode {
match &opts.say {
Some(text) => run_say(opts, text),
None => run_interactive(opts),
}
}
fn run_say(opts: &Options, text: &str) -> ExitCode {
fn run_say(opts: &ChatOptions, text: &str) -> ExitCode {
let session = match &opts.session {
Some(s) => s.clone(),
None => new_session_id(),
@@ -152,7 +142,7 @@ fn run_say(opts: &Options, text: &str) -> ExitCode {
// Runs a --say turn, retrying once when the named session does not exist.
fn run_turn_twice(
opts: &Options,
opts: &ChatOptions,
session: &SessionId,
text: &str,
resume: bool,
@@ -189,7 +179,7 @@ fn run_turn_twice(
}
}
fn run_interactive(opts: &Options) -> ExitCode {
fn run_interactive(opts: &ChatOptions) -> ExitCode {
let mut printer = Printer::new(opts.show_thinking, opts.json);
let mut session = None;
let stdin = std::io::stdin();
@@ -255,7 +245,7 @@ fn run_interactive(opts: &Options) -> ExitCode {
}
// The plain answer goes to stdout; in --json mode the TurnDone goes to stderr after the events.
fn write_answer(opts: &Options, done: &TurnDone) -> std::io::Result<()> {
fn write_answer(opts: &ChatOptions, done: &TurnDone) -> std::io::Result<()> {
if opts.json {
let line = serde_json::to_string(done).map_err(std::io::Error::other)?;
let mut err = std::io::stderr().lock();
+1
View File
@@ -0,0 +1 @@
//! Placeholder for the audit-log verifier (task 19).
+497
View File
@@ -0,0 +1,497 @@
//! Tests for `bxctl`'s admin client and the commands built on it, against a fake `brokerd`.
//! Do not edit.
mod support;
use bxctl::admin::{
AdminError, cmd_approvals, cmd_approve, cmd_grants_check, cmd_refuse, list, reason_name,
request, write_block,
};
use proto::{
Approve, DecisionRecord, DenyReason, Empty, Envelope, ErrorCode, GrantProblem, GrantsReport,
Message, PROTOCOL_VERSION, Refuse,
};
use std::process::Command;
use support::{brokerd_with, fake_brokerd, fake_brokerd_frames, pending, ts, wire_error};
const BACKSLASH: char = '\\';
/// The escape for one code point, built and never spelled out (see `tests/escape.rs`).
fn esc(code: u32) -> String {
format!("{BACKSLASH}u{code:04x}")
}
fn text(out: Vec<u8>) -> String {
String::from_utf8(out).unwrap()
}
const ALLOWED: DecisionRecord = DecisionRecord::Allowed {};
// ---- the client ----
#[test]
fn request_sends_one_final_frame_with_id_1_and_returns_the_answer() {
let fake = fake_brokerd_frames(|request| {
assert_eq!(request.v, PROTOCOL_VERSION);
assert_eq!(request.id, 1);
assert!(request.r#final);
vec![Envelope {
v: PROTOCOL_VERSION,
id: 1,
r#final: true,
msg: Message::Ok(Empty {}),
}]
});
let answer = request(&fake.socket, Message::CheckGrants(Empty {})).unwrap();
assert_eq!(answer, Message::Ok(Empty {}));
assert_eq!(fake.requests(), vec![Message::CheckGrants(Empty {})]);
}
#[test]
fn an_error_frame_is_refused_with_its_code_and_detail() {
let fake = fake_brokerd(|_| wire_error(ErrorCode::Forbidden, "not on this socket"));
match request(&fake.socket, Message::Approvals(Empty {})) {
Err(AdminError::Refused(w)) => {
assert_eq!(w.code, ErrorCode::Forbidden);
assert_eq!(w.detail, "not on this socket");
}
other => panic!("{other:?}"),
}
let e = request(&fake.socket, Message::Approvals(Empty {})).unwrap_err();
assert_eq!(e.to_string(), "forbidden: not on this socket");
}
#[test]
fn an_answer_that_is_not_final_or_has_another_id_is_a_protocol_error() {
let not_final = fake_brokerd_frames(|request| {
vec![Envelope {
v: PROTOCOL_VERSION,
id: request.id,
r#final: false,
msg: Message::Ok(Empty {}),
}]
});
assert!(matches!(
request(&not_final.socket, Message::Approvals(Empty {})),
Err(AdminError::Protocol(_))
));
let other_id = fake_brokerd_frames(|request| {
vec![Envelope {
v: PROTOCOL_VERSION,
id: request.id + 1,
r#final: true,
msg: Message::Ok(Empty {}),
}]
});
assert!(matches!(
request(&other_id.socket, Message::Approvals(Empty {})),
Err(AdminError::Protocol(_))
));
}
#[test]
fn a_connection_closed_without_an_answer_is_a_frame_error() {
let fake = fake_brokerd_frames(|_| Vec::new());
assert!(matches!(
request(&fake.socket, Message::Approvals(Empty {})),
Err(AdminError::Frame(_))
));
}
#[test]
fn no_brokerd_is_a_connect_error_that_names_the_socket() {
let missing = support::temp_socket("nobody-listens.sock");
let e = request(&missing, Message::Approvals(Empty {})).unwrap_err();
assert!(matches!(e, AdminError::Connect(_, _)), "{e:?}");
let message = e.to_string();
assert!(message.starts_with("cannot reach brokerd at "), "{message}");
assert!(message.contains(missing.to_str().unwrap()), "{message}");
}
#[test]
fn list_returns_the_items_and_rejects_any_other_kind() {
let items = vec![pending(41, "shell", "{}"), pending(44, "read_file", "{}")];
let fake = brokerd_with(items.clone(), ALLOWED);
assert_eq!(list(&fake.socket).unwrap(), items);
assert_eq!(fake.requests(), vec![Message::Approvals(Empty {})]);
let wrong = fake_brokerd(|_| Message::Ok(Empty {}));
assert!(matches!(list(&wrong.socket), Err(AdminError::Protocol(_))));
}
#[test]
fn reason_names_are_the_wire_names() {
for reason in [
DenyReason::NoGrant,
DenyReason::GrantExpired,
DenyReason::TaintTooHigh,
DenyReason::DeniedByGrant,
DenyReason::ApprovalRefused,
DenyReason::ApprovalExpired,
DenyReason::GrantsInvalid,
DenyReason::AuditUnavailable,
DenyReason::InvalidArguments,
DenyReason::StateUnreadable,
] {
let wire = serde_json::to_string(&reason).unwrap();
assert_eq!(format!("\"{}\"", reason_name(reason)), wire);
}
}
// ---- the block ----
fn block(item: &proto::PendingApproval, now: &str) -> String {
let mut out = Vec::new();
write_block(&mut out, item, ts(now)).unwrap();
text(out)
}
#[test]
fn the_block_is_two_lines_in_this_exact_form() {
let item = pending(
41,
"shell",
r#"{"command":"rm -rf /home/kyle/scratch/build","cwd":"/home/kyle/scratch"}"#,
);
assert_eq!(
block(&item, "2026-09-18T12:02:00.000Z"),
concat!(
"41 2 min ago expires in 13 min session chat-1758… grant shell-scratch taint private\n",
" shell {\"command\":\"rm -rf /home/kyle/scratch/build\",\"cwd\":\"/home/kyle/scratch\"}\n",
)
);
}
#[test]
fn times_are_whole_seconds_minutes_or_hours_rounded_down() {
let item = pending(41, "shell", "{}");
let first = |now: &str| block(&item, now).lines().next().unwrap().to_string();
// created 12:00:00, expires 12:15:00
assert!(first("2026-09-18T12:00:00.000Z").starts_with("41 0 s ago expires in 15 min "));
assert!(first("2026-09-18T12:00:59.999Z").starts_with("41 59 s ago expires in 14 min "));
assert!(first("2026-09-18T12:01:00.000Z").starts_with("41 1 min ago expires in 14 min "));
assert!(first("2026-09-18T12:14:30.000Z").starts_with("41 14 min ago expires in 30 s "));
// At or after `expires` there is nothing left to wait for.
assert!(first("2026-09-18T12:15:00.000Z").starts_with("41 15 min ago expired "));
assert!(first("2026-09-18T14:05:00.000Z").starts_with("41 2 h ago expired "));
// A clock that is behind the broker's must not underflow.
assert!(first("2026-09-18T11:59:00.000Z").starts_with("41 0 s ago expires in 16 min "));
let mut long = pending(41, "shell", "{}");
long.expires = ts("2026-09-18T15:30:00.000Z");
assert!(block(&long, "2026-09-18T12:00:00.000Z").starts_with("41 0 s ago expires in 3 h "));
}
#[test]
fn a_session_id_longer_than_ten_characters_is_cut_to_nine_and_an_ellipsis() {
let mut item = pending(41, "shell", "{}");
for (id, shown) in [
("s1", "session s1 "),
("abcdefghij", "session abcdefghij "),
("abcdefghijk", "session abcdefghi… "),
] {
item.session = proto::SessionId::new(id).unwrap();
let got = block(&item, "2026-09-18T12:00:00.000Z");
assert!(got.contains(shown), "{id}: {got}");
}
}
#[test]
fn taint_is_the_wire_name() {
let mut item = pending(41, "shell", "{}");
item.taint = proto::DataClass::Secret;
assert!(block(&item, "2026-09-18T12:00:00.000Z").contains(" taint secret\n"));
}
/// Whatever `brokerd` sends is printed as data: tool, grant and arguments all go through
/// `escape_json_text`.
#[test]
fn nothing_in_the_block_reaches_the_terminal_raw() {
let rlo = char::from_u32(0x202e).unwrap();
let zwsp = char::from_u32(0x200b).unwrap();
let isolate = char::from_u32(0x2066).unwrap();
let arguments =
format!("{{\"path\":\"/home/kyle/notes/{rlo}dm.terces{zwsp}{isolate}\x1b[8m\x1b[2J\"}}");
let mut item = pending(41, "read\x1b[1mfile", &arguments);
item.grant = "notes\nread".to_string();
let got = block(&item, "2026-09-18T12:00:00.000Z");
for c in ['\x1b', rlo, zwsp, isolate] {
assert!(!got.contains(c), "U+{:04X} in {got:?}", u32::from(c));
}
assert_eq!(got.matches('\n').count(), 2, "still two lines: {got:?}");
assert!(
got.contains(&format!(
"/home/kyle/notes/{}dm.terces{}{}{}[8m{}[2J",
esc(0x202e),
esc(0x200b),
esc(0x2066),
esc(0x1b),
esc(0x1b)
)),
"{got:?}"
);
assert!(
got.contains(&format!(" read{}[1mfile ", esc(0x1b))),
"{got:?}"
);
assert!(
got.contains(&format!("grant notes{}read ", esc(0x0a))),
"{got:?}"
);
}
// ---- the commands ----
#[test]
fn approvals_prints_one_block_per_item_in_the_order_given() {
let fake = brokerd_with(
vec![
pending(41, "shell", r#"{"command":"ls"}"#),
pending(44, "read_file", r#"{"path":"/home/kyle/notes/a.md"}"#),
],
ALLOWED,
);
let mut out = Vec::new();
let ok = cmd_approvals(&fake.socket, ts("2026-09-18T12:02:00.000Z"), &mut out).unwrap();
assert!(ok);
let got = text(out);
let lines: Vec<&str> = got.lines().collect();
assert_eq!(lines.len(), 4, "{got}");
assert!(lines[0].starts_with("41 2 min ago "), "{got}");
assert_eq!(lines[1], r#" shell {"command":"ls"}"#);
assert!(lines[2].starts_with("44 2 min ago "), "{got}");
assert_eq!(
lines[3],
r#" read_file {"path":"/home/kyle/notes/a.md"}"#
);
}
#[test]
fn approvals_with_nothing_pending_says_so() {
let fake = brokerd_with(Vec::new(), ALLOWED);
let mut out = Vec::new();
assert!(cmd_approvals(&fake.socket, ts("2026-09-18T12:00:00.000Z"), &mut out).unwrap());
assert_eq!(text(out), "no pending approvals\n");
}
#[test]
fn approve_reports_the_re_decision() {
// `ask` and `allowed` both let the call run; only a denial stops it.
for (outcome, line, ok) in [
(DecisionRecord::Allowed {}, "approved 41: runs\n", true),
(DecisionRecord::Ask {}, "approved 41: runs\n", true),
(
DecisionRecord::Denied {
reason: DenyReason::NoGrant,
},
"approved 41: denied (no_grant)\n",
false,
),
(
DecisionRecord::Denied {
reason: DenyReason::TaintTooHigh,
},
"approved 41: denied (taint_too_high)\n",
false,
),
(
DecisionRecord::Denied {
reason: DenyReason::AuditUnavailable,
},
"approved 41: denied (audit_unavailable)\n",
false,
),
] {
let fake = brokerd_with(vec![pending(41, "shell", "{}")], outcome);
let mut out = Vec::new();
assert_eq!(cmd_approve(&fake.socket, 41, &mut out).unwrap(), ok);
assert_eq!(text(out), line);
assert_eq!(
fake.requests(),
vec![Message::Approve(Approve { approval: 41 })]
);
}
}
#[test]
fn refuse_sends_the_reason_when_there_is_one() {
for reason in [None, Some("not on a Friday")] {
let fake = brokerd_with(vec![pending(41, "shell", "{}")], ALLOWED);
let mut out = Vec::new();
assert!(cmd_refuse(&fake.socket, 41, reason, &mut out).unwrap());
assert_eq!(text(out), "refused 41\n");
assert_eq!(
fake.requests(),
vec![Message::Refuse(Refuse {
approval: 41,
reason: reason.map(str::to_string)
})]
);
}
}
#[test]
fn an_unknown_or_answered_id_is_reported_the_same_way_by_both() {
let fake = brokerd_with(vec![pending(41, "shell", "{}")], ALLOWED);
let want = "99: no such approval (already answered or expired)\n";
let mut out = Vec::new();
assert!(!cmd_approve(&fake.socket, 99, &mut out).unwrap());
assert_eq!(text(out), want);
let mut out = Vec::new();
assert!(!cmd_refuse(&fake.socket, 99, None, &mut out).unwrap());
assert_eq!(text(out), want);
}
/// Every exit of every command: any other error frame is an error, and so is an answer of the
/// wrong kind. Nothing is printed for them.
#[test]
fn other_errors_and_wrong_kinds_are_errors_for_every_command() {
let refused = fake_brokerd(|_| wire_error(ErrorCode::Internal, "boom"));
let wrong = fake_brokerd(|_| {
Message::GrantsReport(GrantsReport {
problems: Vec::new(),
})
});
let wrong_for_grants = fake_brokerd(|_| Message::Ok(Empty {}));
let now = ts("2026-09-18T12:00:00.000Z");
let mut out = Vec::new();
assert!(matches!(
cmd_approvals(&refused.socket, now, &mut out),
Err(AdminError::Refused(_))
));
assert!(matches!(
cmd_approve(&refused.socket, 41, &mut out),
Err(AdminError::Refused(_))
));
assert!(matches!(
cmd_refuse(&refused.socket, 41, None, &mut out),
Err(AdminError::Refused(_))
));
assert!(matches!(
cmd_grants_check(&refused.socket, &mut out),
Err(AdminError::Refused(_))
));
assert!(matches!(
cmd_approvals(&wrong.socket, now, &mut out),
Err(AdminError::Protocol(_))
));
assert!(matches!(
cmd_approve(&wrong.socket, 41, &mut out),
Err(AdminError::Protocol(_))
));
assert!(matches!(
cmd_refuse(&wrong.socket, 41, None, &mut out),
Err(AdminError::Protocol(_))
));
assert!(matches!(
cmd_grants_check(&wrong_for_grants.socket, &mut out),
Err(AdminError::Protocol(_))
));
assert_eq!(text(out), "", "an error prints nothing on the output");
}
#[test]
fn grants_check_prints_ok_or_every_problem() {
let fine = fake_brokerd(|_| {
Message::GrantsReport(GrantsReport {
problems: Vec::new(),
})
});
let mut out = Vec::new();
assert!(cmd_grants_check(&fine.socket, &mut out).unwrap());
assert_eq!(text(out), "grants: ok\n");
assert_eq!(fine.requests(), vec![Message::CheckGrants(Empty {})]);
let broken = fake_brokerd(|_| {
Message::GrantsReport(GrantsReport {
problems: vec![
GrantProblem {
file: "notes-read.toml".to_string(),
line: Some(3),
problem: "unknown field `mod`".to_string(),
},
GrantProblem {
file: "Bad Name.toml".to_string(),
line: None,
problem: "the file name is not a valid grant id".to_string(),
},
GrantProblem {
file: "x.toml".to_string(),
line: Some(1),
problem: "two\nlines".to_string(),
},
],
})
});
let mut out = Vec::new();
assert!(!cmd_grants_check(&broken.socket, &mut out).unwrap());
assert_eq!(
text(out),
format!(
"notes-read.toml:3: unknown field `mod`\n\
Bad Name.toml: the file name is not a valid grant id\n\
x.toml:1: two{}lines\n",
esc(0x0a)
),
"every problem, one line each; file and problem go through escape_json_text"
);
}
// ---- the binary ----
fn bxctl(args: &[&str], socket: &std::path::Path) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_bxctl"))
.args(args)
.arg("--admin-socket")
.arg(socket)
.output()
.unwrap()
}
#[test]
fn the_binary_prints_on_stdout_and_sets_the_exit_status() {
let fake = brokerd_with(
vec![pending(41, "shell", r#"{"command":"ls"}"#)],
DecisionRecord::Denied {
reason: DenyReason::DeniedByGrant,
},
);
let output = bxctl(&["approvals"], &fake.socket);
assert_eq!(output.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.ends_with(" shell {\"command\":\"ls\"}\n"),
"{stdout}"
);
let output = bxctl(&["approve", "41"], &fake.socket);
assert_eq!(output.status.code(), Some(1));
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"approved 41: denied (denied_by_grant)\n"
);
let output = bxctl(&["refuse", "41", "--reason", "no"], &fake.socket);
assert_eq!(output.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&output.stdout), "refused 41\n");
let output = bxctl(&["refuse", "7"], &fake.socket);
assert_eq!(output.status.code(), Some(1));
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"7: no such approval (already answered or expired)\n"
);
}
#[test]
fn the_binary_reports_an_error_on_stderr_with_status_1() {
let fake = fake_brokerd(|_| wire_error(ErrorCode::Internal, "boom"));
let output = bxctl(&["grants", "check"], &fake.socket);
assert_eq!(output.status.code(), Some(1));
assert_eq!(String::from_utf8_lossy(&output.stdout), "");
assert_eq!(
String::from_utf8_lossy(&output.stderr),
"bxctl: internal: boom\n"
);
}
+283
View File
@@ -0,0 +1,283 @@
//! Tests for `bxctl`'s command line. Do not edit.
use bxctl::cli::{ChatOptions, Command, USAGE, UsageError, parse};
use proto::SessionId;
use std::path::{Path, PathBuf};
use std::process::Command as Process;
const HOME: &str = "/srv/bx";
fn args(words: &[&str]) -> Vec<String> {
words.iter().map(|w| w.to_string()).collect()
}
fn ok(words: &[&str]) -> Command {
parse(&args(words), Path::new(HOME)).unwrap_or_else(|_| panic!("{words:?} must parse"))
}
fn bad(words: &[&str]) {
assert_eq!(
parse(&args(words), Path::new(HOME)),
Err(UsageError),
"{words:?} must be a usage error"
);
}
fn default_admin() -> PathBuf {
PathBuf::from("/srv/bx/run/owner-broker/admin.sock")
}
#[test]
fn chat_defaults_come_from_home() {
assert_eq!(
ok(&["chat"]),
Command::Chat(ChatOptions {
socket: PathBuf::from("/srv/bx/run/loop/loop.sock"),
admin_socket: default_admin(),
session: None,
show_thinking: true,
say: None,
json: false,
})
);
}
#[test]
fn chat_takes_every_flag_in_any_order() {
assert_eq!(
ok(&[
"chat",
"--json",
"--admin-socket",
"/tmp/a.sock",
"--say",
"hello there",
"--no-thinking",
"--session",
"s-1",
"--socket",
"/tmp/l.sock",
]),
Command::Chat(ChatOptions {
socket: PathBuf::from("/tmp/l.sock"),
admin_socket: PathBuf::from("/tmp/a.sock"),
session: Some(SessionId::new("s-1").unwrap()),
show_thinking: false,
say: Some("hello there".to_string()),
json: true,
})
);
}
#[test]
fn chat_usage_errors() {
bad(&["chat", "--session", "Not Valid!"]);
bad(&["chat", "--session"]);
bad(&["chat", "--socket"]);
bad(&["chat", "--admin-socket"]);
bad(&["chat", "--say"]);
bad(&["chat", "--dance"]);
bad(&["chat", "stray"]);
}
#[test]
fn approvals() {
assert_eq!(
ok(&["approvals"]),
Command::Approvals {
admin_socket: default_admin()
}
);
assert_eq!(
ok(&["approvals", "--admin-socket", "/tmp/a.sock"]),
Command::Approvals {
admin_socket: PathBuf::from("/tmp/a.sock")
}
);
bad(&["approvals", "41"]);
bad(&["approvals", "--admin-socket"]);
bad(&["approvals", "--reason", "x"]);
}
#[test]
fn approve() {
assert_eq!(
ok(&["approve", "41"]),
Command::Approve {
admin_socket: default_admin(),
approval: 41
}
);
// The flag may come before or after the id.
for words in [
["approve", "--admin-socket", "/tmp/a.sock", "41"],
["approve", "41", "--admin-socket", "/tmp/a.sock"],
] {
assert_eq!(
ok(&words),
Command::Approve {
admin_socket: PathBuf::from("/tmp/a.sock"),
approval: 41
}
);
}
assert_eq!(
ok(&["approve", "18446744073709551615"]),
Command::Approve {
admin_socket: default_admin(),
approval: u64::MAX
}
);
}
/// An id is decimal digits and nothing else. `str::parse::<u64>` alone would accept `+41`.
#[test]
fn an_approval_id_is_only_digits() {
bad(&["approve"]);
bad(&["approve", "41", "42"]);
bad(&["approve", "+41"]);
bad(&["approve", "-1"]);
bad(&["approve", "4 1"]);
bad(&["approve", " 41"]);
bad(&["approve", "0x29"]);
bad(&["approve", "forty-one"]);
bad(&["approve", ""]);
bad(&["approve", "18446744073709551616"]);
bad(&["approve", "41", "--reason", "x"]);
bad(&["refuse"]);
bad(&["refuse", "+41"]);
bad(&["refuse", "41", "42"]);
}
#[test]
fn refuse() {
assert_eq!(
ok(&["refuse", "41"]),
Command::Refuse {
admin_socket: default_admin(),
approval: 41,
reason: None
}
);
assert_eq!(
ok(&[
"refuse",
"41",
"--reason",
"not on a Friday",
"--admin-socket",
"/tmp/a.sock"
]),
Command::Refuse {
admin_socket: PathBuf::from("/tmp/a.sock"),
approval: 41,
reason: Some("not on a Friday".to_string())
}
);
// A value is a value, even when it looks like a flag.
assert_eq!(
ok(&["refuse", "--reason", "--admin-socket", "41"]),
Command::Refuse {
admin_socket: default_admin(),
approval: 41,
reason: Some("--admin-socket".to_string())
}
);
bad(&["refuse", "41", "--reason"]);
bad(&["refuse", "41", "--reason", "a", "--reason", "b"]);
}
#[test]
fn grants_check_and_audit_verify() {
assert_eq!(
ok(&["grants", "check"]),
Command::GrantsCheck {
admin_socket: default_admin()
}
);
assert_eq!(
ok(&["grants", "check", "--admin-socket", "/tmp/a.sock"]),
Command::GrantsCheck {
admin_socket: PathBuf::from("/tmp/a.sock")
}
);
assert_eq!(
ok(&["audit", "verify"]),
Command::AuditVerify {
home: PathBuf::from(HOME)
}
);
assert_eq!(
ok(&["audit", "verify", "--home", "/tmp/h"]),
Command::AuditVerify {
home: PathBuf::from("/tmp/h")
}
);
bad(&["grants"]);
bad(&["grants", "list"]);
bad(&["grants", "check", "extra"]);
bad(&["audit"]);
bad(&["audit", "verify", "--home"]);
bad(&["audit", "verify", "--admin-socket", "/tmp/a.sock"]);
}
#[test]
fn anything_else_is_a_usage_error() {
bad(&[]);
bad(&["dance"]);
bad(&["--admin-socket", "/tmp/a.sock", "approvals"]);
}
#[test]
fn usage_names_every_command() {
for word in [
"bxctl chat",
"bxctl approvals",
"bxctl approve <id>",
"bxctl refuse <id>",
"bxctl grants check",
"bxctl audit verify",
"--admin-socket",
"--reason",
"--home",
] {
assert!(USAGE.contains(word), "usage lacks {word:?}");
}
}
#[test]
fn the_binary_prints_usage_and_exits_2() {
for words in [
vec![],
vec!["dance"],
vec!["approve", "+41"],
vec!["refuse"],
] {
let output = Process::new(env!("CARGO_BIN_EXE_bxctl"))
.args(&words)
.output()
.unwrap();
assert_eq!(output.status.code(), Some(2), "{words:?}");
assert_eq!(String::from_utf8_lossy(&output.stdout), "", "{words:?}");
assert!(
String::from_utf8_lossy(&output.stderr).contains("bxctl approve <id>"),
"{words:?}"
);
}
}
/// The default sockets are under `$BOXMAKER_HOME`. Nothing listens there, so the command fails
/// to connect, and says where it tried.
#[test]
fn the_binary_finds_the_admin_socket_under_boxmaker_home() {
let home = std::env::temp_dir().join(format!("bxctl-cli-home-{}", std::process::id()));
let output = Process::new(env!("CARGO_BIN_EXE_bxctl"))
.arg("approvals")
.env("BOXMAKER_HOME", &home)
.output()
.unwrap();
assert_eq!(output.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&output.stderr);
let want = home.join("run/owner-broker/admin.sock");
assert!(stderr.contains(want.to_str().unwrap()), "{stderr}");
}
+150
View File
@@ -0,0 +1,150 @@
//! Tests for `bxctl::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 bxctl::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}"
);
}
}
}
+186
View File
@@ -0,0 +1,186 @@
//! Scripted stand-ins for `brokerd`'s admin socket and for `loopd`, for tests. Do not edit.
#![allow(dead_code)] // each test file uses a different part of this module
use proto::{
ApprovalList, CallId, DataClass, Envelope, ErrorCode, Message, PROTOCOL_VERSION,
PendingApproval, SessionId, Timestamp, TurnDone, TurnEvent, Usage, WireError, read_frame,
write_frame,
};
use std::os::unix::net::UnixListener;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
static NEXT: AtomicU32 = AtomicU32::new(0);
/// A socket path in a fresh temporary directory.
pub fn temp_socket(name: &str) -> PathBuf {
let n = NEXT.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("bxctl-m3a-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir.join(name)
}
pub fn ts(text: &str) -> Timestamp {
Timestamp::parse(text).unwrap()
}
pub struct FakeBrokerd {
pub socket: PathBuf,
/// Every request message received, in order.
pub requests: Arc<Mutex<Vec<Message>>>,
}
impl FakeBrokerd {
pub fn requests(&self) -> Vec<Message> {
self.requests.lock().unwrap().clone()
}
}
/// Serves `admin.sock`: one request per connection, answered with the frames `answer` returns,
/// written exactly as given (so a test can send a wrong id or a frame that is not final). An
/// empty list closes the connection without an answer.
pub fn fake_brokerd_frames(
answer: impl Fn(&Envelope) -> Vec<Envelope> + Send + 'static,
) -> FakeBrokerd {
let socket = temp_socket("admin.sock");
let listener = UnixListener::bind(&socket).unwrap();
let requests = Arc::new(Mutex::new(Vec::new()));
let seen = Arc::clone(&requests);
thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
let Ok(request) = read_frame(&mut stream) else {
continue;
};
seen.lock().unwrap().push(request.msg.clone());
for frame in answer(&request) {
if write_frame(&mut stream, &frame).is_err() {
break; // the client went away; the next connection is still served
}
}
}
});
FakeBrokerd { socket, requests }
}
/// The usual case: one final frame with the request's id.
pub fn fake_brokerd(answer: impl Fn(&Message) -> Message + Send + 'static) -> FakeBrokerd {
fake_brokerd_frames(move |request| {
vec![Envelope {
v: PROTOCOL_VERSION,
id: request.id,
r#final: true,
msg: answer(&request.msg),
}]
})
}
pub fn wire_error(code: ErrorCode, detail: &str) -> Message {
Message::Error(WireError {
code,
detail: detail.to_string(),
})
}
/// A pending approval created at 12:00:00 that expires at 12:15:00 on 2026-09-18.
pub fn pending(approval: u64, tool: &str, arguments: &str) -> PendingApproval {
PendingApproval {
approval,
session: SessionId::new("chat-1758196800-123456789").unwrap(),
call: CallId(7),
tool: tool.to_string(),
arguments: arguments.to_string(),
grant: "shell-scratch".to_string(),
taint: DataClass::Private,
created: ts("2026-09-18T12:00:00.000Z"),
expires: ts("2026-09-18T12:15:00.000Z"),
}
}
/// A `brokerd` with these approvals pending. `approve` is answered with `outcome`, `refuse` with
/// `ok`; both answer `no_such_approval` for an id that is not in the list.
pub fn brokerd_with(items: Vec<PendingApproval>, outcome: proto::DecisionRecord) -> FakeBrokerd {
fake_brokerd(move |msg| {
let known = |id: u64| items.iter().any(|item| item.approval == id);
match msg {
Message::Approvals(_) => Message::ApprovalList(ApprovalList {
items: items.clone(),
}),
Message::Approve(a) if known(a.approval) => {
Message::ApproveResult(proto::ApproveResult {
outcome: outcome.clone(),
})
}
Message::Refuse(r) if known(r.approval) => Message::Ok(proto::Empty {}),
Message::Approve(_) | Message::Refuse(_) => {
wire_error(ErrorCode::NoSuchApproval, "no such approval")
}
_ => wire_error(ErrorCode::BadMessage, "not an admin request"),
}
})
}
pub struct FakeLoopd {
pub socket: PathBuf,
/// The content of every turn received, in order.
pub turns: Arc<Mutex<Vec<String>>>,
}
pub fn usage() -> Usage {
Usage {
cache_n: 10,
prompt_n: 5,
predicted_n: 7,
reasoning_tokens: 3,
thinking_capped: false,
}
}
/// Serves `loop.sock`: every turn gets the same events, then `turn_done` with `answer`. It does
/// not wait for an approval to be answered: `bxctl` deals with each event before it reads the
/// next frame, so the order of what it does is fixed all the same.
pub fn fake_loopd(events: Vec<TurnEvent>, answer: &str) -> FakeLoopd {
let socket = temp_socket("loop.sock");
let listener = UnixListener::bind(&socket).unwrap();
let turns = Arc::new(Mutex::new(Vec::new()));
let seen = Arc::clone(&turns);
let answer = answer.to_string();
thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
let Ok(request) = read_frame(&mut stream) else {
continue;
};
let Message::Turn(turn) = request.msg else {
continue;
};
seen.lock().unwrap().push(turn.content);
let mut frames: Vec<(bool, Message)> = events
.iter()
.map(|e| (false, Message::TurnEvent(e.clone())))
.collect();
frames.push((
true,
Message::TurnDone(TurnDone {
content: answer.clone(),
usage: usage(),
}),
));
for (last, msg) in frames {
let frame = Envelope {
v: PROTOCOL_VERSION,
id: request.id,
r#final: last,
msg,
};
if write_frame(&mut stream, &frame).is_err() {
break;
}
}
}
});
FakeLoopd { socket, turns }
}