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
+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())
}