Hand over the M3a plan: 22 tasks, their files, and the check record
Task files, the files they copy in (byte-identical to the reference on m3a-ref), each area's check record, and a README with the per-task table of what each check exposed. The handoff note is done with. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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))
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user