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).
|
||||
Reference in New Issue
Block a user