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,497 @@
|
||||
//! Tests for `bxctl`'s admin client and the commands built on it, against a fake `brokerd`.
|
||||
//! Do not edit.
|
||||
|
||||
mod support;
|
||||
|
||||
use bxctl::admin::{
|
||||
AdminError, cmd_approvals, cmd_approve, cmd_grants_check, cmd_refuse, list, reason_name,
|
||||
request, write_block,
|
||||
};
|
||||
use proto::{
|
||||
Approve, DecisionRecord, DenyReason, Empty, Envelope, ErrorCode, GrantProblem, GrantsReport,
|
||||
Message, PROTOCOL_VERSION, Refuse,
|
||||
};
|
||||
use std::process::Command;
|
||||
use support::{brokerd_with, fake_brokerd, fake_brokerd_frames, pending, ts, wire_error};
|
||||
|
||||
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 text(out: Vec<u8>) -> String {
|
||||
String::from_utf8(out).unwrap()
|
||||
}
|
||||
|
||||
const ALLOWED: DecisionRecord = DecisionRecord::Allowed {};
|
||||
|
||||
// ---- the client ----
|
||||
|
||||
#[test]
|
||||
fn request_sends_one_final_frame_with_id_1_and_returns_the_answer() {
|
||||
let fake = fake_brokerd_frames(|request| {
|
||||
assert_eq!(request.v, PROTOCOL_VERSION);
|
||||
assert_eq!(request.id, 1);
|
||||
assert!(request.r#final);
|
||||
vec![Envelope {
|
||||
v: PROTOCOL_VERSION,
|
||||
id: 1,
|
||||
r#final: true,
|
||||
msg: Message::Ok(Empty {}),
|
||||
}]
|
||||
});
|
||||
let answer = request(&fake.socket, Message::CheckGrants(Empty {})).unwrap();
|
||||
assert_eq!(answer, Message::Ok(Empty {}));
|
||||
assert_eq!(fake.requests(), vec![Message::CheckGrants(Empty {})]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_error_frame_is_refused_with_its_code_and_detail() {
|
||||
let fake = fake_brokerd(|_| wire_error(ErrorCode::Forbidden, "not on this socket"));
|
||||
match request(&fake.socket, Message::Approvals(Empty {})) {
|
||||
Err(AdminError::Refused(w)) => {
|
||||
assert_eq!(w.code, ErrorCode::Forbidden);
|
||||
assert_eq!(w.detail, "not on this socket");
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
let e = request(&fake.socket, Message::Approvals(Empty {})).unwrap_err();
|
||||
assert_eq!(e.to_string(), "forbidden: not on this socket");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_answer_that_is_not_final_or_has_another_id_is_a_protocol_error() {
|
||||
let not_final = fake_brokerd_frames(|request| {
|
||||
vec![Envelope {
|
||||
v: PROTOCOL_VERSION,
|
||||
id: request.id,
|
||||
r#final: false,
|
||||
msg: Message::Ok(Empty {}),
|
||||
}]
|
||||
});
|
||||
assert!(matches!(
|
||||
request(¬_final.socket, Message::Approvals(Empty {})),
|
||||
Err(AdminError::Protocol(_))
|
||||
));
|
||||
let other_id = fake_brokerd_frames(|request| {
|
||||
vec![Envelope {
|
||||
v: PROTOCOL_VERSION,
|
||||
id: request.id + 1,
|
||||
r#final: true,
|
||||
msg: Message::Ok(Empty {}),
|
||||
}]
|
||||
});
|
||||
assert!(matches!(
|
||||
request(&other_id.socket, Message::Approvals(Empty {})),
|
||||
Err(AdminError::Protocol(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_connection_closed_without_an_answer_is_a_frame_error() {
|
||||
let fake = fake_brokerd_frames(|_| Vec::new());
|
||||
assert!(matches!(
|
||||
request(&fake.socket, Message::Approvals(Empty {})),
|
||||
Err(AdminError::Frame(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_brokerd_is_a_connect_error_that_names_the_socket() {
|
||||
let missing = support::temp_socket("nobody-listens.sock");
|
||||
let e = request(&missing, Message::Approvals(Empty {})).unwrap_err();
|
||||
assert!(matches!(e, AdminError::Connect(_, _)), "{e:?}");
|
||||
let message = e.to_string();
|
||||
assert!(message.starts_with("cannot reach brokerd at "), "{message}");
|
||||
assert!(message.contains(missing.to_str().unwrap()), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_returns_the_items_and_rejects_any_other_kind() {
|
||||
let items = vec![pending(41, "shell", "{}"), pending(44, "read_file", "{}")];
|
||||
let fake = brokerd_with(items.clone(), ALLOWED);
|
||||
assert_eq!(list(&fake.socket).unwrap(), items);
|
||||
assert_eq!(fake.requests(), vec![Message::Approvals(Empty {})]);
|
||||
|
||||
let wrong = fake_brokerd(|_| Message::Ok(Empty {}));
|
||||
assert!(matches!(list(&wrong.socket), Err(AdminError::Protocol(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reason_names_are_the_wire_names() {
|
||||
for reason in [
|
||||
DenyReason::NoGrant,
|
||||
DenyReason::GrantExpired,
|
||||
DenyReason::TaintTooHigh,
|
||||
DenyReason::DeniedByGrant,
|
||||
DenyReason::ApprovalRefused,
|
||||
DenyReason::ApprovalExpired,
|
||||
DenyReason::GrantsInvalid,
|
||||
DenyReason::AuditUnavailable,
|
||||
DenyReason::InvalidArguments,
|
||||
DenyReason::StateUnreadable,
|
||||
] {
|
||||
let wire = serde_json::to_string(&reason).unwrap();
|
||||
assert_eq!(format!("\"{}\"", reason_name(reason)), wire);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the block ----
|
||||
|
||||
fn block(item: &proto::PendingApproval, now: &str) -> String {
|
||||
let mut out = Vec::new();
|
||||
write_block(&mut out, item, ts(now)).unwrap();
|
||||
text(out)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_block_is_two_lines_in_this_exact_form() {
|
||||
let item = pending(
|
||||
41,
|
||||
"shell",
|
||||
r#"{"command":"rm -rf /home/kyle/scratch/build","cwd":"/home/kyle/scratch"}"#,
|
||||
);
|
||||
assert_eq!(
|
||||
block(&item, "2026-09-18T12:02:00.000Z"),
|
||||
concat!(
|
||||
"41 2 min ago expires in 13 min session chat-1758… grant shell-scratch taint private\n",
|
||||
" shell {\"command\":\"rm -rf /home/kyle/scratch/build\",\"cwd\":\"/home/kyle/scratch\"}\n",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn times_are_whole_seconds_minutes_or_hours_rounded_down() {
|
||||
let item = pending(41, "shell", "{}");
|
||||
let first = |now: &str| block(&item, now).lines().next().unwrap().to_string();
|
||||
// created 12:00:00, expires 12:15:00
|
||||
assert!(first("2026-09-18T12:00:00.000Z").starts_with("41 0 s ago expires in 15 min "));
|
||||
assert!(first("2026-09-18T12:00:59.999Z").starts_with("41 59 s ago expires in 14 min "));
|
||||
assert!(first("2026-09-18T12:01:00.000Z").starts_with("41 1 min ago expires in 14 min "));
|
||||
assert!(first("2026-09-18T12:14:30.000Z").starts_with("41 14 min ago expires in 30 s "));
|
||||
// At or after `expires` there is nothing left to wait for.
|
||||
assert!(first("2026-09-18T12:15:00.000Z").starts_with("41 15 min ago expired "));
|
||||
assert!(first("2026-09-18T14:05:00.000Z").starts_with("41 2 h ago expired "));
|
||||
// A clock that is behind the broker's must not underflow.
|
||||
assert!(first("2026-09-18T11:59:00.000Z").starts_with("41 0 s ago expires in 16 min "));
|
||||
|
||||
let mut long = pending(41, "shell", "{}");
|
||||
long.expires = ts("2026-09-18T15:30:00.000Z");
|
||||
assert!(block(&long, "2026-09-18T12:00:00.000Z").starts_with("41 0 s ago expires in 3 h "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_id_longer_than_ten_characters_is_cut_to_nine_and_an_ellipsis() {
|
||||
let mut item = pending(41, "shell", "{}");
|
||||
for (id, shown) in [
|
||||
("s1", "session s1 "),
|
||||
("abcdefghij", "session abcdefghij "),
|
||||
("abcdefghijk", "session abcdefghi… "),
|
||||
] {
|
||||
item.session = proto::SessionId::new(id).unwrap();
|
||||
let got = block(&item, "2026-09-18T12:00:00.000Z");
|
||||
assert!(got.contains(shown), "{id}: {got}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn taint_is_the_wire_name() {
|
||||
let mut item = pending(41, "shell", "{}");
|
||||
item.taint = proto::DataClass::Secret;
|
||||
assert!(block(&item, "2026-09-18T12:00:00.000Z").contains(" taint secret\n"));
|
||||
}
|
||||
|
||||
/// Whatever `brokerd` sends is printed as data: tool, grant and arguments all go through
|
||||
/// `escape_json_text`.
|
||||
#[test]
|
||||
fn nothing_in_the_block_reaches_the_terminal_raw() {
|
||||
let rlo = char::from_u32(0x202e).unwrap();
|
||||
let zwsp = char::from_u32(0x200b).unwrap();
|
||||
let isolate = char::from_u32(0x2066).unwrap();
|
||||
let arguments =
|
||||
format!("{{\"path\":\"/home/kyle/notes/{rlo}dm.terces{zwsp}{isolate}\x1b[8m\x1b[2J\"}}");
|
||||
let mut item = pending(41, "read\x1b[1mfile", &arguments);
|
||||
item.grant = "notes\nread".to_string();
|
||||
let got = block(&item, "2026-09-18T12:00:00.000Z");
|
||||
for c in ['\x1b', rlo, zwsp, isolate] {
|
||||
assert!(!got.contains(c), "U+{:04X} in {got:?}", u32::from(c));
|
||||
}
|
||||
assert_eq!(got.matches('\n').count(), 2, "still two lines: {got:?}");
|
||||
assert!(
|
||||
got.contains(&format!(
|
||||
"/home/kyle/notes/{}dm.terces{}{}{}[8m{}[2J",
|
||||
esc(0x202e),
|
||||
esc(0x200b),
|
||||
esc(0x2066),
|
||||
esc(0x1b),
|
||||
esc(0x1b)
|
||||
)),
|
||||
"{got:?}"
|
||||
);
|
||||
assert!(
|
||||
got.contains(&format!(" read{}[1mfile ", esc(0x1b))),
|
||||
"{got:?}"
|
||||
);
|
||||
assert!(
|
||||
got.contains(&format!("grant notes{}read ", esc(0x0a))),
|
||||
"{got:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- the commands ----
|
||||
|
||||
#[test]
|
||||
fn approvals_prints_one_block_per_item_in_the_order_given() {
|
||||
let fake = brokerd_with(
|
||||
vec![
|
||||
pending(41, "shell", r#"{"command":"ls"}"#),
|
||||
pending(44, "read_file", r#"{"path":"/home/kyle/notes/a.md"}"#),
|
||||
],
|
||||
ALLOWED,
|
||||
);
|
||||
let mut out = Vec::new();
|
||||
let ok = cmd_approvals(&fake.socket, ts("2026-09-18T12:02:00.000Z"), &mut out).unwrap();
|
||||
assert!(ok);
|
||||
let got = text(out);
|
||||
let lines: Vec<&str> = got.lines().collect();
|
||||
assert_eq!(lines.len(), 4, "{got}");
|
||||
assert!(lines[0].starts_with("41 2 min ago "), "{got}");
|
||||
assert_eq!(lines[1], r#" shell {"command":"ls"}"#);
|
||||
assert!(lines[2].starts_with("44 2 min ago "), "{got}");
|
||||
assert_eq!(
|
||||
lines[3],
|
||||
r#" read_file {"path":"/home/kyle/notes/a.md"}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approvals_with_nothing_pending_says_so() {
|
||||
let fake = brokerd_with(Vec::new(), ALLOWED);
|
||||
let mut out = Vec::new();
|
||||
assert!(cmd_approvals(&fake.socket, ts("2026-09-18T12:00:00.000Z"), &mut out).unwrap());
|
||||
assert_eq!(text(out), "no pending approvals\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approve_reports_the_re_decision() {
|
||||
// `ask` and `allowed` both let the call run; only a denial stops it.
|
||||
for (outcome, line, ok) in [
|
||||
(DecisionRecord::Allowed {}, "approved 41: runs\n", true),
|
||||
(DecisionRecord::Ask {}, "approved 41: runs\n", true),
|
||||
(
|
||||
DecisionRecord::Denied {
|
||||
reason: DenyReason::NoGrant,
|
||||
},
|
||||
"approved 41: denied (no_grant)\n",
|
||||
false,
|
||||
),
|
||||
(
|
||||
DecisionRecord::Denied {
|
||||
reason: DenyReason::TaintTooHigh,
|
||||
},
|
||||
"approved 41: denied (taint_too_high)\n",
|
||||
false,
|
||||
),
|
||||
(
|
||||
DecisionRecord::Denied {
|
||||
reason: DenyReason::AuditUnavailable,
|
||||
},
|
||||
"approved 41: denied (audit_unavailable)\n",
|
||||
false,
|
||||
),
|
||||
] {
|
||||
let fake = brokerd_with(vec![pending(41, "shell", "{}")], outcome);
|
||||
let mut out = Vec::new();
|
||||
assert_eq!(cmd_approve(&fake.socket, 41, &mut out).unwrap(), ok);
|
||||
assert_eq!(text(out), line);
|
||||
assert_eq!(
|
||||
fake.requests(),
|
||||
vec![Message::Approve(Approve { approval: 41 })]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuse_sends_the_reason_when_there_is_one() {
|
||||
for reason in [None, Some("not on a Friday")] {
|
||||
let fake = brokerd_with(vec![pending(41, "shell", "{}")], ALLOWED);
|
||||
let mut out = Vec::new();
|
||||
assert!(cmd_refuse(&fake.socket, 41, reason, &mut out).unwrap());
|
||||
assert_eq!(text(out), "refused 41\n");
|
||||
assert_eq!(
|
||||
fake.requests(),
|
||||
vec![Message::Refuse(Refuse {
|
||||
approval: 41,
|
||||
reason: reason.map(str::to_string)
|
||||
})]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_or_answered_id_is_reported_the_same_way_by_both() {
|
||||
let fake = brokerd_with(vec![pending(41, "shell", "{}")], ALLOWED);
|
||||
let want = "99: no such approval (already answered or expired)\n";
|
||||
let mut out = Vec::new();
|
||||
assert!(!cmd_approve(&fake.socket, 99, &mut out).unwrap());
|
||||
assert_eq!(text(out), want);
|
||||
let mut out = Vec::new();
|
||||
assert!(!cmd_refuse(&fake.socket, 99, None, &mut out).unwrap());
|
||||
assert_eq!(text(out), want);
|
||||
}
|
||||
|
||||
/// Every exit of every command: any other error frame is an error, and so is an answer of the
|
||||
/// wrong kind. Nothing is printed for them.
|
||||
#[test]
|
||||
fn other_errors_and_wrong_kinds_are_errors_for_every_command() {
|
||||
let refused = fake_brokerd(|_| wire_error(ErrorCode::Internal, "boom"));
|
||||
let wrong = fake_brokerd(|_| {
|
||||
Message::GrantsReport(GrantsReport {
|
||||
problems: Vec::new(),
|
||||
})
|
||||
});
|
||||
let wrong_for_grants = fake_brokerd(|_| Message::Ok(Empty {}));
|
||||
let now = ts("2026-09-18T12:00:00.000Z");
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(matches!(
|
||||
cmd_approvals(&refused.socket, now, &mut out),
|
||||
Err(AdminError::Refused(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
cmd_approve(&refused.socket, 41, &mut out),
|
||||
Err(AdminError::Refused(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
cmd_refuse(&refused.socket, 41, None, &mut out),
|
||||
Err(AdminError::Refused(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
cmd_grants_check(&refused.socket, &mut out),
|
||||
Err(AdminError::Refused(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
cmd_approvals(&wrong.socket, now, &mut out),
|
||||
Err(AdminError::Protocol(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
cmd_approve(&wrong.socket, 41, &mut out),
|
||||
Err(AdminError::Protocol(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
cmd_refuse(&wrong.socket, 41, None, &mut out),
|
||||
Err(AdminError::Protocol(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
cmd_grants_check(&wrong_for_grants.socket, &mut out),
|
||||
Err(AdminError::Protocol(_))
|
||||
));
|
||||
assert_eq!(text(out), "", "an error prints nothing on the output");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grants_check_prints_ok_or_every_problem() {
|
||||
let fine = fake_brokerd(|_| {
|
||||
Message::GrantsReport(GrantsReport {
|
||||
problems: Vec::new(),
|
||||
})
|
||||
});
|
||||
let mut out = Vec::new();
|
||||
assert!(cmd_grants_check(&fine.socket, &mut out).unwrap());
|
||||
assert_eq!(text(out), "grants: ok\n");
|
||||
assert_eq!(fine.requests(), vec![Message::CheckGrants(Empty {})]);
|
||||
|
||||
let broken = fake_brokerd(|_| {
|
||||
Message::GrantsReport(GrantsReport {
|
||||
problems: vec![
|
||||
GrantProblem {
|
||||
file: "notes-read.toml".to_string(),
|
||||
line: Some(3),
|
||||
problem: "unknown field `mod`".to_string(),
|
||||
},
|
||||
GrantProblem {
|
||||
file: "Bad Name.toml".to_string(),
|
||||
line: None,
|
||||
problem: "the file name is not a valid grant id".to_string(),
|
||||
},
|
||||
GrantProblem {
|
||||
file: "x.toml".to_string(),
|
||||
line: Some(1),
|
||||
problem: "two\nlines".to_string(),
|
||||
},
|
||||
],
|
||||
})
|
||||
});
|
||||
let mut out = Vec::new();
|
||||
assert!(!cmd_grants_check(&broken.socket, &mut out).unwrap());
|
||||
assert_eq!(
|
||||
text(out),
|
||||
format!(
|
||||
"notes-read.toml:3: unknown field `mod`\n\
|
||||
Bad Name.toml: the file name is not a valid grant id\n\
|
||||
x.toml:1: two{}lines\n",
|
||||
esc(0x0a)
|
||||
),
|
||||
"every problem, one line each; file and problem go through escape_json_text"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- the binary ----
|
||||
|
||||
fn bxctl(args: &[&str], socket: &std::path::Path) -> std::process::Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_bxctl"))
|
||||
.args(args)
|
||||
.arg("--admin-socket")
|
||||
.arg(socket)
|
||||
.output()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_binary_prints_on_stdout_and_sets_the_exit_status() {
|
||||
let fake = brokerd_with(
|
||||
vec![pending(41, "shell", r#"{"command":"ls"}"#)],
|
||||
DecisionRecord::Denied {
|
||||
reason: DenyReason::DeniedByGrant,
|
||||
},
|
||||
);
|
||||
let output = bxctl(&["approvals"], &fake.socket);
|
||||
assert_eq!(output.status.code(), Some(0));
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
stdout.ends_with(" shell {\"command\":\"ls\"}\n"),
|
||||
"{stdout}"
|
||||
);
|
||||
|
||||
let output = bxctl(&["approve", "41"], &fake.socket);
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
"approved 41: denied (denied_by_grant)\n"
|
||||
);
|
||||
|
||||
let output = bxctl(&["refuse", "41", "--reason", "no"], &fake.socket);
|
||||
assert_eq!(output.status.code(), Some(0));
|
||||
assert_eq!(String::from_utf8_lossy(&output.stdout), "refused 41\n");
|
||||
|
||||
let output = bxctl(&["refuse", "7"], &fake.socket);
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
"7: no such approval (already answered or expired)\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_binary_reports_an_error_on_stderr_with_status_1() {
|
||||
let fake = fake_brokerd(|_| wire_error(ErrorCode::Internal, "boom"));
|
||||
let output = bxctl(&["grants", "check"], &fake.socket);
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
assert_eq!(String::from_utf8_lossy(&output.stdout), "");
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
"bxctl: internal: boom\n"
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
//! Tests for `bxctl`'s command line. Do not edit.
|
||||
|
||||
use bxctl::cli::{ChatOptions, Command, USAGE, UsageError, parse};
|
||||
use proto::SessionId;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command as Process;
|
||||
|
||||
const HOME: &str = "/srv/bx";
|
||||
|
||||
fn args(words: &[&str]) -> Vec<String> {
|
||||
words.iter().map(|w| w.to_string()).collect()
|
||||
}
|
||||
|
||||
fn ok(words: &[&str]) -> Command {
|
||||
parse(&args(words), Path::new(HOME)).unwrap_or_else(|_| panic!("{words:?} must parse"))
|
||||
}
|
||||
|
||||
fn bad(words: &[&str]) {
|
||||
assert_eq!(
|
||||
parse(&args(words), Path::new(HOME)),
|
||||
Err(UsageError),
|
||||
"{words:?} must be a usage error"
|
||||
);
|
||||
}
|
||||
|
||||
fn default_admin() -> PathBuf {
|
||||
PathBuf::from("/srv/bx/run/owner-broker/admin.sock")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_defaults_come_from_home() {
|
||||
assert_eq!(
|
||||
ok(&["chat"]),
|
||||
Command::Chat(ChatOptions {
|
||||
socket: PathBuf::from("/srv/bx/run/loop/loop.sock"),
|
||||
admin_socket: default_admin(),
|
||||
session: None,
|
||||
show_thinking: true,
|
||||
say: None,
|
||||
json: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_takes_every_flag_in_any_order() {
|
||||
assert_eq!(
|
||||
ok(&[
|
||||
"chat",
|
||||
"--json",
|
||||
"--admin-socket",
|
||||
"/tmp/a.sock",
|
||||
"--say",
|
||||
"hello there",
|
||||
"--no-thinking",
|
||||
"--session",
|
||||
"s-1",
|
||||
"--socket",
|
||||
"/tmp/l.sock",
|
||||
]),
|
||||
Command::Chat(ChatOptions {
|
||||
socket: PathBuf::from("/tmp/l.sock"),
|
||||
admin_socket: PathBuf::from("/tmp/a.sock"),
|
||||
session: Some(SessionId::new("s-1").unwrap()),
|
||||
show_thinking: false,
|
||||
say: Some("hello there".to_string()),
|
||||
json: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_usage_errors() {
|
||||
bad(&["chat", "--session", "Not Valid!"]);
|
||||
bad(&["chat", "--session"]);
|
||||
bad(&["chat", "--socket"]);
|
||||
bad(&["chat", "--admin-socket"]);
|
||||
bad(&["chat", "--say"]);
|
||||
bad(&["chat", "--dance"]);
|
||||
bad(&["chat", "stray"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approvals() {
|
||||
assert_eq!(
|
||||
ok(&["approvals"]),
|
||||
Command::Approvals {
|
||||
admin_socket: default_admin()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
ok(&["approvals", "--admin-socket", "/tmp/a.sock"]),
|
||||
Command::Approvals {
|
||||
admin_socket: PathBuf::from("/tmp/a.sock")
|
||||
}
|
||||
);
|
||||
bad(&["approvals", "41"]);
|
||||
bad(&["approvals", "--admin-socket"]);
|
||||
bad(&["approvals", "--reason", "x"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approve() {
|
||||
assert_eq!(
|
||||
ok(&["approve", "41"]),
|
||||
Command::Approve {
|
||||
admin_socket: default_admin(),
|
||||
approval: 41
|
||||
}
|
||||
);
|
||||
// The flag may come before or after the id.
|
||||
for words in [
|
||||
["approve", "--admin-socket", "/tmp/a.sock", "41"],
|
||||
["approve", "41", "--admin-socket", "/tmp/a.sock"],
|
||||
] {
|
||||
assert_eq!(
|
||||
ok(&words),
|
||||
Command::Approve {
|
||||
admin_socket: PathBuf::from("/tmp/a.sock"),
|
||||
approval: 41
|
||||
}
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
ok(&["approve", "18446744073709551615"]),
|
||||
Command::Approve {
|
||||
admin_socket: default_admin(),
|
||||
approval: u64::MAX
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// An id is decimal digits and nothing else. `str::parse::<u64>` alone would accept `+41`.
|
||||
#[test]
|
||||
fn an_approval_id_is_only_digits() {
|
||||
bad(&["approve"]);
|
||||
bad(&["approve", "41", "42"]);
|
||||
bad(&["approve", "+41"]);
|
||||
bad(&["approve", "-1"]);
|
||||
bad(&["approve", "4 1"]);
|
||||
bad(&["approve", " 41"]);
|
||||
bad(&["approve", "0x29"]);
|
||||
bad(&["approve", "forty-one"]);
|
||||
bad(&["approve", ""]);
|
||||
bad(&["approve", "18446744073709551616"]);
|
||||
bad(&["approve", "41", "--reason", "x"]);
|
||||
bad(&["refuse"]);
|
||||
bad(&["refuse", "+41"]);
|
||||
bad(&["refuse", "41", "42"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuse() {
|
||||
assert_eq!(
|
||||
ok(&["refuse", "41"]),
|
||||
Command::Refuse {
|
||||
admin_socket: default_admin(),
|
||||
approval: 41,
|
||||
reason: None
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
ok(&[
|
||||
"refuse",
|
||||
"41",
|
||||
"--reason",
|
||||
"not on a Friday",
|
||||
"--admin-socket",
|
||||
"/tmp/a.sock"
|
||||
]),
|
||||
Command::Refuse {
|
||||
admin_socket: PathBuf::from("/tmp/a.sock"),
|
||||
approval: 41,
|
||||
reason: Some("not on a Friday".to_string())
|
||||
}
|
||||
);
|
||||
// A value is a value, even when it looks like a flag.
|
||||
assert_eq!(
|
||||
ok(&["refuse", "--reason", "--admin-socket", "41"]),
|
||||
Command::Refuse {
|
||||
admin_socket: default_admin(),
|
||||
approval: 41,
|
||||
reason: Some("--admin-socket".to_string())
|
||||
}
|
||||
);
|
||||
bad(&["refuse", "41", "--reason"]);
|
||||
bad(&["refuse", "41", "--reason", "a", "--reason", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grants_check_and_audit_verify() {
|
||||
assert_eq!(
|
||||
ok(&["grants", "check"]),
|
||||
Command::GrantsCheck {
|
||||
admin_socket: default_admin()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
ok(&["grants", "check", "--admin-socket", "/tmp/a.sock"]),
|
||||
Command::GrantsCheck {
|
||||
admin_socket: PathBuf::from("/tmp/a.sock")
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
ok(&["audit", "verify"]),
|
||||
Command::AuditVerify {
|
||||
home: PathBuf::from(HOME)
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
ok(&["audit", "verify", "--home", "/tmp/h"]),
|
||||
Command::AuditVerify {
|
||||
home: PathBuf::from("/tmp/h")
|
||||
}
|
||||
);
|
||||
bad(&["grants"]);
|
||||
bad(&["grants", "list"]);
|
||||
bad(&["grants", "check", "extra"]);
|
||||
bad(&["audit"]);
|
||||
bad(&["audit", "verify", "--home"]);
|
||||
bad(&["audit", "verify", "--admin-socket", "/tmp/a.sock"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anything_else_is_a_usage_error() {
|
||||
bad(&[]);
|
||||
bad(&["dance"]);
|
||||
bad(&["--admin-socket", "/tmp/a.sock", "approvals"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_names_every_command() {
|
||||
for word in [
|
||||
"bxctl chat",
|
||||
"bxctl approvals",
|
||||
"bxctl approve <id>",
|
||||
"bxctl refuse <id>",
|
||||
"bxctl grants check",
|
||||
"bxctl audit verify",
|
||||
"--admin-socket",
|
||||
"--reason",
|
||||
"--home",
|
||||
] {
|
||||
assert!(USAGE.contains(word), "usage lacks {word:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_binary_prints_usage_and_exits_2() {
|
||||
for words in [
|
||||
vec![],
|
||||
vec!["dance"],
|
||||
vec!["approve", "+41"],
|
||||
vec!["refuse"],
|
||||
] {
|
||||
let output = Process::new(env!("CARGO_BIN_EXE_bxctl"))
|
||||
.args(&words)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(2), "{words:?}");
|
||||
assert_eq!(String::from_utf8_lossy(&output.stdout), "", "{words:?}");
|
||||
assert!(
|
||||
String::from_utf8_lossy(&output.stderr).contains("bxctl approve <id>"),
|
||||
"{words:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The default sockets are under `$BOXMAKER_HOME`. Nothing listens there, so the command fails
|
||||
/// to connect, and says where it tried.
|
||||
#[test]
|
||||
fn the_binary_finds_the_admin_socket_under_boxmaker_home() {
|
||||
let home = std::env::temp_dir().join(format!("bxctl-cli-home-{}", std::process::id()));
|
||||
let output = Process::new(env!("CARGO_BIN_EXE_bxctl"))
|
||||
.arg("approvals")
|
||||
.env("BOXMAKER_HOME", &home)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let want = home.join("run/owner-broker/admin.sock");
|
||||
assert!(stderr.contains(want.to_str().unwrap()), "{stderr}");
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Tests for `bxctl::escape`: text the model wrote is printed as data. Do not edit.
|
||||
//!
|
||||
//! The expected escapes are built by `esc`, never spelled out, so that nothing that handles this
|
||||
//! file can turn one into the character it stands for.
|
||||
|
||||
use bxctl::escape::{escape_json_text, escape_model_text};
|
||||
|
||||
const BACKSLASH: char = '\\';
|
||||
|
||||
/// The escape for one code point: a backslash, `u`, and four lowercase hex digits.
|
||||
fn esc(code: u32) -> String {
|
||||
format!("{BACKSLASH}u{code:04x}")
|
||||
}
|
||||
|
||||
fn ch(code: u32) -> char {
|
||||
char::from_u32(code).unwrap()
|
||||
}
|
||||
|
||||
/// Every code point that must be escaped, as inclusive ranges.
|
||||
const HIDDEN: [(u32, u32); 6] = [
|
||||
(0x0000, 0x001f),
|
||||
(0x007f, 0x009f),
|
||||
(0x200b, 0x200f),
|
||||
(0x2028, 0x202e),
|
||||
(0x2060, 0x2069),
|
||||
(0xfeff, 0xfeff),
|
||||
];
|
||||
|
||||
fn hidden(code: u32) -> bool {
|
||||
HIDDEN.iter().any(|(lo, hi)| (*lo..=*hi).contains(&code))
|
||||
}
|
||||
|
||||
/// Walks every code point below U+11000, not a sample: each is either escaped exactly or left
|
||||
/// exactly as it is.
|
||||
#[test]
|
||||
fn every_listed_code_point_is_escaped_and_no_other() {
|
||||
let mut escaped = 0;
|
||||
for code in 0..0x11000u32 {
|
||||
let Some(c) = char::from_u32(code) else {
|
||||
continue; // the surrogates are not characters
|
||||
};
|
||||
let text = format!("a{c}b");
|
||||
let got = escape_json_text(&text);
|
||||
if hidden(code) {
|
||||
escaped += 1;
|
||||
assert_eq!(got, format!("a{}b", esc(code)), "U+{code:04X}");
|
||||
} else {
|
||||
assert_eq!(got, text, "U+{code:04X} must pass through");
|
||||
}
|
||||
}
|
||||
assert_eq!(escaped, 32 + 33 + 5 + 7 + 10 + 1, "the six ranges, in full");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_edges_of_each_range() {
|
||||
for (lo, hi) in HIDDEN {
|
||||
assert_eq!(escape_json_text(&ch(lo).to_string()), esc(lo));
|
||||
assert_eq!(escape_json_text(&ch(hi).to_string()), esc(hi));
|
||||
if lo > 0 {
|
||||
let before = ch(lo - 1).to_string();
|
||||
assert_eq!(escape_json_text(&before), before, "U+{:04X}", lo - 1);
|
||||
}
|
||||
let after = ch(hi + 1).to_string();
|
||||
assert_eq!(escape_json_text(&after), after, "U+{:04X}", hi + 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_digits_are_lowercase_and_there_are_always_four() {
|
||||
assert_eq!(escape_json_text("\x1b"), esc(0x1b));
|
||||
assert!(escape_json_text("\x1b").ends_with("001b"));
|
||||
assert!(escape_json_text("\0").ends_with("0000"));
|
||||
assert!(escape_json_text(&ch(0xfeff).to_string()).ends_with("feff"));
|
||||
assert!(escape_json_text(&ch(0x202e).to_string()).ends_with("202e"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_escape_sequence_cannot_reach_the_terminal() {
|
||||
let text = "before\x1b[8mhidden\x1b[0m\x07after";
|
||||
let got = escape_json_text(text);
|
||||
assert!(!got.contains('\x1b') && !got.contains('\x07'), "{got:?}");
|
||||
assert_eq!(
|
||||
got,
|
||||
format!(
|
||||
"before{}[8mhidden{}[0m{}after",
|
||||
esc(0x1b),
|
||||
esc(0x1b),
|
||||
esc(0x07)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_cannot_be_shown_backwards() {
|
||||
// U+202E makes a terminal draw what follows from right to left.
|
||||
let text = format!("/home/kyle/notes/{}dm.terces", ch(0x202e));
|
||||
assert_eq!(
|
||||
escape_json_text(&text),
|
||||
format!("/home/kyle/notes/{}dm.terces", esc(0x202e))
|
||||
);
|
||||
let text = format!("a{}b{}c", ch(0x200b), ch(0x2066));
|
||||
assert_eq!(
|
||||
escape_json_text(&text),
|
||||
format!("a{}b{}c", esc(0x200b), esc(0x2066))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_text_is_unchanged() {
|
||||
for text in [
|
||||
"",
|
||||
"plain",
|
||||
r#"{"command":"ls -l","cwd":"/home/kyle"}"#,
|
||||
"naïve café 日本語 🙂",
|
||||
"a backslash \\ and a quote \" stay as they are",
|
||||
] {
|
||||
assert_eq!(escape_json_text(text), text);
|
||||
assert_eq!(escape_model_text(text), text);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_text_escapes_newline_and_tab_but_model_text_keeps_them() {
|
||||
let text = "one\n\ttwo\r\n";
|
||||
assert_eq!(
|
||||
escape_json_text(text),
|
||||
format!("one{}{}two{}{}", esc(0x0a), esc(0x09), esc(0x0d), esc(0x0a))
|
||||
);
|
||||
assert_eq!(
|
||||
escape_model_text(text),
|
||||
format!("one\n\ttwo{}\n", esc(0x0d)),
|
||||
"only newline and tab pass; a carriage return could overwrite the line"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_text_escapes_everything_else_the_same_way() {
|
||||
for (lo, hi) in HIDDEN {
|
||||
for code in lo..=hi {
|
||||
if code == 0x0a || code == 0x09 {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(
|
||||
escape_model_text(&ch(code).to_string()),
|
||||
esc(code),
|
||||
"U+{code:04X}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Scripted stand-ins for `brokerd`'s admin socket and for `loopd`, for tests. Do not edit.
|
||||
|
||||
#![allow(dead_code)] // each test file uses a different part of this module
|
||||
|
||||
use proto::{
|
||||
ApprovalList, CallId, DataClass, Envelope, ErrorCode, Message, PROTOCOL_VERSION,
|
||||
PendingApproval, SessionId, Timestamp, TurnDone, TurnEvent, Usage, WireError, read_frame,
|
||||
write_frame,
|
||||
};
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
|
||||
static NEXT: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
/// A socket path in a fresh temporary directory.
|
||||
pub fn temp_socket(name: &str) -> PathBuf {
|
||||
let n = NEXT.fetch_add(1, Ordering::SeqCst);
|
||||
let dir = std::env::temp_dir().join(format!("bxctl-m3a-{}-{n}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir.join(name)
|
||||
}
|
||||
|
||||
pub fn ts(text: &str) -> Timestamp {
|
||||
Timestamp::parse(text).unwrap()
|
||||
}
|
||||
|
||||
pub struct FakeBrokerd {
|
||||
pub socket: PathBuf,
|
||||
/// Every request message received, in order.
|
||||
pub requests: Arc<Mutex<Vec<Message>>>,
|
||||
}
|
||||
|
||||
impl FakeBrokerd {
|
||||
pub fn requests(&self) -> Vec<Message> {
|
||||
self.requests.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves `admin.sock`: one request per connection, answered with the frames `answer` returns,
|
||||
/// written exactly as given (so a test can send a wrong id or a frame that is not final). An
|
||||
/// empty list closes the connection without an answer.
|
||||
pub fn fake_brokerd_frames(
|
||||
answer: impl Fn(&Envelope) -> Vec<Envelope> + Send + 'static,
|
||||
) -> FakeBrokerd {
|
||||
let socket = temp_socket("admin.sock");
|
||||
let listener = UnixListener::bind(&socket).unwrap();
|
||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let seen = Arc::clone(&requests);
|
||||
thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
let Ok(mut stream) = stream else { continue };
|
||||
let Ok(request) = read_frame(&mut stream) else {
|
||||
continue;
|
||||
};
|
||||
seen.lock().unwrap().push(request.msg.clone());
|
||||
for frame in answer(&request) {
|
||||
if write_frame(&mut stream, &frame).is_err() {
|
||||
break; // the client went away; the next connection is still served
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
FakeBrokerd { socket, requests }
|
||||
}
|
||||
|
||||
/// The usual case: one final frame with the request's id.
|
||||
pub fn fake_brokerd(answer: impl Fn(&Message) -> Message + Send + 'static) -> FakeBrokerd {
|
||||
fake_brokerd_frames(move |request| {
|
||||
vec![Envelope {
|
||||
v: PROTOCOL_VERSION,
|
||||
id: request.id,
|
||||
r#final: true,
|
||||
msg: answer(&request.msg),
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
pub fn wire_error(code: ErrorCode, detail: &str) -> Message {
|
||||
Message::Error(WireError {
|
||||
code,
|
||||
detail: detail.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// A pending approval created at 12:00:00 that expires at 12:15:00 on 2026-09-18.
|
||||
pub fn pending(approval: u64, tool: &str, arguments: &str) -> PendingApproval {
|
||||
PendingApproval {
|
||||
approval,
|
||||
session: SessionId::new("chat-1758196800-123456789").unwrap(),
|
||||
call: CallId(7),
|
||||
tool: tool.to_string(),
|
||||
arguments: arguments.to_string(),
|
||||
grant: "shell-scratch".to_string(),
|
||||
taint: DataClass::Private,
|
||||
created: ts("2026-09-18T12:00:00.000Z"),
|
||||
expires: ts("2026-09-18T12:15:00.000Z"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A `brokerd` with these approvals pending. `approve` is answered with `outcome`, `refuse` with
|
||||
/// `ok`; both answer `no_such_approval` for an id that is not in the list.
|
||||
pub fn brokerd_with(items: Vec<PendingApproval>, outcome: proto::DecisionRecord) -> FakeBrokerd {
|
||||
fake_brokerd(move |msg| {
|
||||
let known = |id: u64| items.iter().any(|item| item.approval == id);
|
||||
match msg {
|
||||
Message::Approvals(_) => Message::ApprovalList(ApprovalList {
|
||||
items: items.clone(),
|
||||
}),
|
||||
Message::Approve(a) if known(a.approval) => {
|
||||
Message::ApproveResult(proto::ApproveResult {
|
||||
outcome: outcome.clone(),
|
||||
})
|
||||
}
|
||||
Message::Refuse(r) if known(r.approval) => Message::Ok(proto::Empty {}),
|
||||
Message::Approve(_) | Message::Refuse(_) => {
|
||||
wire_error(ErrorCode::NoSuchApproval, "no such approval")
|
||||
}
|
||||
_ => wire_error(ErrorCode::BadMessage, "not an admin request"),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub struct FakeLoopd {
|
||||
pub socket: PathBuf,
|
||||
/// The content of every turn received, in order.
|
||||
pub turns: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
pub fn usage() -> Usage {
|
||||
Usage {
|
||||
cache_n: 10,
|
||||
prompt_n: 5,
|
||||
predicted_n: 7,
|
||||
reasoning_tokens: 3,
|
||||
thinking_capped: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves `loop.sock`: every turn gets the same events, then `turn_done` with `answer`. It does
|
||||
/// not wait for an approval to be answered: `bxctl` deals with each event before it reads the
|
||||
/// next frame, so the order of what it does is fixed all the same.
|
||||
pub fn fake_loopd(events: Vec<TurnEvent>, answer: &str) -> FakeLoopd {
|
||||
let socket = temp_socket("loop.sock");
|
||||
let listener = UnixListener::bind(&socket).unwrap();
|
||||
let turns = Arc::new(Mutex::new(Vec::new()));
|
||||
let seen = Arc::clone(&turns);
|
||||
let answer = answer.to_string();
|
||||
thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
let Ok(mut stream) = stream else { continue };
|
||||
let Ok(request) = read_frame(&mut stream) else {
|
||||
continue;
|
||||
};
|
||||
let Message::Turn(turn) = request.msg else {
|
||||
continue;
|
||||
};
|
||||
seen.lock().unwrap().push(turn.content);
|
||||
let mut frames: Vec<(bool, Message)> = events
|
||||
.iter()
|
||||
.map(|e| (false, Message::TurnEvent(e.clone())))
|
||||
.collect();
|
||||
frames.push((
|
||||
true,
|
||||
Message::TurnDone(TurnDone {
|
||||
content: answer.clone(),
|
||||
usage: usage(),
|
||||
}),
|
||||
));
|
||||
for (last, msg) in frames {
|
||||
let frame = Envelope {
|
||||
v: PROTOCOL_VERSION,
|
||||
id: request.id,
|
||||
r#final: last,
|
||||
msg,
|
||||
};
|
||||
if write_frame(&mut stream, &frame).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
FakeLoopd { socket, turns }
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! `bxctl audit verify` against the fixture logs in `crates/proto/tests/fixtures/audit/`.
|
||||
//! Do not edit. The output is compared byte for byte: the owner reads it, and so do scripts.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
static NEXT: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
/// A home directory whose `audit/` is a copy of the fixture log `case`. Removed when dropped.
|
||||
struct Home {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl Home {
|
||||
fn with_case(case: &str) -> Home {
|
||||
let n = NEXT.fetch_add(1, Ordering::SeqCst);
|
||||
let name = format!("bxctl-verify-{}-{n}", std::process::id());
|
||||
let path = std::env::temp_dir().join(name);
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
let audit = path.join("audit");
|
||||
std::fs::create_dir_all(&audit).unwrap();
|
||||
let from = format!(
|
||||
"{}/../proto/tests/fixtures/audit/{case}",
|
||||
env!("CARGO_MANIFEST_DIR")
|
||||
);
|
||||
for entry in std::fs::read_dir(&from).unwrap_or_else(|e| panic!("{from}: {e}")) {
|
||||
let entry = entry.unwrap();
|
||||
std::fs::copy(entry.path(), audit.join(entry.file_name())).unwrap();
|
||||
}
|
||||
Home { path }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Home {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
fn run(case: &str) -> (bool, String) {
|
||||
let home = Home::with_case(case);
|
||||
// What brokerd leaves beside the log must not be read as part of it.
|
||||
std::fs::write(home.path.join("audit/.lock"), "").unwrap();
|
||||
let mut out = Vec::new();
|
||||
let ok = bxctl::verify::run(&home.path, &mut out).unwrap();
|
||||
(ok, String::from_utf8(out).unwrap())
|
||||
}
|
||||
|
||||
/// The hex of the hash of the last line of `file` in `case`.
|
||||
fn head_of(case: &str, file: &str) -> String {
|
||||
let path = format!(
|
||||
"{}/../proto/tests/fixtures/audit/{case}/{file}",
|
||||
env!("CARGO_MANIFEST_DIR")
|
||||
);
|
||||
let text = std::fs::read_to_string(path).unwrap();
|
||||
proto::sha256(text.lines().last().unwrap().as_bytes())
|
||||
.unwrap()
|
||||
.to_hex()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_good_log() {
|
||||
let (ok, out) = run("good");
|
||||
assert!(ok);
|
||||
let head = head_of("good", "2026-09-18.jsonl");
|
||||
assert_eq!(
|
||||
out,
|
||||
format!(
|
||||
"audit: ok, 10 records, head {head}\n\
|
||||
pending or abandoned: approval 6\n\
|
||||
running or unfinished: decision 7\n"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everything_worth_knowing_is_listed_one_per_line() {
|
||||
let (ok, out) = run("recovered-next-day");
|
||||
assert!(ok);
|
||||
let lines: Vec<&str> = out.lines().collect();
|
||||
assert!(
|
||||
lines[0].starts_with("audit: ok, 11 records, head "),
|
||||
"{out}"
|
||||
);
|
||||
assert_eq!(
|
||||
lines[1..],
|
||||
[
|
||||
"recovered line: 2026-09-17.jsonl:6",
|
||||
"pending or abandoned: approval 7",
|
||||
"running or unfinished: decision 8",
|
||||
]
|
||||
);
|
||||
|
||||
let (ok, out) = run("accepted-break-older-file");
|
||||
assert!(ok);
|
||||
assert!(
|
||||
out.contains("\naccepted break: 2026-09-18.jsonl:6\n"),
|
||||
"{out}"
|
||||
);
|
||||
|
||||
let (ok, out) = run("clock-back");
|
||||
assert!(ok);
|
||||
assert!(
|
||||
out.ends_with("\nclock went backwards: 2026-09-18.jsonl:6\n"),
|
||||
"{out}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A torn final line is what a crash, or a `brokerd` in the middle of a write, leaves. It is
|
||||
/// reported and is not a failure.
|
||||
#[test]
|
||||
fn a_torn_tail_is_reported_and_is_ok() {
|
||||
let (ok, out) = run("torn-tail");
|
||||
assert!(ok);
|
||||
assert!(out.starts_with("audit: ok, 10 records, head "), "{out}");
|
||||
assert!(
|
||||
out.ends_with(
|
||||
"\ntorn final line: 2026-09-18.jsonl:6 (brokerd recovers it at its next start)\n"
|
||||
),
|
||||
"{out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_chain_is_two_lines_and_false() {
|
||||
let cases = [
|
||||
(
|
||||
"changed-byte",
|
||||
"2026-09-17.jsonl:4: prev is not the hash of the line before",
|
||||
),
|
||||
("deleted-line", "2026-09-17.jsonl:3: seq is 3, expected 2"),
|
||||
(
|
||||
"cut-short",
|
||||
"2026-09-17.jsonl:3: does not parse as an audit record",
|
||||
),
|
||||
(
|
||||
"file-not-chained",
|
||||
"2026-09-18.jsonl:1: does not chain from the last line of the file before",
|
||||
),
|
||||
(
|
||||
"break-wrong-line",
|
||||
"2026-09-17.jsonl:4: prev is not the hash of the line before",
|
||||
),
|
||||
];
|
||||
for (case, first) in cases {
|
||||
let (ok, out) = run(case);
|
||||
assert!(!ok, "{case}");
|
||||
assert_eq!(
|
||||
out,
|
||||
format!("{first}\nsee docs/runbook.md#audit-chain-broken\n"),
|
||||
"{case}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_audit_directory_is_an_empty_log() {
|
||||
let home = Home::with_case("good");
|
||||
for entry in std::fs::read_dir(home.path.join("audit")).unwrap() {
|
||||
std::fs::remove_file(entry.unwrap().path()).unwrap();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
assert!(bxctl::verify::run(&home.path, &mut out).unwrap());
|
||||
assert_eq!(
|
||||
String::from_utf8(out).unwrap(),
|
||||
"audit: ok, 0 records, head none\n"
|
||||
);
|
||||
}
|
||||
|
||||
/// A home with no audit directory is a mistake in `--home`, not a clean log.
|
||||
#[test]
|
||||
fn a_missing_audit_directory_is_an_error() {
|
||||
let home = Home::with_case("good");
|
||||
std::fs::remove_dir_all(home.path.join("audit")).unwrap();
|
||||
let mut out = Vec::new();
|
||||
let error = bxctl::verify::run(&home.path, &mut out).unwrap_err();
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
Reference in New Issue
Block a user