Add bxctl approvals, approve, refuse and grants check
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
//! Placeholder for the audit-log verifier (task 19).
|
||||
@@ -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(¬_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"
|
||||
);
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user