M5b plan: task 09, TurnDone.untrusted from the session's log

bxcore's TurnDone gains untrusted: bool, always written and required
when read (no serde default: a missing field would read as a clean
session, the wrong way to fail). loopd's new Session::untrusted is true
once any ToolResult in the log is labelled untrusted, and channel::handle
puts it in the TurnDone; the log is the one source, so it holds across
resumes and restarts. gatewayd, bxctl and brokerd sources are unchanged;
their tests that build a TurnDone get new versions. Task 01's broker.rs
is kept as broker.rs-task01, and task 01 now copies it.

Checks: the given tests fail at ref 08 as the task says (strict's
turn_done.json, and 13 test targets with E0560/E0609 only); the task's
edits applied from its text to a fresh ref 08 tree are byte-identical to
the reference; the gate passes with 1058 tests; 12 mutations caught;
/private/tmp and TMPDIR unchanged around the runs; no tag in plan or
given files.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-26 06:37:03 -07:00
co-authored by Claude Opus 5.5
parent d4b7f42b56
commit 8658af58b7
11 changed files with 1907 additions and 3 deletions
@@ -52,7 +52,8 @@ This is the complete list. Each one is covered by an edit below or by a given fi
`crates/brokerd/tests/invalid_detail.rs` `crates/brokerd/tests/invalid_detail.rs`
- **new versions** of: `crates/bxcore/tests/wire.rs`, `crates/bxcore/tests/strict.rs`; - **new versions** of: `crates/bxcore/tests/wire.rs`, `crates/bxcore/tests/strict.rs`;
`crates/brokerd/tests/admin.rs`, `broker.rs`, `broker_pending.rs`, `courier.rs`, `ledger.rs`, `crates/brokerd/tests/admin.rs`, `broker.rs`, `broker_pending.rs`, `courier.rs`, `ledger.rs`,
`serve.rs`; `crates/loopd/tests/broker_port.rs`, `tools.rs`, `turn_broker.rs` `serve.rs`; `crates/loopd/tests/broker_port.rs`, `tools.rs`, `turn_broker.rs`. Your
`broker.rs` is the given `broker.rs-task01`: the given `broker.rs` is task 09's.
- Modify: `crates/bxcore/src/wire.rs`, `crates/brokerd/src/args.rs`, - Modify: `crates/bxcore/src/wire.rs`, `crates/brokerd/src/args.rs`,
`crates/brokerd/src/policy/mod.rs`, `crates/brokerd/src/ledger/mod.rs`, `crates/brokerd/src/policy/mod.rs`, `crates/brokerd/src/ledger/mod.rs`,
`crates/brokerd/src/broker.rs`, `crates/loopd/src/tools.rs`, `crates/loopd/src/turn.rs`, `crates/brokerd/src/broker.rs`, `crates/loopd/src/tools.rs`, `crates/loopd/src/turn.rs`,
@@ -489,8 +490,8 @@ The event keeps only the reason: the owner's texts do not change.
crates/brokerd/tests/ crates/brokerd/tests/
cp docs/plans/M5b/files/crates/brokerd/tests/admin.rs \ cp docs/plans/M5b/files/crates/brokerd/tests/admin.rs \
crates/brokerd/tests/ crates/brokerd/tests/
cp docs/plans/M5b/files/crates/brokerd/tests/broker.rs \ cp docs/plans/M5b/files/crates/brokerd/tests/broker.rs-task01 \
crates/brokerd/tests/ crates/brokerd/tests/broker.rs
cp docs/plans/M5b/files/crates/brokerd/tests/broker_pending.rs \ cp docs/plans/M5b/files/crates/brokerd/tests/broker_pending.rs \
crates/brokerd/tests/ crates/brokerd/tests/
cp docs/plans/M5b/files/crates/brokerd/tests/courier.rs \ cp docs/plans/M5b/files/crates/brokerd/tests/courier.rs \
+250
View File
@@ -0,0 +1,250 @@
# M5b task 09: `TurnDone` says whether the session has read untrusted content
**Branch:** `m5b` (run `git switch m5b`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `bxcore, loopd: TurnDone says whether the session has read untrusted content`
## Goal
`gatewayd` will post the links of a reply as code when the reply comes from a session that has
read untrusted content, a fetched page or a recalled memory-tainted note (M5b spec, section 9;
that is task 10). It learns that from `loopd`, in the frame that ends each turn. So `TurnDone`
gains a field (M5b spec, sections 8 and 9):
```json
{"kind":"turn_done","body":{"content":"…","usage":{…},"untrusted":true}}
```
- **`bxcore`**: `TurnDone` gains `untrusted: bool`, last. It is always written, and it is
**required** when read: a frame without it is refused, never read as a reply from a clean
session (see "Facts you need"). The fixture `turn_done.json` changes, and a second one,
`turn_done_untrusted.json`, holds `true`.
- **`loopd`**: a new method, `Session::untrusted`, is true when any tool result in the session's
log is labelled untrusted. `channel::handle` puts it in the `TurnDone`. The log is the one
source: it already holds each tool result with the label `brokerd` gave it, it is read back
whole when a session is reopened, and records are only ever appended, so once a session has
read untrusted content every later `TurnDone` of it says so, also after `loopd` restarts.
- **Unchanged**: every source file of `gatewayd`, `bxctl` and `brokerd`. Only their tests build a
`TurnDone`, and new versions of those are given.
| The session's log holds | `TurnDone.untrusted` |
|---|---|
| no tool result, or only results labelled trusted | `false` |
| a denial, or a failure (they are logged trusted) | `false` |
| a result labelled untrusted, in this turn or any earlier one | `true` |
| a `recall` result with a memory-tainted hit (`brokerd` labels it untrusted) | `true` |
## Every place `TurnDone` appears
This is the complete list. Each one is covered by an edit below or by a given file.
| Place | What happens there |
|---|---|
| `crates/bxcore/src/wire.rs`, the struct | gains `untrusted` (edit W1) |
| `crates/loopd/src/session.rs` | new `Session::untrusted` (edit S1) |
| `crates/loopd/src/channel.rs`, `handle` | sets `untrusted` from the session (edit C1) |
| `crates/gatewayd/src/deliver.rs` | reads `content` only; no change (task 10 reads the flag) |
| `crates/bxctl/src/chat.rs`, `crates/bxctl/src/main.rs` | pass the `TurnDone` on; `--json` prints it whole with `serde_json`, so the new field shows there; no change |
| `crates/brokerd/src/broker.rs` | `Message::TurnDone(_)`, a pattern; no change |
| `crates/loopd/tests/channel.rs`, `memory.rs` | patterns and field reads; no change |
| tests that build a `TurnDone` in `bxcore`, `brokerd`, `bxctl`, `gatewayd` | new versions are given |
## Files
- Copy, from `docs/plans/M5b/files/` (the same path under it):
- new: `crates/bxcore/tests/fixtures/wire/turn_done_untrusted.json`,
`crates/loopd/tests/turn_done_untrusted.rs`
- **new versions** of: `crates/bxcore/tests/fixtures/wire/turn_done.json`,
`crates/bxcore/tests/turn_wire.rs`; `crates/brokerd/tests/broker.rs`;
`crates/bxctl/tests/chat.rs`, `crates/bxctl/tests/support/mod.rs`;
`crates/gatewayd/tests/support/fake_loop.rs` (they replace the files in the tree). Do not copy
`broker.rs-task01`: it is the older version.
- Modify: `crates/bxcore/src/wire.rs` (W1), `crates/loopd/src/session.rs` (S1),
`crates/loopd/src/channel.rs` (C1), `docs/implementer-log.md`.
- Do not touch: any other source file, any `Cargo.toml`, `crates/bxcore/tests/strict.rs`.
## Facts you need
- **Why the field is required.** `serde` treats a `bool` field without `#[serde(default)]` as
required: decoding a `turn_done` without it fails with ``missing field `untrusted` ``. A default
would read a missing field as `false`, "clean", which is the wrong way to fail: a reply from an
untrusted session would keep its links live. `loopd`, `gatewayd` and `bxctl` are built from
this one workspace and deployed together, and `TurnDone` already refuses unknown fields, so an
older reader refuses the new frame anyway. So: no `#[serde(default)]`, no
`skip_serializing_if`. `PROTOCOL_VERSION` does not change (task 01 added a field without
changing it too).
- **Where the labels come from.** `brokerd` labels every result it answers with
(`ToolResponse::Result { untrusted, .. }`): from the grant, and for `recall` also from
`memoryd`, which marks a result holding a memory-tainted hit untrusted; `brokerd` keeps the
stricter of the two. `run_turn` (`crates/loopd/src/turn.rs`, unchanged) logs each tool result
as `LogRecord::ToolResult { untrusted, .. }` with that label. A denial, a failure and
`loopd`'s own tools (`clock`, `find_tool`, `call_tool`'s answers) are logged with
`untrusted: false`, as `brokerd`'s own state does: a failure changes no state.
- `Session::open` reads every record of the log back into `records`, so `Session::untrusted` on a
reopened session sees every earlier turn's results. Every turn after a session's first reopens
it (`resume`), in the same `loopd` or a restarted one.
- In `channel::handle`, `session` is still in scope after `run_turn` returns: `run_turn` borrows
it only for the call. `session.untrusted()` borrows it again, immutably.
- The calls the edits make:
| Call | Signature, and what it gives |
|---|---|
| `Iterator::any` | `self.records.iter().any(f) -> bool`: true when `f` is true for some record |
| `matches!` | `matches!(record, LogRecord::ToolResult { untrusted: true, .. })`: true for a tool result whose field `untrusted` is `true` |
| `Session::untrusted` | `pub fn untrusted(&self) -> bool` (new, S1) |
## The edits, exactly
Nothing else in these files changes. Each quoted "before" block occurs exactly once in its file.
### `crates/bxcore/src/wire.rs`
**W1.** The lines
```rust
// JSON: {"kind":"turn_done","body":{"content":"…","usage":{…}}}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TurnDone {
pub content: String,
pub usage: Usage,
}
```
become
```rust
// JSON: {"kind":"turn_done","body":{"content":"…","usage":{…},"untrusted":false}}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TurnDone {
pub content: String,
pub usage: Usage,
/// True once the session has read untrusted content, in this turn or an earlier one (M5b
/// spec, section 9). It has no default: a frame without it is refused, never read as clean.
pub untrusted: bool,
}
```
### `crates/loopd/src/session.rs`
**S1.** The line
```rust
pub fn last_usage(&self) -> Option<bxcore::Usage> {
```
becomes
```rust
/// True once any tool result in the log is labelled untrusted: a result under an untrusted
/// grant, or a recalled memory-tainted note, which `brokerd` labels so. The labels are in
/// the log, so this holds after the session is reopened, and it never becomes false again.
pub fn untrusted(&self) -> bool {
self.records.iter().any(|record| {
matches!(
record,
LogRecord::ToolResult {
untrusted: true,
..
}
)
})
}
pub fn last_usage(&self) -> Option<bxcore::Usage> {
```
`LogRecord` is imported at the top of the file already.
### `crates/loopd/src/channel.rs`
**C1.** In `handle`, the lines
```rust
msg: Message::TurnDone(TurnDone {
content: outcome.content,
usage: outcome.usage,
}),
```
become
```rust
msg: Message::TurnDone(TurnDone {
content: outcome.content,
usage: outcome.usage,
// The whole log, this turn's results included (M5b spec, section 8).
untrusted: session.untrusted(),
}),
```
## Steps
- [ ] **1. Copy the given files.** From the repository root, eight commands:
```sh
cp docs/plans/M5b/files/crates/bxcore/tests/fixtures/wire/turn_done.json \
crates/bxcore/tests/fixtures/wire/
cp docs/plans/M5b/files/crates/bxcore/tests/fixtures/wire/turn_done_untrusted.json \
crates/bxcore/tests/fixtures/wire/
cp docs/plans/M5b/files/crates/bxcore/tests/turn_wire.rs crates/bxcore/tests/
cp docs/plans/M5b/files/crates/brokerd/tests/broker.rs crates/brokerd/tests/
cp docs/plans/M5b/files/crates/bxctl/tests/chat.rs crates/bxctl/tests/
cp docs/plans/M5b/files/crates/bxctl/tests/support/mod.rs crates/bxctl/tests/support/
cp docs/plans/M5b/files/crates/gatewayd/tests/support/fake_loop.rs \
crates/gatewayd/tests/support/
cp docs/plans/M5b/files/crates/loopd/tests/turn_done_untrusted.rs crates/loopd/tests/
```
Then `git status --short` lists 6 modified files and 2 new ones, all under `crates/*/tests/`.
- [ ] **2. See them fail.** Two commands:
- `cargo test -p bxcore --test strict`: one test fails,
`envelopes_reject_unknown_keys_at_every_depth`, with `turn_done.json: fixture must decode`.
- `cargo build --tests --keep-going -p bxcore -p brokerd -p loopd -p bxctl -p gatewayd`:
thirteen test targets do not compile: `turn_wire` in `bxcore`; `broker` in `brokerd`;
`turn_done_untrusted` in `loopd`; `admin`, `chat` and `chat_approvals` in `bxctl`;
`deliver`, `deliver_notice`, `serve`, `serve_answers`, `serve_approvals`, `serve_redirect`
and `serve_restart` in `gatewayd`. Every error is about the field this task adds: `E0560`
(``struct `bxcore::TurnDone` has no field named `untrusted` ``) or, once, `E0609`
(``no field `untrusted` on type `bxcore::TurnDone` ``).
- [ ] **3. `bxcore`.** Make edit W1. `cargo test -p bxcore`: every suite passes, among them the
seven tests of `turn_wire`.
- [ ] **4. `loopd`.** Make edits S1 and C1. `cargo check -p loopd`: no error. Then
`cargo test -p loopd --test turn_done_untrusted`: 6 passed, and `cargo test -p loopd`: every
suite passes.
- [ ] **5. The other crates.** `cargo test -p brokerd -p bxctl -p gatewayd`: every suite passes.
You changed nothing in them but the copied tests.
- [ ] **6. Check.** `git diff --stat -- crates/gatewayd/src crates/bxctl/src crates/brokerd/src`
prints nothing, and `grep -n 'serde(default' crates/bxcore/src/wire.rs` prints only the line
of `ToolResponse::Denied`'s `detail` (task 01), not one for `untrusted`.
- [ ] **7. Run the gate.** `cargo fmt --all`, then `make gate`, with a shell timeout of 600000 ms.
Expected last line: `gate: ok`, with 1058 tests: 1050 before this task, plus 8 new ones (two in
`bxcore/tests/turn_wire.rs`, six in `loopd/tests/turn_done_untrusted.rs`; the new versions of
the other files keep their counts).
- [ ] **8. Log and commit.** `git status --short` lists the three sources, the six replaced test
files and your log as modified, and `turn_done_untrusted.json` and `turn_done_untrusted.rs` as
new. Stage with
`git add crates/bxcore crates/loopd crates/brokerd crates/bxctl crates/gatewayd docs/implementer-log.md Cargo.lock`,
then commit as `AGENTS.md` says. `Cargo.lock` does not change in this task; the `git add` is
harmless.
## Done when
- `make gate` prints `gate: ok` with 1058 tests.
- `git diff HEAD~1 --stat` lists only `crates/bxcore/src/wire.rs`, `crates/loopd/src/session.rs`,
`crates/loopd/src/channel.rs`, the eight copied files and `docs/implementer-log.md`.
## Stop and report if
- A "before" block of W1, S1 or C1 is not in its file exactly as shown, or is there more than
once.
- Step 2 shows a different result: another test failing in `strict`, other test targets, or an
error of another kind.
- After step 3, 4 or 5, a test fails. Do not change a test or a fixture: stop, and give the
failing tests' names and their messages.
- The fix seems to need a change anywhere else: in `turn.rs`, in `gatewayd`, `bxctl` or `brokerd`
sources, or a default for the field. It does not; stop and say why you think so.
- A test in `memoryd`, `inferproxy` or `toolkit` fails in the gate: check once whether it fails
without your change, then stop and report both results.
- The gate passes with a count other than 1058.
- A comment in an edit does not fit the code under it. Quote it and stop.
@@ -165,6 +165,7 @@ fn every_kind_but_tool_request() -> Vec<Message> {
Message::TurnDone(TurnDone { Message::TurnDone(TurnDone {
content: String::new(), content: String::new(),
usage, usage,
untrusted: false,
}), }),
Message::Approvals(Empty {}), Message::Approvals(Empty {}),
Message::ApprovalList(ApprovalList { items: Vec::new() }), Message::ApprovalList(ApprovalList { items: Vec::new() }),
@@ -0,0 +1,327 @@
//! One `broker.sock` connection: decision, record, run, answer; and every way it fails closed.
//! Do not edit.
#[path = "support/client.rs"]
mod client;
#[path = "support/rig.rs"]
mod rig;
#[path = "support/runtime.rs"]
mod runtime;
#[path = "support/sink.rs"]
mod sink;
#[path = "support/tmp.rs"]
mod tmp;
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::UnixStream;
use std::sync::Arc;
use brokerd::broker;
use brokerd::ledger::NOT_RECORDED;
use brokerd::runner::{REFUSING, RunError};
use bxcore::{
ApprovalList, Approve, ApproveResult, AuditEvent, DataClass, DecisionRecord, DenyReason, Empty,
Envelope, ErrorCode, GrantsReport, Message, Refuse, ResultStatus, SessionId, ToolResponse,
Turn, TurnDone, TurnEvent, Usage, WireError,
};
use client::{Serve, call, next, open};
use rig::{Rig, grant_text, request};
use runtime::Recording;
const NOTES: &str = r#"{"path":"/n/a"}"#;
fn read_notes(call_id: u64) -> bxcore::ToolRequest {
request("s1", call_id, "read_file", NOTES)
}
fn only(frames: &[Envelope]) -> &ToolResponse {
assert_eq!(frames.len(), 1, "{frames:?}");
assert!(frames[0].r#final);
match &frames[0].msg {
Message::ToolResponse(r) => r,
other => panic!("{other:?}"),
}
}
fn denied(reason: DenyReason) -> ToolResponse {
ToolResponse::Denied {
reason,
detail: None,
}
}
fn auto_notes(rig: &Rig, class: &str) {
let text = grant_text("read_file", "auto", "", "paths = [\"/n\"]").replace(
"result_class = \"private\"",
&format!("result_class = \"{class}\""),
);
rig.grant("notes", &text);
}
#[test]
fn a_call_no_grant_allows_is_denied_and_recorded() {
let rig = Rig::new("broker-nogrant");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let frames = call(&broker, read_notes(41));
assert_eq!(frames[0].id, 41, "the answer carries the request's id");
assert_eq!(only(&frames), &denied(DenyReason::NoGrant));
assert_eq!(rt.count(), 0);
match rig.events().as_slice() {
[AuditEvent::Decision { outcome, .. }] => assert_eq!(
*outcome,
DecisionRecord::Denied {
reason: DenyReason::NoGrant
}
),
other => panic!("{other:?}"),
}
}
#[test]
fn an_allowed_call_runs_and_its_result_raises_the_state() {
let rig = Rig::new("broker-allowed");
auto_notes(&rig, "secret");
let rt = Recording::answering("the notes");
let broker = rig.broker(&rt);
let frames = call(&broker, read_notes(7));
assert_eq!(
only(&frames),
&ToolResponse::Result {
content: "the notes".to_string(),
class: DataClass::Secret,
untrusted: false,
truncated: false,
}
);
assert_eq!(rt.count(), 1);
let events = rig.events();
assert!(matches!(
events.as_slice(),
[
AuditEvent::Decision { .. },
AuditEvent::Result {
decision: 0,
status: ResultStatus::Result,
taint_after: DataClass::Secret,
..
}
]
));
let state = rig.state().read(&SessionId::new("s1").unwrap()).unwrap();
assert_eq!(state.taint, DataClass::Secret);
}
#[test]
fn a_runtime_failure_is_passed_on_and_recorded_as_failed() {
let rig = Rig::new("broker-refusing");
auto_notes(&rig, "secret");
let rt = Recording::with(Err(RunError::Unavailable(REFUSING.to_string())));
let broker = rig.broker(&rt);
let frames = call(&broker, read_notes(1));
assert_eq!(
only(&frames),
&ToolResponse::Failed {
message: REFUSING.to_string()
}
);
match &rig.events()[1] {
AuditEvent::Result {
status,
taint_after,
..
} => assert_eq!(
(*status, *taint_after),
(ResultStatus::Failed, DataClass::Private)
),
other => panic!("{other:?}"),
}
assert!(!rig.state_file("s1").exists(), "a failure changes no state");
}
fn every_kind_but_tool_request() -> Vec<Message> {
let usage = Usage {
cache_n: 0,
prompt_n: 0,
predicted_n: 0,
reasoning_tokens: 0,
thinking_capped: false,
};
vec![
Message::ToolResponse(denied(DenyReason::NoGrant)),
Message::Error(WireError {
code: ErrorCode::Internal,
detail: String::new(),
}),
Message::Turn(Turn {
session: SessionId::new("s1").unwrap(),
content: String::new(),
resume: false,
}),
Message::TurnEvent(TurnEvent::Content {
text: String::new(),
}),
Message::TurnDone(TurnDone {
content: String::new(),
usage,
}),
Message::Approvals(Empty {}),
Message::ApprovalList(ApprovalList { items: Vec::new() }),
Message::Approve(Approve { approval: 0 }),
Message::ApproveResult(ApproveResult {
outcome: DecisionRecord::Allowed {},
}),
Message::Refuse(Refuse {
approval: 0,
reason: None,
}),
Message::Ok(Empty {}),
Message::CheckGrants(Empty {}),
Message::GrantsReport(GrantsReport {
problems: Vec::new(),
}),
]
}
#[test]
fn every_other_kind_on_broker_sock_is_forbidden() {
let rig = Rig::new("broker-forbidden");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let kinds = every_kind_but_tool_request();
assert_eq!(kinds.len(), 13, "every Message variant but tool_request");
for (i, msg) in kinds.into_iter().enumerate() {
let id = 100 + i as u64;
let mut stream = open(&broker, broker::handle, id, msg);
let env = next(&mut stream);
assert_eq!((env.id, env.r#final), (id, true));
match env.msg {
Message::Error(e) => assert_eq!(e.code, ErrorCode::Forbidden),
other => panic!("{other:?}"),
}
}
let lines = rig.lines.with("broker.sock");
assert_eq!(lines.len(), 13, "{:?}", rig.lines.all());
for line in &lines {
assert!(
line.ends_with("\nsee docs/runbook.md#socket-forbidden"),
"{line}"
);
}
assert_eq!(rig.lines.with("kind approve on broker.sock").len(), 1);
assert!(
rig.events().is_empty(),
"nothing is recorded for a refused kind"
);
}
#[test]
fn a_frame_that_is_not_json_is_answered_with_bad_message() {
let rig = Rig::new("broker-badframe");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let (mut client, server) = UnixStream::pair().unwrap();
let b = Arc::clone(&broker);
std::thread::spawn(move || broker::handle(server, &b));
client.write_all(&3u32.to_be_bytes()).unwrap();
client.write_all(b"{{{").unwrap();
let env = next(&mut client);
assert_eq!((env.id, env.r#final), (0, true));
match env.msg {
Message::Error(e) => assert_eq!(e.code, ErrorCode::BadMessage),
other => panic!("{other:?}"),
}
assert!(rig.events().is_empty());
}
#[test]
fn one_invalid_grant_file_denies_a_call_a_valid_file_would_allow() {
let rig = Rig::new("broker-invalid");
auto_notes(&rig, "private");
rig.grant("bad", "tool = \"read_file\"\nmdoe = \"auto\"\n");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
for id in [1, 2] {
let frames = call(&broker, read_notes(id));
assert_eq!(only(&frames), &denied(DenyReason::GrantsInvalid));
}
assert_eq!(rt.count(), 0);
// Printed once for the two calls, with the pointer.
let printed = rig.lines.with("bad.toml");
assert_eq!(printed.len(), 1, "{:?}", rig.lines.all());
assert!(
printed[0].ends_with("see docs/runbook.md#grants-invalid"),
"{}",
printed[0]
);
rig.remove_grant("bad");
let frames = call(&broker, read_notes(3));
assert!(matches!(only(&frames), ToolResponse::Result { .. }));
assert_eq!(rt.count(), 1);
}
#[test]
fn a_damaged_session_state_is_state_unreadable() {
let rig = Rig::new("broker-state");
auto_notes(&rig, "private");
std::fs::create_dir_all(rig.cfg.state_dir()).unwrap();
std::fs::write(rig.state_file("s1"), "{\"taint\":\"loud\"}").unwrap();
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
let frames = call(&broker, read_notes(1));
assert_eq!(only(&frames), &denied(DenyReason::StateUnreadable));
assert_eq!(rt.count(), 0);
let lines = rig.lines.with("s1.json");
assert!(
lines
.iter()
.any(|l| l.ends_with("see docs/runbook.md#broker-state-damaged")),
"{lines:?}"
);
}
#[test]
fn a_failed_audit_write_runs_nothing_now_or_later() {
let rig = Rig::new("broker-audit");
auto_notes(&rig, "private");
let rt = Recording::answering("x");
let broker = rig.broker(&rt);
rig.switch.fail(true);
let frames = call(&broker, read_notes(1));
assert_eq!(only(&frames), &denied(DenyReason::AuditUnavailable));
rig.switch.fail(false);
let frames = call(&broker, read_notes(2));
assert_eq!(only(&frames), &denied(DenyReason::AuditUnavailable));
assert_eq!(rt.count(), 0);
assert!(
!rig.lines
.with("see docs/runbook.md#audit-unavailable")
.is_empty()
);
}
#[test]
fn a_state_that_cannot_be_written_withholds_the_content() {
if tmp::running_as_root("a_state_that_cannot_be_written_withholds_the_content") {
return;
}
let rig = Rig::new("broker-ro");
auto_notes(&rig, "secret");
let dir = rig.cfg.state_dir();
std::fs::create_dir_all(&dir).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap();
let rt = Recording::answering("the secret");
let broker = rig.broker(&rt);
let frames = call(&broker, read_notes(1));
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
assert_eq!(
only(&frames),
&ToolResponse::Failed {
message: NOT_RECORDED.to_string()
}
);
assert_eq!(rt.count(), 1, "it ran; its content is what is withheld");
let text = format!("{frames:?}");
assert!(!text.contains("the secret"), "{text}");
}
@@ -0,0 +1 @@
{"v":1,"id":3,"final":true,"msg":{"kind":"turn_done","body":{"content":"It is noon.","usage":{"cache_n":539,"prompt_n":27,"predicted_n":24,"reasoning_tokens":8,"thinking_capped":false},"untrusted":false}}}
@@ -0,0 +1 @@
{"v":1,"id":4,"final":true,"msg":{"kind":"turn_done","body":{"content":"The page says to run the script.","usage":{"cache_n":566,"prompt_n":412,"predicted_n":31,"reasoning_tokens":12,"thinking_capped":false},"untrusted":true}}}
@@ -0,0 +1,320 @@
//! Tests for the channel messages and the usage record, against byte-exact fixtures. Do not edit.
use bxcore::{
CallId, DataClass, DenyReason, Envelope, Epoch, ErrorCode, Hash32, LogRecord, Message,
SessionId, Timestamp, ToolCall, Turn, TurnDone, TurnEvent, Usage, WireError,
};
fn fixture(kind: &str, name: &str) -> String {
let path = format!(
"{}/tests/fixtures/{kind}/{name}",
env!("CARGO_MANIFEST_DIR")
);
std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("{path}: {e}"))
.trim_end_matches('\n')
.to_string()
}
fn check(name: &str, want: Envelope) {
let text = fixture("wire", name);
let got: Envelope = serde_json::from_str(&text).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(got, want, "{name}: decoded value");
assert_eq!(
serde_json::to_string(&want).unwrap(),
text,
"{name}: encoded bytes"
);
}
fn env(id: u64, r#final: bool, msg: Message) -> Envelope {
Envelope {
v: 1,
id,
r#final,
msg,
}
}
fn usage() -> Usage {
Usage {
cache_n: 539,
prompt_n: 27,
predicted_n: 24,
reasoning_tokens: 8,
thinking_capped: false,
}
}
#[test]
fn turn() {
let body = Turn {
session: SessionId::new("chat-1789700000-42").unwrap(),
content: "What time is it?".to_string(),
resume: false,
};
check("turn.json", env(3, true, Message::Turn(body)));
}
#[test]
fn turn_events() {
check(
"turn_event_tool_result.json",
env(
3,
false,
Message::TurnEvent(TurnEvent::ToolResult {
name: "clock".to_string(),
class: DataClass::Public,
truncated: false,
}),
),
);
check(
"turn_event_content.json",
env(
3,
false,
Message::TurnEvent(TurnEvent::Content {
text: "It is ".to_string(),
}),
),
);
check(
"turn_event_retrying.json",
env(
3,
false,
Message::TurnEvent(TurnEvent::Retrying {
attempt: 2,
after_ms: 1500,
error: "the server went silent".to_string(),
}),
),
);
check(
"turn_event_approval_pending.json",
env(
3,
false,
Message::TurnEvent(TurnEvent::ApprovalPending {
approval: 41,
tool: "shell".to_string(),
expires: Timestamp::parse("2026-09-18T08:20:00.000Z").unwrap(),
}),
),
);
check(
"turn_event_tool_denied.json",
env(
3,
false,
Message::TurnEvent(TurnEvent::ToolDenied {
name: "read_file".to_string(),
reason: DenyReason::NoGrant,
}),
),
);
}
#[test]
fn turn_done_and_the_new_error_codes() {
check(
"turn_done.json",
env(
3,
true,
Message::TurnDone(TurnDone {
content: "It is noon.".to_string(),
usage: usage(),
untrusted: false,
}),
),
);
let body = WireError {
code: ErrorCode::SessionFull,
detail: "this conversation is full; start a new one".to_string(),
};
check(
"error_session_full.json",
env(3, true, Message::Error(body)),
);
let codes = [
(ErrorCode::SessionFull, "session_full"),
(ErrorCode::TurnLimit, "turn_limit"),
(ErrorCode::SessionBusy, "session_busy"),
(ErrorCode::NoSuchSession, "no_such_session"),
(ErrorCode::SessionExists, "session_exists"),
(ErrorCode::Inference, "inference"),
];
for (value, text) in codes {
assert_eq!(
serde_json::to_string(&value).unwrap(),
format!("\"{text}\"")
);
}
}
/// A reply from a session that has read untrusted content says so, and `untrusted` is written
/// whether it is true or false (M5b spec, section 9).
#[test]
fn turn_done_from_an_untrusted_session() {
check(
"turn_done_untrusted.json",
env(
4,
true,
Message::TurnDone(TurnDone {
content: "The page says to run the script.".to_string(),
usage: Usage {
cache_n: 566,
prompt_n: 412,
predicted_n: 31,
reasoning_tokens: 12,
thinking_capped: false,
},
untrusted: true,
}),
),
);
}
/// `untrusted` has no default: a `turn_done` without it, as a `loopd` from before M5b sent, is
/// refused, never read as a reply from a clean session.
#[test]
fn a_turn_done_without_untrusted_is_refused() {
let old = r#"{"v":1,"id":3,"final":true,"msg":{"kind":"turn_done","body":{"content":"It is noon.","usage":{"cache_n":539,"prompt_n":27,"predicted_n":24,"reasoning_tokens":8,"thinking_capped":false}}}}"#;
let error = serde_json::from_str::<Envelope>(old).unwrap_err();
assert!(
error.to_string().contains("missing field `untrusted`"),
"{error}"
);
}
#[test]
fn every_turn_event_kind_round_trips() {
let all = vec![
TurnEvent::Queued { ahead: 1 },
TurnEvent::Waiting { slot_busy: true },
TurnEvent::Progress {
total: 100,
cache: 50,
processed: 75,
},
TurnEvent::Reasoning {
text: "hm".to_string(),
},
TurnEvent::Content {
text: "hi".to_string(),
},
TurnEvent::ToolCallStarted {
name: "clock".to_string(),
},
TurnEvent::ToolResult {
name: "clock".to_string(),
class: DataClass::Secret,
truncated: true,
},
TurnEvent::ThinkingCapped { tokens: 4096 },
TurnEvent::Retrying {
attempt: 2,
after_ms: 10,
error: "x".to_string(),
},
TurnEvent::CacheLoss {
expected: 500,
got: 20,
},
TurnEvent::ApprovalPending {
approval: u64::MAX,
tool: "http_fetch".to_string(),
expires: Timestamp::parse("2026-09-18T08:20:00.000Z").unwrap(),
},
TurnEvent::ToolDenied {
name: "shell".to_string(),
reason: DenyReason::StateUnreadable,
},
];
for event in all {
let text = serde_json::to_string(&event).unwrap();
assert!(text.starts_with("{\"event\":\""), "{text}");
assert_eq!(serde_json::from_str::<TurnEvent>(&text).unwrap(), event);
}
assert!(serde_json::from_str::<TurnEvent>(r#"{"event":"content","text":"x","zz":1}"#).is_err());
assert!(serde_json::from_str::<TurnEvent>(r#"{"event":"dance"}"#).is_err());
}
#[test]
fn usage_records() {
let text = fixture("records", "session_usage.jsonl");
let lines: Vec<&str> = text.lines().collect();
let want = [
LogRecord::SessionStart {
time: Timestamp::parse("2026-09-18T08:05:00.000Z").unwrap(),
session: SessionId::new("chat-1789700000-42").unwrap(),
epoch: Epoch(0),
slot: 0,
baseline: Hash32::from_hex(
"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
)
.unwrap(),
},
LogRecord::User {
time: Timestamp::parse("2026-09-18T08:05:01.000Z").unwrap(),
content: "What time is it?".to_string(),
},
LogRecord::Assistant {
time: Timestamp::parse("2026-09-18T08:05:03.000Z").unwrap(),
content: None,
reasoning_content: None,
tool_calls: vec![ToolCall {
id: "call_a1".to_string(),
name: "clock".to_string(),
arguments: "{}".to_string(),
}],
},
LogRecord::Usage {
time: Timestamp::parse("2026-09-18T08:05:03.000Z").unwrap(),
cache_n: 539,
prompt_n: 27,
predicted_n: 24,
reasoning_tokens: 8,
thinking_capped: false,
},
LogRecord::ToolResult {
time: Timestamp::parse("2026-09-18T08:05:03.100Z").unwrap(),
call: CallId(1),
tool_call_id: "call_a1".to_string(),
content: "2026-09-18T08:05:03.000Z".to_string(),
class: DataClass::Public,
untrusted: false,
truncated: false,
},
LogRecord::Assistant {
time: Timestamp::parse("2026-09-18T08:05:05.000Z").unwrap(),
content: Some("It is five past eight.".to_string()),
reasoning_content: None,
tool_calls: vec![],
},
LogRecord::Usage {
time: Timestamp::parse("2026-09-18T08:05:05.000Z").unwrap(),
cache_n: 589,
prompt_n: 40,
predicted_n: 64,
reasoning_tokens: 27,
thinking_capped: true,
},
];
assert_eq!(lines.len(), want.len());
for (i, (line, want)) in lines.iter().zip(&want).enumerate() {
let got: LogRecord =
serde_json::from_str(line).unwrap_or_else(|e| panic!("line {}: {e}", i + 1));
assert_eq!(&got, want, "line {}", i + 1);
assert_eq!(
&serde_json::to_string(want).unwrap(),
line,
"line {}: bytes",
i + 1
);
}
}
@@ -0,0 +1,469 @@
//! Tests for `bxctl chat`, against a fake `loopd` that speaks the channel protocol. Do not edit.
#[path = "support/temp.rs"]
mod temp;
use bxcore::{
DataClass, Envelope, ErrorCode, Message, PROTOCOL_VERSION, SessionId, Turn, TurnDone,
TurnEvent, Usage, WireError, read_frame, write_frame,
};
use bxctl::chat::{ChatError, Printer, new_session_id, run_turn};
use std::os::unix::net::UnixListener;
use std::path::PathBuf;
use std::process::Command;
use std::sync::{Arc, Mutex};
use std::thread;
/// What the fake `loopd` sends back for one turn, after the events: done or an error.
#[derive(Clone)]
enum End {
Done(TurnDone),
Error(ErrorCode, &'static str),
}
struct FakeLoopd {
socket: PathBuf,
turns: Arc<Mutex<Vec<Turn>>>,
_dir: temp::TempDir,
}
fn usage() -> Usage {
Usage {
cache_n: 10,
prompt_n: 5,
predicted_n: 7,
reasoning_tokens: 3,
thinking_capped: false,
}
}
/// Serves every connection with the same script: the given events, then `end`. Records the
/// turns it received.
fn fake_loopd(events: Vec<TurnEvent>, end: End) -> FakeLoopd {
let dir = temp::TempDir::new("bxctl-test");
let socket = dir.join("loop.sock");
let listener = UnixListener::bind(&socket).unwrap();
let turns = Arc::new(Mutex::new(Vec::new()));
let seen = Arc::clone(&turns);
thread::spawn(move || {
for stream in listener.incoming() {
let mut stream = stream.unwrap();
let request = read_frame(&mut stream).unwrap();
let Message::Turn(turn) = request.msg else {
panic!("not a turn")
};
seen.lock().unwrap().push(turn.clone());
let id = request.id;
let end = if turn.resume && turn.content == "trigger-no-such-session" {
End::Error(ErrorCode::NoSuchSession, "session x does not exist")
} else if turn.content == "trigger-turn-limit" {
End::Error(ErrorCode::TurnLimit, "the turn hit a limit")
} else {
end.clone()
};
for e in &events {
write_frame(
&mut stream,
&Envelope {
v: PROTOCOL_VERSION,
id,
r#final: false,
msg: Message::TurnEvent(e.clone()),
},
)
.unwrap();
}
let last = match end {
End::Done(done) => Message::TurnDone(done),
End::Error(code, detail) => Message::Error(WireError {
code,
detail: detail.to_string(),
}),
};
write_frame(
&mut stream,
&Envelope {
v: PROTOCOL_VERSION,
id,
r#final: true,
msg: last,
},
)
.unwrap();
}
});
FakeLoopd {
socket,
turns,
_dir: dir,
}
}
fn events() -> Vec<TurnEvent> {
vec![
TurnEvent::Queued { ahead: 1 },
TurnEvent::Waiting { slot_busy: true },
TurnEvent::Progress {
total: 100,
cache: 50,
processed: 75,
},
TurnEvent::Reasoning {
text: "let me ".to_string(),
},
TurnEvent::Reasoning {
text: "think".to_string(),
},
TurnEvent::ToolCallStarted {
name: "clock".to_string(),
},
TurnEvent::ToolResult {
name: "clock".to_string(),
class: DataClass::Public,
truncated: true,
},
TurnEvent::ThinkingCapped { tokens: 4096 },
TurnEvent::Retrying {
attempt: 2,
after_ms: 1500,
error: "the server went silent".to_string(),
},
TurnEvent::CacheLoss {
expected: 500,
got: 20,
},
TurnEvent::Content {
text: "It is ".to_string(),
},
TurnEvent::Content {
text: "noon.".to_string(),
},
]
}
fn done() -> End {
End::Done(TurnDone {
content: "It is noon.".to_string(),
usage: usage(),
untrusted: false,
})
}
fn id(s: &str) -> SessionId {
SessionId::new(s).unwrap()
}
#[test]
fn run_turn_delivers_every_event_in_order_then_the_answer() {
let fake = fake_loopd(events(), done());
let mut seen = Vec::new();
let done = run_turn(
&fake.socket,
&id("s1"),
"what time is it?",
false,
&mut |e| seen.push(e.clone()),
)
.unwrap();
assert_eq!(seen, events());
assert_eq!(done.content, "It is noon.");
assert_eq!(done.usage, usage());
let turns = fake.turns.lock().unwrap();
assert_eq!(turns.len(), 1);
assert_eq!(
turns[0],
Turn {
session: id("s1"),
content: "what time is it?".to_string(),
resume: false
}
);
}
#[test]
fn an_error_frame_is_refused_with_its_code_and_detail() {
let fake = fake_loopd(
vec![TurnEvent::Content {
text: "partial".to_string(),
}],
End::Error(ErrorCode::SessionFull, "this conversation is full"),
);
let mut seen = 0;
let err = run_turn(&fake.socket, &id("s1"), "x", true, &mut |_| seen += 1).unwrap_err();
assert_eq!(seen, 1, "events before the error are still delivered");
match err {
ChatError::Refused(w) => {
assert_eq!(w.code, ErrorCode::SessionFull);
assert_eq!(w.detail, "this conversation is full");
}
other => panic!("{other:?}"),
}
let e: Box<dyn std::error::Error> =
Box::new(run_turn(&fake.socket, &id("s1"), "x", true, &mut |_| {}).unwrap_err());
assert!(e.to_string().contains("session full"), "{e}");
}
#[test]
fn no_loopd_is_a_connect_error() {
let missing = std::env::temp_dir().join("bxctl-no-such-loopd.sock");
assert!(matches!(
run_turn(&missing, &id("s1"), "x", false, &mut |_| {}),
Err(ChatError::Connect(_))
));
}
#[test]
fn new_session_ids_are_valid_and_distinct() {
let a = new_session_id();
let b = new_session_id();
assert!(a.as_str().starts_with("chat-"));
assert_ne!(a, b);
}
#[test]
fn the_printer_formats_each_event_kind() {
let mut out = Vec::new();
let mut p = Printer::new(true, false);
for e in events() {
p.event(&mut out, &e).unwrap();
}
p.end_reasoning(&mut out).unwrap();
let text = String::from_utf8(out).unwrap();
assert!(
text.contains("\x1b[2mlet me think\x1b[0m\n"),
"reasoning dimmed, joined, and ended once: {text:?}"
);
assert!(text.contains("[tool clock]\n"), "{text:?}");
assert!(text.contains("[clock: Public, truncated]\n"), "{text:?}");
assert!(text.contains("[waiting: slot busy]\n"), "{text:?}");
assert!(
text.contains("[retrying: attempt 2 in 1500 ms: the server went silent]\n"),
"{text:?}"
);
assert!(
text.contains("[thinking capped at 4096 tokens]\n"),
"{text:?}"
);
assert!(text.contains("[cache loss: 20 of 500]\n"), "{text:?}");
assert!(
text.ends_with("It is noon."),
"content streams as it is: {text:?}"
);
assert!(
!text.contains("Queued") && !text.contains("Progress"),
"queued and progress are silent: {text:?}"
);
let mut out = Vec::new();
let mut p = Printer::new(false, false);
for e in events() {
p.event(&mut out, &e).unwrap();
}
let text = String::from_utf8(out).unwrap();
assert!(
!text.contains("let me"),
"--no-thinking hides reasoning: {text:?}"
);
assert!(
!text.contains("\x1b["),
"and no escape codes are left: {text:?}"
);
let mut out = Vec::new();
let mut p = Printer::new(true, true);
for e in events() {
p.event(&mut out, &e).unwrap();
}
let text = String::from_utf8(out).unwrap();
let lines: Vec<&str> = text.lines().collect();
assert_eq!(
lines.len(),
events().len(),
"--json: one line per event, none skipped"
);
let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(first, serde_json::json!({"event": "queued", "ahead": 1}));
}
#[test]
fn say_prints_only_the_answer_on_stdout() {
let fake = fake_loopd(events(), done());
let output = Command::new(env!("CARGO_BIN_EXE_bxctl"))
.args(["chat", "--socket"])
.arg(&fake.socket)
.args(["--session", "scripted-1", "--say", "what time is it?"])
.output()
.unwrap();
assert!(output.status.success());
assert_eq!(String::from_utf8_lossy(&output.stdout), "It is noon.\n");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("[tool clock]"),
"events go to stderr: {stderr}"
);
let turns = fake.turns.lock().unwrap();
assert_eq!(turns.len(), 1);
assert_eq!(turns[0].session, id("scripted-1"));
assert!(
turns[0].resume,
"a session given on the command line is resumed"
);
}
#[test]
fn say_creates_a_named_session_that_does_not_exist_yet() {
let fake = fake_loopd(vec![], done());
let output = Command::new(env!("CARGO_BIN_EXE_bxctl"))
.args(["chat", "--socket"])
.arg(&fake.socket)
.args(["--session", "fresh", "--say", "trigger-no-such-session"])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let turns = fake.turns.lock().unwrap();
assert_eq!(turns.len(), 2, "resume was refused, so it was created");
assert!(turns[0].resume && !turns[1].resume);
}
#[test]
fn say_reports_an_error_on_stderr_with_status_1() {
let fake = fake_loopd(
vec![],
End::Error(ErrorCode::TurnLimit, "the turn hit a limit"),
);
let output = Command::new(env!("CARGO_BIN_EXE_bxctl"))
.args(["chat", "--socket"])
.arg(&fake.socket)
.args(["--say", "x"])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(1));
assert_eq!(String::from_utf8_lossy(&output.stdout), "");
assert!(String::from_utf8_lossy(&output.stderr).contains("turn limit: the turn hit a limit"));
}
#[test]
fn json_mode_prints_frames_as_json_lines() {
let fake = fake_loopd(events(), done());
let output = Command::new(env!("CARGO_BIN_EXE_bxctl"))
.args(["chat", "--socket"])
.arg(&fake.socket)
.args(["--session", "j", "--json", "--say", "x"])
.output()
.unwrap();
assert!(output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
let lines: Vec<&str> = stderr.lines().collect();
assert_eq!(
lines.len(),
events().len() + 1,
"every event, then the done frame: {stderr}"
);
let last: serde_json::Value = serde_json::from_str(lines[lines.len() - 1]).unwrap();
assert_eq!(last["content"], "It is noon.");
assert_eq!(last["usage"]["cache_n"], 10);
assert_eq!(last["untrusted"], false, "the done frame is printed whole");
}
#[test]
fn interactive_mode_reads_lines_until_quit() {
let fake = fake_loopd(
vec![TurnEvent::Content {
text: "ok".to_string(),
}],
End::Done(TurnDone {
content: "ok".to_string(),
usage: usage(),
untrusted: false,
}),
);
let mut child = Command::new(env!("CARGO_BIN_EXE_bxctl"))
.args(["chat", "--socket"])
.arg(&fake.socket)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
{
let mut stdin = child.stdin.take().unwrap();
std::io::Write::write_all(&mut stdin, b"first\n\nsecond\n/quit\nnever sent\n").unwrap();
}
let output = child.wait_with_output().unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.starts_with("session chat-"), "{stdout}");
assert_eq!(stdout.matches("ok").count(), 2, "{stdout}");
let turns = fake.turns.lock().unwrap();
assert_eq!(
turns.len(),
2,
"a blank line sends nothing, and /quit stops"
);
assert!(
!turns[0].resume && turns[1].resume,
"the first turn creates, the second resumes"
);
assert_eq!(turns[0].session, turns[1].session);
}
#[test]
fn bad_arguments_print_usage() {
let output = Command::new(env!("CARGO_BIN_EXE_bxctl"))
.args(["chat", "--session", "Not Valid!"])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(2));
assert!(String::from_utf8_lossy(&output.stderr).contains("usage"));
let output = Command::new(env!("CARGO_BIN_EXE_bxctl"))
.args(["dance"])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(2));
}
/// A failed turn is reported, and the conversation goes on: the session still exists and the
/// next line is a new turn on it.
#[test]
fn interactive_mode_survives_a_failed_turn() {
let fake = fake_loopd(
vec![TurnEvent::Content {
text: "ok".to_string(),
}],
End::Done(TurnDone {
content: "ok".to_string(),
usage: usage(),
untrusted: false,
}),
);
let mut child = Command::new(env!("CARGO_BIN_EXE_bxctl"))
.args(["chat", "--socket"])
.arg(&fake.socket)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
{
let mut stdin = child.stdin.take().unwrap();
std::io::Write::write_all(&mut stdin, b"first\ntrigger-turn-limit\nthird\n/quit\n")
.unwrap();
}
let output = child.wait_with_output().unwrap();
assert!(
output.status.success(),
"a failed turn does not end the chat"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("turn limit: the turn hit a limit"),
"{stderr}"
);
let turns = fake.turns.lock().unwrap();
assert_eq!(turns.len(), 3, "the turn after the failure was sent");
assert!(turns[2].resume, "and it resumed the same session");
assert_eq!(turns[2].session, turns[0].session);
}
@@ -0,0 +1,213 @@
//! 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 bxcore::{
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, which is removed when the value is dropped
/// (tip T44).
pub struct TempSocket {
dir: PathBuf,
path: PathBuf,
}
impl std::ops::Deref for TempSocket {
type Target = std::path::Path;
fn deref(&self) -> &std::path::Path {
&self.path
}
}
impl AsRef<std::path::Path> for TempSocket {
fn as_ref(&self) -> &std::path::Path {
&self.path
}
}
impl Drop for TempSocket {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
pub fn temp_socket(name: &str) -> TempSocket {
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();
let path = dir.join(name);
TempSocket { dir, path }
}
pub fn ts(text: &str) -> Timestamp {
Timestamp::parse(text).unwrap()
}
pub struct FakeBrokerd {
pub socket: TempSocket,
/// 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: bxcore::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(bxcore::ApproveResult {
outcome: outcome.clone(),
})
}
Message::Refuse(r) if known(r.approval) => Message::Ok(bxcore::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: TempSocket,
/// 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(),
untrusted: false,
}),
));
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,102 @@
//! A fake `loopd` on a Unix socket: it records each turn and answers with the frames the test's
//! function gives for it. Do not edit.
#![allow(dead_code)] // each test file uses a different part of this module
use std::io::Write;
use std::os::unix::net::UnixListener;
use std::path::Path;
use std::sync::mpsc;
use bxcore::{
Envelope, ErrorCode, Message, PROTOCOL_VERSION, Turn, TurnDone, TurnEvent, Usage, WireError,
read_frame, write_frame,
};
const USAGE: Usage = Usage {
cache_n: 0,
prompt_n: 1,
predicted_n: 1,
reasoning_tokens: 0,
thinking_capped: false,
};
/// What the fake sends back for a turn.
pub enum Reply {
Frame(Envelope),
/// Raw bytes, for broken frames.
Bytes(Vec<u8>),
/// Stop answering this connection (the caller sees it close).
Close,
}
pub fn event(e: TurnEvent) -> Reply {
Reply::Frame(Envelope {
v: PROTOCOL_VERSION,
id: 1,
r#final: false,
msg: Message::TurnEvent(e),
})
}
pub fn done(content: &str) -> Reply {
let msg = Message::TurnDone(TurnDone {
content: content.to_string(),
usage: USAGE,
untrusted: false,
});
Reply::Frame(Envelope {
v: PROTOCOL_VERSION,
id: 1,
r#final: true,
msg,
})
}
pub fn error(code: ErrorCode, detail: &str) -> Reply {
let msg = Message::Error(WireError {
code,
detail: detail.to_string(),
});
Reply::Frame(Envelope {
v: PROTOCOL_VERSION,
id: 1,
r#final: true,
msg,
})
}
/// Listen on `socket`; `script(n, &turn)` gives the replies to the n-th turn (from 0).
pub fn serve_loop<F>(socket: &Path, script: F) -> mpsc::Receiver<Turn>
where
F: Fn(usize, &Turn) -> Vec<Reply> + Send + 'static,
{
let listener = UnixListener::bind(socket).unwrap();
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
for (n, stream) in listener.incoming().enumerate() {
let Ok(mut stream) = stream else { continue };
let Ok(Envelope {
msg: Message::Turn(turn),
..
}) = read_frame(&mut stream)
else {
continue;
};
// Reported before the script runs: a script may wait for the test.
let _ = tx.send(turn.clone());
let replies = script(n, &turn);
for reply in replies {
let ok = match reply {
Reply::Frame(env) => write_frame(&mut stream, &env).is_ok(),
Reply::Bytes(b) => stream.write_all(&b).is_ok(),
Reply::Close => false,
};
if !ok {
break;
}
}
}
});
rx
}
@@ -0,0 +1,219 @@
//! `TurnDone.untrusted` follows the session's labels (M5b spec, sections 8 and 9): false while
//! every tool result in the session's log is labelled trusted, and true from the turn in which one
//! is labelled untrusted (a fetched page, or a recalled memory-tainted note) and in every turn
//! after it, also after `loopd` restarts. A denial or a failure is never untrusted. Do not edit.
mod support;
#[path = "support/tags.rs"]
mod tags;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use bxcore::{
DataClass, DenyReason, Envelope, Message, PROTOCOL_VERSION, SessionId, ToolResponse, Turn,
read_frame, write_frame,
};
use loopd::channel::{Context, serve};
use loopd::llama::Client;
use loopd::memory::NoMemory;
use loopd::tools::Registry;
use support::{FakeServer, Home, Reply, ScriptedPort};
use tags::{call, stream};
const CHAT: &str = "/v1/chat/completions";
/// A fake inference server and a home that one `loopd` or several, one after the other, serve
/// from.
struct Setup {
server: FakeServer,
// Declared last so it is dropped last: fields drop in order, and the home must outlive
// everything that may still write into it (tip T44).
home: Home,
}
impl Setup {
/// The server answers the completions in `chat` in order; the last one repeats.
fn new(chat: Vec<Reply>) -> Setup {
let home = Home::new();
let server = FakeServer::start();
server.route(CHAT, chat);
Setup { server, home }
}
/// Starts a channel server on the home, as a new `loopd` process would: its own context and
/// tool port, nothing carried over but the files. `name` names its socket; the port answers
/// the tool calls with `replies`, in order.
fn loopd(&self, name: &str, replies: Vec<ToolResponse>) -> PathBuf {
let mut cfg = self.home.config(&self.server.socket);
cfg.channel.socket = self.home.dir.join(name);
let socket = cfg.channel_socket();
let listener = UnixListener::bind(&socket).unwrap();
let ctx = Arc::new(Context::new(
cfg.clone(),
Client::new(cfg),
Box::new(ScriptedPort::new(replies)),
Registry::m2b(),
Box::new(NoMemory),
));
thread::spawn(move || serve(listener, ctx));
socket
}
}
/// Sends one turn and returns its `TurnDone`'s `untrusted`. Any other final frame fails the test.
fn untrusted(socket: &Path, session: &str, content: &str, resume: bool) -> bool {
let mut stream = UnixStream::connect(socket).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
let msg = Message::Turn(Turn {
session: SessionId::new(session).unwrap(),
content: content.to_string(),
resume,
});
let turn = Envelope {
v: PROTOCOL_VERSION,
id: 7,
r#final: true,
msg,
};
write_frame(&mut stream, &turn).unwrap();
loop {
let frame = read_frame(&mut stream).unwrap();
if !frame.r#final {
continue;
}
match frame.msg {
Message::TurnDone(done) => return done.untrusted,
other => panic!("the turn did not end with turn_done: {other:?}"),
}
}
}
/// A result as `brokerd` answers it, with its label.
fn result(content: &str, untrusted: bool) -> ToolResponse {
ToolResponse::Result {
content: content.to_string(),
class: DataClass::Private,
untrusted,
truncated: false,
}
}
/// The model calls `recall`; the fake port stands for `brokerd` and `memoryd` behind it.
fn recall() -> Reply {
stream(
&[call("call-r", "recall", r#"{"query":"deploy"}"#)],
"tool_calls",
)
}
#[test]
fn a_clean_session_s_turns_carry_false() {
let s = Setup::new(vec![Reply::fixture("turn1"), Reply::fixture("turn2")]);
let socket = s.loopd("loop.sock", vec![]);
assert!(!untrusted(&socket, "a", "hello", false), "turn 1");
assert!(!untrusted(&socket, "a", "and again", true), "turn 2");
}
#[test]
fn trusted_results_denials_and_failures_keep_it_false() {
let s = Setup::new(vec![
Reply::fixture("tool_call"),
Reply::fixture("plain"),
Reply::fixture("tool_call"),
Reply::fixture("plain"),
Reply::fixture("tool_call"),
Reply::fixture("plain"),
]);
let socket = s.loopd(
"loop.sock",
vec![
result("straylight\n", false),
ToolResponse::Denied {
reason: DenyReason::NoGrant,
detail: None,
},
ToolResponse::Failed {
message: "the container could not start".to_string(),
},
],
);
assert!(
!untrusted(&socket, "a", "hostname?", false),
"a trusted result"
);
assert!(!untrusted(&socket, "a", "again?", true), "a denial");
assert!(!untrusted(&socket, "a", "once more?", true), "a failure");
}
#[test]
fn an_untrusted_result_makes_its_turn_and_every_later_one_true() {
let s = Setup::new(vec![
Reply::fixture("plain"),
Reply::fixture("tool_call"),
Reply::fixture("plain"),
Reply::fixture("tool_call"),
Reply::fixture("plain"),
]);
let socket = s.loopd(
"loop.sock",
vec![
result("a fetched page\n", true),
result("straylight\n", false),
],
);
assert!(!untrusted(&socket, "a", "hello", false), "before the page");
assert!(untrusted(&socket, "a", "read it", true), "the page's turn");
assert!(
untrusted(&socket, "a", "hostname?", true),
"a later turn whose own result is trusted"
);
assert!(untrusted(&socket, "a", "thanks", true), "a later turn");
assert!(
!untrusted(&socket, "b", "hello", false),
"another session is not touched"
);
}
#[test]
fn a_recalled_memory_tainted_note_makes_it_true() {
let s = Setup::new(vec![recall(), Reply::fixture("plain")]);
let hit = "[1] memory-tainted · note, 2026-09-25 14:02, session chat-7 · private\n\
the deploy runs from ci.example.org";
let socket = s.loopd("loop.sock", vec![result(hit, true)]);
assert!(
untrusted(&socket, "a", "how do we deploy?", false),
"the recall's turn"
);
assert!(untrusted(&socket, "a", "thanks", true), "a later turn");
}
#[test]
fn a_recall_of_trusted_memory_keeps_it_false() {
let s = Setup::new(vec![recall(), Reply::fixture("plain")]);
let hit = "[1] identity · moved 2026-09-25 from memory.md · private\n\
deploys go through ci.example.org";
let socket = s.loopd("loop.sock", vec![result(hit, false)]);
assert!(!untrusted(&socket, "a", "how do we deploy?", false));
}
#[test]
fn a_restarted_loopd_reads_it_from_the_log() {
let s = Setup::new(vec![Reply::fixture("tool_call"), Reply::fixture("plain")]);
let first = s.loopd("first.sock", vec![result("a fetched page\n", true)]);
assert!(untrusted(&first, "a", "read it", false), "the first loopd");
let second = s.loopd("second.sock", vec![]);
assert!(
untrusted(&second, "a", "thanks", true),
"the second loopd, on the same session"
);
assert!(
!untrusted(&second, "b", "hello", false),
"the second loopd, on a new session"
);
}