M3a review findings 9, 10, 12, 13. A retrying error and every error detail can carry the inference server's body, so they are escaped like model text. Admin requests wait at most 30 s, so a stuck brokerd cannot hang bxctl or a chat turn. A failed write is AdminError::Io and stops handle_pending instead of being answered with another write. The usage line says what audit verify checks. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
291 lines
8.8 KiB
Rust
291 lines
8.8 KiB
Rust
//! `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]
|
|
Check the audit log's hash chain, reading the files directly.";
|
|
|
|
// 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())
|
|
}
|