Show and answer approvals in bxctl chat
Implemented-By: Grok 4.6
This commit is contained in:
+146
-10
@@ -1,14 +1,17 @@
|
||||
//! `bxctl chat`: a turn client over `loop.sock` and a printer for the events it receives.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use proto::{
|
||||
Envelope, ErrorCode, Message, PROTOCOL_VERSION, SessionId, Turn, TurnDone, TurnEvent,
|
||||
WireError, read_frame, write_frame,
|
||||
};
|
||||
use std::io::{BufRead, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::Path;
|
||||
|
||||
use proto::{
|
||||
DenyReason, Envelope, ErrorCode, Message, PROTOCOL_VERSION, SessionId, Timestamp, Turn,
|
||||
TurnDone, TurnEvent, WireError, read_frame, write_frame,
|
||||
};
|
||||
|
||||
use crate::admin;
|
||||
use crate::escape::escape_model_text;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ChatError {
|
||||
Connect(std::io::Error),
|
||||
@@ -142,7 +145,7 @@ impl Printer {
|
||||
out.write_all(b"\x1b[2m")?;
|
||||
self.dimmed_open = true;
|
||||
}
|
||||
out.write_all(text.as_bytes())?;
|
||||
out.write_all(escape_model_text(text).as_bytes())?;
|
||||
}
|
||||
}
|
||||
TurnEvent::Content { text } => {
|
||||
@@ -151,11 +154,12 @@ impl Printer {
|
||||
self.dimmed_open = false;
|
||||
}
|
||||
if self.stream_content {
|
||||
out.write_all(text.as_bytes())?;
|
||||
out.write_all(escape_model_text(text).as_bytes())?;
|
||||
}
|
||||
}
|
||||
TurnEvent::ToolCallStarted { name } => {
|
||||
self.close_dimmed(out)?;
|
||||
let name = escape_model_text(name);
|
||||
writeln!(out, "[tool {name}]")?;
|
||||
}
|
||||
TurnEvent::ToolResult {
|
||||
@@ -164,6 +168,7 @@ impl Printer {
|
||||
truncated,
|
||||
} => {
|
||||
self.close_dimmed(out)?;
|
||||
let name = escape_model_text(name);
|
||||
if *truncated {
|
||||
writeln!(out, "[{name}: {class:?}, truncated]")?;
|
||||
} else {
|
||||
@@ -195,8 +200,32 @@ impl Printer {
|
||||
writeln!(out, "[cache loss: {got} of {expected}]")?;
|
||||
}
|
||||
TurnEvent::Queued { .. } | TurnEvent::Progress { .. } => {}
|
||||
// Printed from task 20 on; until then these events are not sent.
|
||||
TurnEvent::ApprovalPending { .. } | TurnEvent::ToolDenied { .. } => {}
|
||||
TurnEvent::ApprovalPending { .. } => {
|
||||
self.close_dimmed(out)?;
|
||||
}
|
||||
TurnEvent::ToolDenied { name, reason } => {
|
||||
self.close_dimmed(out)?;
|
||||
let shown = escape_model_text(name);
|
||||
writeln!(out, "[denied {shown}: {}]", admin::reason_name(*reason))?;
|
||||
match reason {
|
||||
DenyReason::GrantsInvalid => {
|
||||
writeln!(out, "see docs/runbook.md#grants-invalid")?;
|
||||
}
|
||||
DenyReason::AuditUnavailable => {
|
||||
writeln!(out, "see docs/runbook.md#audit-unavailable")?;
|
||||
}
|
||||
DenyReason::StateUnreadable => {
|
||||
writeln!(out, "see docs/runbook.md#broker-state-damaged")?;
|
||||
}
|
||||
DenyReason::NoGrant
|
||||
| DenyReason::GrantExpired
|
||||
| DenyReason::TaintTooHigh
|
||||
| DenyReason::DeniedByGrant
|
||||
| DenyReason::ApprovalRefused
|
||||
| DenyReason::ApprovalExpired
|
||||
| DenyReason::InvalidArguments => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.flush()
|
||||
}
|
||||
@@ -213,3 +242,110 @@ impl Printer {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OnPending {
|
||||
EventOnly,
|
||||
Show,
|
||||
Ask,
|
||||
}
|
||||
|
||||
pub struct Approvals<'a> {
|
||||
pub admin_socket: &'a Path,
|
||||
pub on_pending: OnPending,
|
||||
}
|
||||
|
||||
pub struct TurnIo<'a> {
|
||||
pub printer: &'a mut Printer,
|
||||
pub input: &'a mut dyn BufRead,
|
||||
pub out: &'a mut dyn Write,
|
||||
}
|
||||
|
||||
pub fn handle_pending(
|
||||
admin_socket: &Path,
|
||||
approval: u64,
|
||||
ask: bool,
|
||||
now: Timestamp,
|
||||
input: &mut dyn BufRead,
|
||||
out: &mut dyn Write,
|
||||
) -> std::io::Result<()> {
|
||||
let items = match admin::list(admin_socket) {
|
||||
Ok(items) => items,
|
||||
Err(e) => {
|
||||
writeln!(out, "approval {approval}: cannot ask brokerd: {e}")?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let Some(item) = items.iter().find(|item| item.approval == approval) else {
|
||||
writeln!(out, "approval {approval} is no longer pending")?;
|
||||
return Ok(());
|
||||
};
|
||||
out.write_all(b"\x1b[0m")?;
|
||||
admin::write_block(out, item, now)?;
|
||||
if !ask {
|
||||
return Ok(());
|
||||
}
|
||||
write!(out, "type {approval} to approve, anything else refuses: ")?;
|
||||
out.flush()?;
|
||||
let mut line = String::new();
|
||||
input.read_line(&mut line)?;
|
||||
if line.ends_with('\n') {
|
||||
line.pop();
|
||||
}
|
||||
if line.ends_with('\r') {
|
||||
line.pop();
|
||||
}
|
||||
let result = if line == approval.to_string() {
|
||||
admin::cmd_approve(admin_socket, approval, out)
|
||||
} else {
|
||||
admin::cmd_refuse(admin_socket, approval, None, out)
|
||||
};
|
||||
match result {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => {
|
||||
writeln!(out, "approval {approval}: {e}")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The outer error is a failed write to `io.out`; the inner one is the turn's.
|
||||
pub fn stream_turn(
|
||||
socket: &Path,
|
||||
session: &SessionId,
|
||||
text: &str,
|
||||
resume: bool,
|
||||
approvals: &Approvals<'_>,
|
||||
io: &mut TurnIo<'_>,
|
||||
) -> std::io::Result<Result<TurnDone, ChatError>> {
|
||||
let mut err = None;
|
||||
let outcome = run_turn(socket, session, text, resume, &mut |event| {
|
||||
if err.is_some() {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = io.printer.event(io.out, event) {
|
||||
err = Some(e);
|
||||
return;
|
||||
}
|
||||
if let TurnEvent::ApprovalPending { approval, .. } = event
|
||||
&& approvals.on_pending != OnPending::EventOnly
|
||||
{
|
||||
let ask = approvals.on_pending == OnPending::Ask;
|
||||
if let Err(e) = handle_pending(
|
||||
approvals.admin_socket,
|
||||
*approval,
|
||||
ask,
|
||||
Timestamp::now(),
|
||||
io.input,
|
||||
io.out,
|
||||
) {
|
||||
err = Some(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(e) = err {
|
||||
return Err(e);
|
||||
}
|
||||
io.printer.end_reasoning(io.out)?;
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
+48
-56
@@ -5,9 +5,10 @@ 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 bxctl::chat::{Approvals, ChatError, OnPending, Printer, TurnIo, new_session_id, stream_turn};
|
||||
use bxctl::cli::{self, ChatOptions, Command, USAGE};
|
||||
use proto::{ErrorCode, SessionId, Timestamp, TurnDone, TurnEvent};
|
||||
use bxctl::escape::escape_model_text;
|
||||
use proto::{ErrorCode, SessionId, Timestamp, TurnDone};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
@@ -85,58 +86,39 @@ fn cmd_grants_check(admin: &Path) -> Result<bool, AdminError> {
|
||||
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
|
||||
// Result) does not lose a write failure. The error is checked after run_turn returns.
|
||||
struct Sink<'a> {
|
||||
out: &'a mut (dyn Write + 'static),
|
||||
err: Option<std::io::Error>,
|
||||
}
|
||||
|
||||
impl Sink<'_> {
|
||||
fn event(&mut self, printer: &mut Printer, event: &TurnEvent) {
|
||||
if self.err.is_none() {
|
||||
self.err = printer.event(self.out, event).err();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Runs one turn, streaming events through `printer` to `out`. Returns the turn outcome, or the
|
||||
// first io error that occurred while writing.
|
||||
fn stream_turn(
|
||||
socket: &Path,
|
||||
session: &SessionId,
|
||||
text: &str,
|
||||
resume: bool,
|
||||
sink: &mut Sink,
|
||||
printer: &mut Printer,
|
||||
) -> Result<Result<TurnDone, ChatError>, std::io::Error> {
|
||||
let outcome = run_turn(socket, session, text, resume, &mut |event| {
|
||||
sink.event(printer, event)
|
||||
});
|
||||
// Flush any dimmed block left open before reporting the turn's result.
|
||||
if sink.err.is_none() {
|
||||
printer.end_reasoning(sink.out)?;
|
||||
}
|
||||
match sink.err.take() {
|
||||
Some(e) => Err(e),
|
||||
None => Ok(outcome),
|
||||
fn on_pending(opts: &ChatOptions) -> OnPending {
|
||||
if opts.json {
|
||||
OnPending::EventOnly
|
||||
} else if opts.say.is_some() {
|
||||
OnPending::Show
|
||||
} else {
|
||||
OnPending::Ask
|
||||
}
|
||||
}
|
||||
|
||||
fn run(opts: &ChatOptions) -> ExitCode {
|
||||
let stdin = std::io::stdin();
|
||||
let mut input = BufReader::new(stdin.lock());
|
||||
match &opts.say {
|
||||
Some(text) => run_say(opts, text),
|
||||
None => run_interactive(opts),
|
||||
Some(text) => run_say(opts, text, &mut input),
|
||||
None => run_interactive(opts, &mut input),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_say(opts: &ChatOptions, text: &str) -> ExitCode {
|
||||
fn run_say(opts: &ChatOptions, text: &str, input: &mut dyn BufRead) -> ExitCode {
|
||||
let session = match &opts.session {
|
||||
Some(s) => s.clone(),
|
||||
None => new_session_id(),
|
||||
};
|
||||
let mut printer = Printer::new(opts.show_thinking, opts.json);
|
||||
let code = run_turn_twice(opts, &session, text, opts.session.is_some(), &mut printer);
|
||||
let code = run_turn_twice(
|
||||
opts,
|
||||
&session,
|
||||
text,
|
||||
opts.session.is_some(),
|
||||
&mut printer,
|
||||
input,
|
||||
);
|
||||
match code {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(RunError::Io(e)) => {
|
||||
@@ -157,14 +139,20 @@ fn run_turn_twice(
|
||||
text: &str,
|
||||
resume: bool,
|
||||
printer: &mut Printer,
|
||||
input: &mut dyn BufRead,
|
||||
) -> Result<(), RunError> {
|
||||
let stderr = std::io::stderr();
|
||||
let mut handle = stderr.lock();
|
||||
let mut sink = Sink {
|
||||
out: &mut handle,
|
||||
err: None,
|
||||
let approvals = Approvals {
|
||||
admin_socket: &opts.admin_socket,
|
||||
on_pending: on_pending(opts),
|
||||
};
|
||||
match stream_turn(&opts.socket, session, text, resume, &mut sink, printer) {
|
||||
let mut io = TurnIo {
|
||||
printer,
|
||||
input,
|
||||
out: &mut handle,
|
||||
};
|
||||
match stream_turn(&opts.socket, session, text, resume, &approvals, &mut io) {
|
||||
Err(e) => Err(RunError::Io(e)),
|
||||
Ok(Ok(done)) => {
|
||||
write_answer(opts, &done).map_err(RunError::Io)?;
|
||||
@@ -172,11 +160,12 @@ fn run_turn_twice(
|
||||
}
|
||||
Ok(Err(ChatError::Refused(w))) if w.code == ErrorCode::NoSuchSession => {
|
||||
// The named session does not exist: retry once, creating it (resume=false).
|
||||
let mut sink = Sink {
|
||||
let mut io = TurnIo {
|
||||
printer,
|
||||
input,
|
||||
out: &mut handle,
|
||||
err: None,
|
||||
};
|
||||
match stream_turn(&opts.socket, session, text, false, &mut sink, printer) {
|
||||
match stream_turn(&opts.socket, session, text, false, &approvals, &mut io) {
|
||||
Err(e) => Err(RunError::Io(e)),
|
||||
Ok(Ok(done)) => {
|
||||
write_answer(opts, &done).map_err(RunError::Io)?;
|
||||
@@ -189,11 +178,9 @@ fn run_turn_twice(
|
||||
}
|
||||
}
|
||||
|
||||
fn run_interactive(opts: &ChatOptions) -> ExitCode {
|
||||
fn run_interactive(opts: &ChatOptions, reader: &mut dyn BufRead) -> ExitCode {
|
||||
let mut printer = Printer::new(opts.show_thinking, opts.json);
|
||||
let mut session = None;
|
||||
let stdin = std::io::stdin();
|
||||
let mut reader = BufReader::new(stdin.lock());
|
||||
loop {
|
||||
eprint!("> ");
|
||||
let _ = std::io::stderr().flush();
|
||||
@@ -222,17 +209,22 @@ fn run_interactive(opts: &ChatOptions) -> ExitCode {
|
||||
}
|
||||
let stderr = std::io::stderr();
|
||||
let mut handle = stderr.lock();
|
||||
let mut sink = Sink {
|
||||
let approvals = Approvals {
|
||||
admin_socket: &opts.admin_socket,
|
||||
on_pending: on_pending(opts),
|
||||
};
|
||||
let mut io = TurnIo {
|
||||
printer: &mut printer,
|
||||
input: reader,
|
||||
out: &mut handle,
|
||||
err: None,
|
||||
};
|
||||
match stream_turn(
|
||||
&opts.socket,
|
||||
this_session,
|
||||
text,
|
||||
resume,
|
||||
&mut sink,
|
||||
&mut printer,
|
||||
&approvals,
|
||||
&mut io,
|
||||
) {
|
||||
Err(e) => {
|
||||
eprintln!("bxctl: {e}");
|
||||
@@ -264,7 +256,7 @@ fn write_answer(opts: &ChatOptions, done: &TurnDone) -> std::io::Result<()> {
|
||||
}
|
||||
let stdout = std::io::stdout();
|
||||
let mut out = stdout.lock();
|
||||
out.write_all(done.content.as_bytes())?;
|
||||
out.write_all(escape_model_text(&done.content).as_bytes())?;
|
||||
out.write_all(b"\n")?;
|
||||
out.flush()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
//! Tests for approvals in `bxctl chat`, against a fake `loopd` and a fake `brokerd`. Do not edit.
|
||||
//!
|
||||
//! What the owner is shown comes from `brokerd`, never from `loopd`'s event, and only the
|
||||
//! approval's id, typed in full, approves.
|
||||
|
||||
mod support;
|
||||
|
||||
use bxctl::chat::{Approvals, OnPending, Printer, TurnIo, handle_pending, stream_turn};
|
||||
use proto::{
|
||||
Approve, DecisionRecord, Empty, ErrorCode, Message, Refuse, SessionId, Timestamp, TurnEvent,
|
||||
};
|
||||
use std::io::{BufRead, Cursor, Write};
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
use support::{FakeBrokerd, brokerd_with, fake_brokerd, fake_loopd, pending, ts, wire_error};
|
||||
|
||||
const BACKSLASH: char = '\\';
|
||||
const NOW: &str = "2026-09-18T12:02:00.000Z";
|
||||
const PROMPT: &str = "type 41 to approve, anything else refuses: ";
|
||||
|
||||
/// 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 approvals_request() -> Message {
|
||||
Message::Approvals(Empty {})
|
||||
}
|
||||
|
||||
fn approve_request() -> Message {
|
||||
Message::Approve(Approve { approval: 41 })
|
||||
}
|
||||
|
||||
fn refuse_request() -> Message {
|
||||
Message::Refuse(Refuse {
|
||||
approval: 41,
|
||||
reason: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A `brokerd` with approval 41 pending, for `shell`.
|
||||
fn broker() -> FakeBrokerd {
|
||||
brokerd_with(
|
||||
vec![pending(41, "shell", r#"{"command":"ls"}"#)],
|
||||
DecisionRecord::Allowed {},
|
||||
)
|
||||
}
|
||||
|
||||
/// Runs `handle_pending` for approval 41 with `typed` waiting on stdin. Returns what was
|
||||
/// printed and what was left unread.
|
||||
fn handle(socket: &Path, ask: bool, typed: &str) -> (String, String) {
|
||||
let mut input = Cursor::new(typed.as_bytes().to_vec());
|
||||
let mut out = Vec::new();
|
||||
handle_pending(socket, 41, ask, ts(NOW), &mut input, &mut out).unwrap();
|
||||
let mut left = String::new();
|
||||
std::io::Read::read_to_string(&mut input, &mut left).unwrap();
|
||||
(String::from_utf8(out).unwrap(), left)
|
||||
}
|
||||
|
||||
// ---- handle_pending ----
|
||||
|
||||
#[test]
|
||||
fn the_block_then_the_question_and_the_id_approves() {
|
||||
let fake = broker();
|
||||
let (out, left) = handle(&fake.socket, true, "41\n");
|
||||
assert_eq!(
|
||||
out,
|
||||
format!(
|
||||
"\x1b[0m41 2 min ago expires in 13 min session chat-1758… grant shell-scratch \
|
||||
taint private\n shell {{\"command\":\"ls\"}}\n{PROMPT}approved 41: runs\n"
|
||||
),
|
||||
"attributes reset, the block, the question, the outcome"
|
||||
);
|
||||
assert_eq!(left, "");
|
||||
assert_eq!(
|
||||
fake.requests(),
|
||||
vec![approvals_request(), approve_request()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_id_with_a_carriage_return_also_approves() {
|
||||
let fake = broker();
|
||||
handle(&fake.socket, true, "41\r\n");
|
||||
assert_eq!(
|
||||
fake.requests(),
|
||||
vec![approvals_request(), approve_request()]
|
||||
);
|
||||
}
|
||||
|
||||
/// Everything that is not exactly the id refuses: `y`, a near miss, an empty line, the end of
|
||||
/// the input.
|
||||
#[test]
|
||||
fn anything_else_refuses() {
|
||||
for typed in [
|
||||
"y\n",
|
||||
"Y\n",
|
||||
"yes\n",
|
||||
"\n",
|
||||
"",
|
||||
" 41\n",
|
||||
"41 \n",
|
||||
"041\n",
|
||||
"+41\n",
|
||||
"4 1\n",
|
||||
"42\n",
|
||||
"approve 41\n",
|
||||
"41",
|
||||
] {
|
||||
let fake = broker();
|
||||
let (out, _) = handle(&fake.socket, true, typed);
|
||||
if typed == "41" {
|
||||
// The last line of the input need not end in a newline; it is still the id.
|
||||
assert_eq!(
|
||||
fake.requests(),
|
||||
vec![approvals_request(), approve_request()]
|
||||
);
|
||||
continue;
|
||||
}
|
||||
assert_eq!(
|
||||
fake.requests(),
|
||||
vec![approvals_request(), refuse_request()],
|
||||
"{typed:?}"
|
||||
);
|
||||
assert!(
|
||||
out.ends_with(&format!("{PROMPT}refused 41\n")),
|
||||
"{typed:?}: {out:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A line typed while the turn ran is already waiting when the question is asked. It is the
|
||||
/// answer, it is not the id, so it refuses; and only that one line is read.
|
||||
#[test]
|
||||
fn a_line_already_waiting_refuses_and_only_one_line_is_read() {
|
||||
let fake = broker();
|
||||
let (_, left) = handle(&fake.socket, true, "are you still there?\n41\n");
|
||||
assert_eq!(fake.requests(), vec![approvals_request(), refuse_request()]);
|
||||
assert_eq!(left, "41\n", "the next line is left for the chat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_id_that_is_not_listed_is_no_longer_pending_and_nothing_is_asked() {
|
||||
let fake = brokerd_with(
|
||||
vec![pending(40, "shell", "{}"), pending(42, "shell", "{}")],
|
||||
DecisionRecord::Allowed {},
|
||||
);
|
||||
let (out, left) = handle(&fake.socket, true, "41\n");
|
||||
assert_eq!(out, "approval 41 is no longer pending\n");
|
||||
assert_eq!(left, "41\n", "nothing was read");
|
||||
assert_eq!(fake.requests(), vec![approvals_request()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_ask_the_block_is_shown_and_nothing_is_asked_or_read() {
|
||||
let fake = broker();
|
||||
let (out, left) = handle(&fake.socket, false, "41\n");
|
||||
assert!(out.starts_with("\x1b[0m41 2 min ago "), "{out:?}");
|
||||
assert!(out.ends_with(" shell {\"command\":\"ls\"}\n"), "{out:?}");
|
||||
assert!(!out.contains("to approve"), "{out:?}");
|
||||
assert_eq!(left, "41\n");
|
||||
assert_eq!(fake.requests(), vec![approvals_request()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_brokerd_that_cannot_be_reached_is_reported_and_nothing_is_asked() {
|
||||
let missing = support::temp_socket("nobody-listens.sock");
|
||||
let (out, left) = handle(&missing, true, "41\n");
|
||||
assert!(
|
||||
out.starts_with("approval 41: cannot ask brokerd: cannot reach brokerd at "),
|
||||
"{out:?}"
|
||||
);
|
||||
assert!(out.ends_with('\n') && out.lines().count() == 1, "{out:?}");
|
||||
assert_eq!(left, "41\n");
|
||||
}
|
||||
|
||||
/// The approval can expire between the list and the answer.
|
||||
#[test]
|
||||
fn an_answer_that_comes_too_late_is_reported() {
|
||||
let fake = fake_brokerd(|msg| match msg {
|
||||
Message::Approvals(_) => Message::ApprovalList(proto::ApprovalList {
|
||||
items: vec![pending(41, "shell", "{}")],
|
||||
}),
|
||||
_ => wire_error(ErrorCode::NoSuchApproval, "no such approval"),
|
||||
});
|
||||
let (out, _) = handle(&fake.socket, true, "41\n");
|
||||
assert!(
|
||||
out.ends_with(&format!(
|
||||
"{PROMPT}41: no such approval (already answered or expired)\n"
|
||||
)),
|
||||
"{out:?}"
|
||||
);
|
||||
let (out, _) = handle(&fake.socket, true, "no\n");
|
||||
assert!(
|
||||
out.ends_with(&format!(
|
||||
"{PROMPT}41: no such approval (already answered or expired)\n"
|
||||
)),
|
||||
"{out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Any other failure of the answer is reported on one line, and the turn goes on: the call is
|
||||
/// still pending at `brokerd`, and `bxctl approve` from another terminal can answer it.
|
||||
#[test]
|
||||
fn an_answer_that_fails_is_reported_and_is_not_an_error() {
|
||||
let fake = fake_brokerd(|msg| match msg {
|
||||
Message::Approvals(_) => Message::ApprovalList(proto::ApprovalList {
|
||||
items: vec![pending(41, "shell", "{}")],
|
||||
}),
|
||||
_ => wire_error(ErrorCode::Internal, "boom"),
|
||||
});
|
||||
let (out, _) = handle(&fake.socket, true, "41\n");
|
||||
assert!(
|
||||
out.ends_with(&format!("{PROMPT}approval 41: internal: boom\n")),
|
||||
"{out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// What `brokerd` sends is printed as data here too.
|
||||
#[test]
|
||||
fn the_block_in_chat_is_escaped() {
|
||||
let rlo = char::from_u32(0x202e).unwrap();
|
||||
let arguments = format!("{{\"path\":\"/home/kyle/{rlo}dm\x1b[8m\"}}");
|
||||
let fake = brokerd_with(
|
||||
vec![pending(41, "read_file", &arguments)],
|
||||
DecisionRecord::Allowed {},
|
||||
);
|
||||
let (out, _) = handle(&fake.socket, false, "");
|
||||
assert_eq!(out.matches('\x1b').count(), 1, "only the reset: {out:?}");
|
||||
assert!(!out.contains(rlo), "{out:?}");
|
||||
assert!(
|
||||
out.contains(&format!("/home/kyle/{}dm{}[8m", esc(0x202e), esc(0x1b))),
|
||||
"{out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
struct Broken;
|
||||
|
||||
impl Write for Broken {
|
||||
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
|
||||
Err(std::io::Error::other("the terminal went away"))
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Err(std::io::Error::other("the terminal went away"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Every write can fail, and none is ignored: not the "no longer pending" line, not the block,
|
||||
/// not the question. Nothing is approved for an owner who was shown nothing.
|
||||
#[test]
|
||||
fn a_failed_write_is_an_error_and_nothing_is_answered() {
|
||||
let fake = broker();
|
||||
let mut input = Cursor::new(b"41\n".to_vec());
|
||||
assert!(handle_pending(&fake.socket, 41, true, ts(NOW), &mut input, &mut Broken).is_err());
|
||||
assert_eq!(fake.requests(), vec![approvals_request()]);
|
||||
|
||||
let none = brokerd_with(Vec::new(), DecisionRecord::Allowed {});
|
||||
let mut input = Cursor::new(Vec::new());
|
||||
assert!(handle_pending(&none.socket, 41, true, ts(NOW), &mut input, &mut Broken).is_err());
|
||||
}
|
||||
|
||||
// ---- stream_turn ----
|
||||
|
||||
fn pending_event(tool: &str) -> TurnEvent {
|
||||
TurnEvent::ApprovalPending {
|
||||
approval: 41,
|
||||
tool: tool.to_string(),
|
||||
expires: Timestamp::parse("2026-09-18T12:15:00.000Z").unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs one turn against a fake `loopd` that sends `events`. Returns what was printed.
|
||||
fn turn(
|
||||
events: Vec<TurnEvent>,
|
||||
admin_socket: &Path,
|
||||
on_pending: OnPending,
|
||||
json: bool,
|
||||
typed: &str,
|
||||
) -> String {
|
||||
let loopd = fake_loopd(events, "done");
|
||||
let mut printer = Printer::new(true, json);
|
||||
let mut input: Box<dyn BufRead> = Box::new(Cursor::new(typed.as_bytes().to_vec()));
|
||||
let mut out = Vec::new();
|
||||
let approvals = Approvals {
|
||||
admin_socket,
|
||||
on_pending,
|
||||
};
|
||||
let mut io = TurnIo {
|
||||
printer: &mut printer,
|
||||
input: &mut *input,
|
||||
out: &mut out,
|
||||
};
|
||||
let done = stream_turn(
|
||||
&loopd.socket,
|
||||
&SessionId::new("s1").unwrap(),
|
||||
"go",
|
||||
false,
|
||||
&approvals,
|
||||
&mut io,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(done.content, "done");
|
||||
String::from_utf8(out).unwrap()
|
||||
}
|
||||
|
||||
/// A compromised `loopd` must not choose what the owner sees: the event says `read_file`, the
|
||||
/// broker's entry says `shell`, and the block says `shell`.
|
||||
#[test]
|
||||
fn the_block_comes_from_brokerd_not_from_the_event() {
|
||||
let fake = broker();
|
||||
let out = turn(
|
||||
vec![pending_event("read_file")],
|
||||
&fake.socket,
|
||||
OnPending::Ask,
|
||||
false,
|
||||
"41\n",
|
||||
);
|
||||
assert!(out.contains(" shell {\"command\":\"ls\"}\n"), "{out:?}");
|
||||
assert!(!out.contains("read_file"), "{out:?}");
|
||||
assert_eq!(
|
||||
fake.requests(),
|
||||
vec![approvals_request(), approve_request()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_turn_goes_on_after_the_answer() {
|
||||
let fake = broker();
|
||||
let out = turn(
|
||||
vec![
|
||||
TurnEvent::Reasoning {
|
||||
text: "hm".to_string(),
|
||||
},
|
||||
pending_event("shell"),
|
||||
TurnEvent::Content {
|
||||
text: "It ran.".to_string(),
|
||||
},
|
||||
],
|
||||
&fake.socket,
|
||||
OnPending::Ask,
|
||||
false,
|
||||
"41\n",
|
||||
);
|
||||
assert!(
|
||||
out.starts_with("\x1b[2mhm\x1b[0m\n\x1b[0m41 "),
|
||||
"reasoning is ended before the block: {out:?}"
|
||||
);
|
||||
assert!(out.ends_with("approved 41: runs\nIt ran."), "{out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_prints_the_block_and_answers_nothing() {
|
||||
let fake = broker();
|
||||
let out = turn(
|
||||
vec![pending_event("shell")],
|
||||
&fake.socket,
|
||||
OnPending::Show,
|
||||
false,
|
||||
"41\n",
|
||||
);
|
||||
assert!(out.contains(" shell {\"command\":\"ls\"}\n"), "{out:?}");
|
||||
assert!(!out.contains("to approve"), "{out:?}");
|
||||
assert_eq!(fake.requests(), vec![approvals_request()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_only_prints_the_json_line_and_never_asks_brokerd() {
|
||||
let fake = broker();
|
||||
let out = turn(
|
||||
vec![pending_event("shell")],
|
||||
&fake.socket,
|
||||
OnPending::EventOnly,
|
||||
true,
|
||||
"41\n",
|
||||
);
|
||||
let lines: Vec<&str> = out.lines().collect();
|
||||
assert_eq!(lines.len(), 1, "{out:?}");
|
||||
let event: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
|
||||
assert_eq!(event["event"], "approval_pending");
|
||||
assert_eq!(event["approval"], 41);
|
||||
assert_eq!(fake.requests(), Vec::<Message>::new());
|
||||
}
|
||||
|
||||
// ---- the binary ----
|
||||
|
||||
fn chat(loopd: &Path, brokerd: &Path, extra: &[&str], typed: &str) -> std::process::Output {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_bxctl"))
|
||||
.args(["chat", "--socket"])
|
||||
.arg(loopd)
|
||||
.arg("--admin-socket")
|
||||
.arg(brokerd)
|
||||
.args(extra)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
{
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
stdin.write_all(typed.as_bytes()).unwrap();
|
||||
}
|
||||
child.wait_with_output().unwrap()
|
||||
}
|
||||
|
||||
/// With a pipe, everything typed is in the reader's buffer before the first turn is sent. The
|
||||
/// approval's answer must come from that same reader: a second reader on stdin would see the
|
||||
/// end of the input, and refuse.
|
||||
#[test]
|
||||
fn interactive_mode_reads_the_answer_from_the_same_input_as_the_chat() {
|
||||
let loopd = fake_loopd(vec![pending_event("shell")], "ok");
|
||||
let fake = broker();
|
||||
let output = chat(&loopd.socket, &fake.socket, &[], "go\n41\n/quit\n");
|
||||
assert!(output.status.success());
|
||||
assert_eq!(
|
||||
fake.requests(),
|
||||
vec![approvals_request(), approve_request()]
|
||||
);
|
||||
assert_eq!(
|
||||
*loopd.turns.lock().unwrap(),
|
||||
vec!["go".to_string()],
|
||||
"the answer was not sent to the model as a message"
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains(PROMPT), "{stderr}");
|
||||
assert!(stderr.contains("approved 41: runs"), "{stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_mode_refuses_on_a_stray_line() {
|
||||
let loopd = fake_loopd(vec![pending_event("shell")], "ok");
|
||||
let fake = broker();
|
||||
let output = chat(&loopd.socket, &fake.socket, &[], "go\ny\n/quit\n");
|
||||
assert!(output.status.success());
|
||||
assert_eq!(fake.requests(), vec![approvals_request(), refuse_request()]);
|
||||
assert_eq!(*loopd.turns.lock().unwrap(), vec!["go".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn say_shows_the_block_and_answers_nothing() {
|
||||
let loopd = fake_loopd(vec![pending_event("read_file")], "ok");
|
||||
let fake = broker();
|
||||
let output = chat(&loopd.socket, &fake.socket, &["--say", "go"], "41\n");
|
||||
assert!(output.status.success());
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains(" shell {\"command\":\"ls\"}\n"),
|
||||
"{stderr}"
|
||||
);
|
||||
assert!(!stderr.contains("to approve"), "{stderr}");
|
||||
assert_eq!(fake.requests(), vec![approvals_request()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_prints_only_json_and_never_asks_brokerd() {
|
||||
let loopd = fake_loopd(vec![pending_event("shell")], "ok");
|
||||
let fake = broker();
|
||||
let output = chat(
|
||||
&loopd.socket,
|
||||
&fake.socket,
|
||||
&["--json", "--say", "go"],
|
||||
"41\n",
|
||||
);
|
||||
assert!(output.status.success());
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
for line in stderr.lines() {
|
||||
assert!(
|
||||
serde_json::from_str::<serde_json::Value>(line).is_ok(),
|
||||
"not JSON: {line}"
|
||||
);
|
||||
}
|
||||
assert_eq!(stderr.lines().count(), 2, "the event and the done frame");
|
||||
assert_eq!(fake.requests(), Vec::<Message>::new());
|
||||
}
|
||||
|
||||
/// The answer on stdout is the model's text too.
|
||||
#[test]
|
||||
fn the_answer_on_stdout_is_printed_as_data() {
|
||||
let loopd = fake_loopd(Vec::new(), "a\x1b[8mb\n\tc");
|
||||
let fake = broker();
|
||||
let output = chat(&loopd.socket, &fake.socket, &["--say", "go"], "");
|
||||
assert!(output.status.success());
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
format!("a{}[8mb\n\tc\n", esc(0x1b))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//! Tests for how `bxctl chat` prints what the model wrote and what `brokerd` denied. Do not edit.
|
||||
|
||||
use bxctl::chat::Printer;
|
||||
use proto::{DataClass, DenyReason, Timestamp, TurnEvent};
|
||||
|
||||
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 print(printer: &mut Printer, events: &[TurnEvent]) -> String {
|
||||
let mut out = Vec::new();
|
||||
for e in events {
|
||||
printer.event(&mut out, e).unwrap();
|
||||
}
|
||||
printer.end_reasoning(&mut out).unwrap();
|
||||
String::from_utf8(out).unwrap()
|
||||
}
|
||||
|
||||
fn denied(name: &str, reason: DenyReason) -> TurnEvent {
|
||||
TurnEvent::ToolDenied {
|
||||
name: name.to_string(),
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_denial_shows_its_reason_by_its_wire_name() {
|
||||
let mut p = Printer::new(true, false);
|
||||
assert_eq!(
|
||||
print(&mut p, &[denied("read_file", DenyReason::NoGrant)]),
|
||||
"[denied read_file: no_grant]\n"
|
||||
);
|
||||
}
|
||||
|
||||
/// Walks all ten reasons: the three that mean the harness is refusing to work carry their
|
||||
/// runbook entry on the next line, and the other seven carry nothing.
|
||||
#[test]
|
||||
fn the_three_fail_closed_denials_point_to_the_runbook_and_no_other_does() {
|
||||
let cases = [
|
||||
(DenyReason::NoGrant, "no_grant", None),
|
||||
(DenyReason::GrantExpired, "grant_expired", None),
|
||||
(DenyReason::TaintTooHigh, "taint_too_high", None),
|
||||
(DenyReason::DeniedByGrant, "denied_by_grant", None),
|
||||
(DenyReason::ApprovalRefused, "approval_refused", None),
|
||||
(DenyReason::ApprovalExpired, "approval_expired", None),
|
||||
(DenyReason::InvalidArguments, "invalid_arguments", None),
|
||||
(
|
||||
DenyReason::GrantsInvalid,
|
||||
"grants_invalid",
|
||||
Some("see docs/runbook.md#grants-invalid"),
|
||||
),
|
||||
(
|
||||
DenyReason::AuditUnavailable,
|
||||
"audit_unavailable",
|
||||
Some("see docs/runbook.md#audit-unavailable"),
|
||||
),
|
||||
(
|
||||
DenyReason::StateUnreadable,
|
||||
"state_unreadable",
|
||||
Some("see docs/runbook.md#broker-state-damaged"),
|
||||
),
|
||||
];
|
||||
for (reason, name, pointer) in cases {
|
||||
let mut p = Printer::new(true, false);
|
||||
let got = print(&mut p, &[denied("shell", reason)]);
|
||||
let want = match pointer {
|
||||
Some(pointer) => format!("[denied shell: {name}]\n{pointer}\n"),
|
||||
None => format!("[denied shell: {name}]\n"),
|
||||
};
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_denial_ends_an_open_reasoning_block_first() {
|
||||
let mut p = Printer::new(true, false);
|
||||
let got = print(
|
||||
&mut p,
|
||||
&[
|
||||
TurnEvent::Reasoning {
|
||||
text: "hm".to_string(),
|
||||
},
|
||||
denied("shell", DenyReason::NoGrant),
|
||||
],
|
||||
);
|
||||
assert_eq!(got, "\x1b[2mhm\x1b[0m\n[denied shell: no_grant]\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_and_content_are_printed_as_data() {
|
||||
let hostile = "a\x1b[8mb\x07c\rd";
|
||||
let mut p = Printer::new(true, false);
|
||||
let got = print(
|
||||
&mut p,
|
||||
&[
|
||||
TurnEvent::Reasoning {
|
||||
text: hostile.to_string(),
|
||||
},
|
||||
TurnEvent::Content {
|
||||
text: hostile.to_string(),
|
||||
},
|
||||
],
|
||||
);
|
||||
let shown = format!("a{}[8mb{}c{}d", esc(0x1b), esc(0x07), esc(0x0d));
|
||||
// The only escape sequences left are the printer's own: dim on, dim off.
|
||||
assert_eq!(got, format!("\x1b[2m{shown}\x1b[0m\n{shown}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invisible_and_direction_changing_characters_are_shown() {
|
||||
let rlo = char::from_u32(0x202e).unwrap();
|
||||
let mut p = Printer::new(true, false);
|
||||
let got = print(
|
||||
&mut p,
|
||||
&[TurnEvent::Content {
|
||||
text: format!("see {rlo}txt.exe"),
|
||||
}],
|
||||
);
|
||||
assert_eq!(got, format!("see {}txt.exe", esc(0x202e)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newlines_and_tabs_in_model_text_pass_through() {
|
||||
let mut p = Printer::new(true, false);
|
||||
let got = print(
|
||||
&mut p,
|
||||
&[TurnEvent::Content {
|
||||
text: "one\n\ttwo\n".to_string(),
|
||||
}],
|
||||
);
|
||||
assert_eq!(got, "one\n\ttwo\n");
|
||||
}
|
||||
|
||||
/// The model chooses tool names too: every place a name is printed escapes it.
|
||||
#[test]
|
||||
fn tool_names_are_printed_as_data_everywhere() {
|
||||
let name = "sh\x1b[2Jell";
|
||||
let shown = format!("sh{}[2Jell", esc(0x1b));
|
||||
let mut p = Printer::new(true, false);
|
||||
let got = print(
|
||||
&mut p,
|
||||
&[
|
||||
TurnEvent::ToolCallStarted {
|
||||
name: name.to_string(),
|
||||
},
|
||||
TurnEvent::ToolResult {
|
||||
name: name.to_string(),
|
||||
class: DataClass::Public,
|
||||
truncated: false,
|
||||
},
|
||||
TurnEvent::ToolResult {
|
||||
name: name.to_string(),
|
||||
class: DataClass::Private,
|
||||
truncated: true,
|
||||
},
|
||||
denied(name, DenyReason::NoGrant),
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
got,
|
||||
format!(
|
||||
"[tool {shown}]\n[{shown}: Public]\n[{shown}: Private, truncated]\n\
|
||||
[denied {shown}: no_grant]\n"
|
||||
)
|
||||
);
|
||||
assert!(!got.contains('\x1b'));
|
||||
}
|
||||
|
||||
/// The printer shows nothing for a pending approval: the block comes from `brokerd`, through
|
||||
/// `handle_pending`. It still ends an open reasoning block, so the block is not dimmed.
|
||||
#[test]
|
||||
fn a_pending_approval_prints_nothing_itself_but_ends_the_reasoning_block() {
|
||||
let pending = TurnEvent::ApprovalPending {
|
||||
approval: 41,
|
||||
tool: "read_file".to_string(),
|
||||
expires: Timestamp::parse("2026-09-18T12:15:00.000Z").unwrap(),
|
||||
};
|
||||
let mut p = Printer::new(true, false);
|
||||
assert_eq!(print(&mut p, std::slice::from_ref(&pending)), "");
|
||||
let mut p = Printer::new(true, false);
|
||||
let got = print(
|
||||
&mut p,
|
||||
&[
|
||||
TurnEvent::Reasoning {
|
||||
text: "hm".to_string(),
|
||||
},
|
||||
pending,
|
||||
],
|
||||
);
|
||||
assert_eq!(got, "\x1b[2mhm\x1b[0m\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_mode_prints_the_new_events_as_json_lines() {
|
||||
let mut p = Printer::new(true, true);
|
||||
let got = print(
|
||||
&mut p,
|
||||
&[
|
||||
TurnEvent::ApprovalPending {
|
||||
approval: 41,
|
||||
tool: "read_file".to_string(),
|
||||
expires: Timestamp::parse("2026-09-18T12:15:00.000Z").unwrap(),
|
||||
},
|
||||
denied("shell", DenyReason::GrantsInvalid),
|
||||
],
|
||||
);
|
||||
let lines: Vec<serde_json::Value> = got
|
||||
.lines()
|
||||
.map(|l| serde_json::from_str(l).unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec![
|
||||
serde_json::json!({"event": "approval_pending", "approval": 41,
|
||||
"tool": "read_file", "expires": "2026-09-18T12:15:00.000Z"}),
|
||||
serde_json::json!({"event": "tool_denied", "name": "shell",
|
||||
"reason": "grants_invalid"}),
|
||||
]
|
||||
);
|
||||
assert!(!got.contains("runbook"), "json mode adds no prose");
|
||||
}
|
||||
Reference in New Issue
Block a user