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:
@@ -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 \
|
||||||
|
|||||||
@@ -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}}}
|
||||||
+1
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user