//! 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 { 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, 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 = 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::::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::(line).is_ok(), "not JSON: {line}" ); } assert_eq!(stderr.lines().count(), 2, "the event and the done frame"); assert_eq!(fake.requests(), Vec::::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)) ); }