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>
251 lines
12 KiB
Markdown
251 lines
12 KiB
Markdown
# 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.
|